diff --git a/manifest.json b/manifest.json index d07bdc9..7e88b3a 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "longform-paperbell", "name": "Longform (PaperBell)", - "version": "2.2.0-beta.4", + "version": "2.2.0-beta.6", "minAppVersion": "1.0", "description": "Write novels, screenplays, and other long projects in Obsidian.", "author": "Kevin Barrett", diff --git a/package-lock.json b/package-lock.json index d831e6e..d2e740a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "longform", - "version": "2.2.0-beta.3", + "version": "2.2.0-beta.6", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "longform", - "version": "2.2.0-beta.3", + "version": "2.2.0-beta.6", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "@popperjs/core": "^2.11.2", diff --git a/package.json b/package.json index ecf069f..d8b725a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "longform", - "version": "2.2.0-beta.4", + "version": "2.2.0-beta.6", "description": "Write novels, screenplays, and other long projects in Obsidian (https://obsidian.md).", "main": "main.js", "scripts": { diff --git a/src/commands/compile.ts b/src/commands/compile.ts index df5bca0..e2b50c8 100644 --- a/src/commands/compile.ts +++ b/src/commands/compile.ts @@ -16,6 +16,7 @@ import { } from "src/compile"; import { JumpModal } from "./helpers"; import { draftTitle } from "src/model/draft-utils"; +import { projectRootPath } from "src/model/project-resources"; import type { Draft } from "src/model/types"; export const compileCurrent: CommandBuilder = (plugin) => ({ @@ -46,12 +47,17 @@ export const compileCurrent: CommandBuilder = (plugin) => ({ } } + const projectRoot = projectRootPath( + get(projects)[draft.title] ?? [draft] + ); + compile( plugin.app, draft, workflow, calculatedKinds, - onCompileStatusChange + onCompileStatusChange, + { projectRoot } ); }, }); @@ -162,7 +168,8 @@ export const compileSelection: CommandBuilder = (plugin) => ({ draft, workflow, calculatedKinds, - onCompileStatusChange + onCompileStatusChange, + { projectRoot: projectRootPath(project) } ); } ).open(); diff --git a/src/compile/index.ts b/src/compile/index.ts index fdcc88e..1c6fd0d 100644 --- a/src/compile/index.ts +++ b/src/compile/index.ts @@ -186,7 +186,7 @@ export async function compile( workflow: Workflow, kinds: CompileStepKind[], statusCallback: (status: CompileStatus) => void, - options?: { suppressOpenAfter?: boolean } + options?: { suppressOpenAfter?: boolean; projectRoot?: string } ): Promise { let currentInput: any; @@ -251,6 +251,7 @@ export async function compile( normalizePath, }, suppressOpenAfter: options?.suppressOpenAfter, + projectRoot: options?.projectRoot, }; console.log( diff --git a/src/compile/steps/abstract-compile-step.ts b/src/compile/steps/abstract-compile-step.ts index e3ea023..d2163ac 100644 --- a/src/compile/steps/abstract-compile-step.ts +++ b/src/compile/steps/abstract-compile-step.ts @@ -137,6 +137,13 @@ export type CompileContext = { * Set during batch ("Compile All Drafts") runs to avoid spawning a pane per draft. */ suppressOpenAfter?: boolean; + /** + * The project root: the lowest common ancestor folder of the project's drafts. + * Steps that resolve shared resources (e.g. metadata.json) search from the + * draft's folder up to this root, inclusive. When absent, only the draft's own + * folder (`projectPath`) is searched. + */ + projectRoot?: string; }; /** diff --git a/src/compile/steps/add-zenodo-frontmatter.ts b/src/compile/steps/add-zenodo-frontmatter.ts index dbe491a..68255c1 100644 --- a/src/compile/steps/add-zenodo-frontmatter.ts +++ b/src/compile/steps/add-zenodo-frontmatter.ts @@ -9,6 +9,7 @@ import { buildPandocYaml, type ZenodoMetadata, } from "./add-zenodo-frontmatter-utils"; +import { projectResourceCandidatePaths } from "src/model/project-resources"; export const AddZenodoFrontmatterStep = makeBuiltinStep({ id: "add-zenodo-frontmatter", @@ -22,7 +23,7 @@ export const AddZenodoFrontmatterStep = makeBuiltinStep({ id: "metadata-file", name: "Metadata file", description: - "Filename of the Zenodo deposition metadata JSON in your project folder (or its 'source/' subfolder). Trailing '.json' is optional.", + "Filename of the Zenodo deposition metadata JSON. Searched for in the draft's folder (or its 'source/' subfolder) and any parent folder up to the project root, so multiple drafts can share one file. Trailing '.json' is optional.", type: CompileStepOptionType.Text, default: "metadata.json", }, @@ -55,10 +56,11 @@ export const AddZenodoFrontmatterStep = makeBuiltinStep({ ? metaFileName : `${metaFileName}.json`; - const candidatePaths = [ - `${context.projectPath}/${baseName}`, - `${context.projectPath}/source/${baseName}`, - ]; + const candidatePaths = projectResourceCandidatePaths( + context.projectPath, + context.projectRoot ?? context.projectPath, + baseName + ); let file: TFile | null = null; let foundPath = ""; diff --git a/src/compile/steps/replace-json-placeholders.ts b/src/compile/steps/replace-json-placeholders.ts index 01ccf85..7c5fd56 100644 --- a/src/compile/steps/replace-json-placeholders.ts +++ b/src/compile/steps/replace-json-placeholders.ts @@ -9,6 +9,7 @@ import { buildPlaceholderRegex, getByPath, } from "./replace-json-placeholders-utils"; +import { projectResourceCandidatePaths } from "src/model/project-resources"; export const ReplaceJsonPlaceholdersStep = makeBuiltinStep({ id: "replace-json-placeholders", @@ -22,7 +23,7 @@ export const ReplaceJsonPlaceholdersStep = makeBuiltinStep({ id: "json-file", name: "JSON file", description: - "Filename of the JSON data file in your project folder (or its 'source/' subfolder). Trailing '.json' is optional.", + "Filename of the JSON data file. Searched for in the draft's folder (or its 'source/' subfolder) and any parent folder up to the project root. Trailing '.json' is optional.", type: CompileStepOptionType.Text, default: "results.json", }, @@ -69,10 +70,11 @@ export const ReplaceJsonPlaceholdersStep = makeBuiltinStep({ ? jsonFileName : `${jsonFileName}.json`; - const candidatePaths = [ - `${context.projectPath}/${baseName}`, - `${context.projectPath}/source/${baseName}`, - ]; + const candidatePaths = projectResourceCandidatePaths( + context.projectPath, + context.projectRoot ?? context.projectPath, + baseName + ); let file: TFile | null = null; let foundPath = ""; diff --git a/src/compile/steps/write-to-note-utils.ts b/src/compile/steps/write-to-note-utils.ts new file mode 100644 index 0000000..4515b36 --- /dev/null +++ b/src/compile/steps/write-to-note-utils.ts @@ -0,0 +1,26 @@ +import type { Draft } from "src/model/types"; + +/** + * The per-draft name used by the `$2` placeholder in the Save-as-Note output path. + * Uses the draft's explicit `draftTitle` when set, otherwise falls back to the + * index file's basename (without the `.md` extension) so the name is always + * distinct across the drafts of a project. + */ +export function draftOutputName(draft: Draft): string { + const indexBasename = (draft.vaultPath.split("/").pop() ?? "").replace( + /\.md$/, + "" + ); + return draft.draftTitle ?? indexBasename; +} + +/** + * Substitutes the Save-as-Note output-path placeholders for a given draft: + * - `$1` → the project title (shared across a project's drafts) + * - `$2` → this draft's name (see {@link draftOutputName}) + * All occurrences of each token are replaced. + */ +export function applyTargetPlaceholders(target: string, draft: Draft): string { + const draftName = draftOutputName(draft); + return target.split("$2").join(draftName).split("$1").join(draft.title); +} diff --git a/src/compile/steps/write-to-note.ts b/src/compile/steps/write-to-note.ts index d5eb249..aed1bc3 100644 --- a/src/compile/steps/write-to-note.ts +++ b/src/compile/steps/write-to-note.ts @@ -5,6 +5,7 @@ import { CompileStepOptionType, makeBuiltinStep, } from "./abstract-compile-step"; +import { applyTargetPlaceholders } from "./write-to-note-utils"; export const WriteToNoteStep = makeBuiltinStep({ id: "write-to-note", @@ -37,17 +38,10 @@ export const WriteToNoteStep = makeBuiltinStep({ if (context.kind !== CompileStepKind.Manuscript) { throw new Error("Cannot write non-manuscript as note."); } else { - const indexBasename = context.draft.vaultPath - .split("/") - .last() - .replace(/\.md$/, ""); - const draftName = context.draft.draftTitle ?? indexBasename; - let target = context.optionValues["target"] as string; - target = target - .split("$2") - .join(draftName) - .split("$1") - .join(context.draft.title); + const target = applyTargetPlaceholders( + context.optionValues["target"] as string, + context.draft + ); const openAfter = context.optionValues["open-after"] as boolean; if (!target || target.length == 0) { diff --git a/src/model/project-resources.ts b/src/model/project-resources.ts new file mode 100644 index 0000000..ca7afc0 --- /dev/null +++ b/src/model/project-resources.ts @@ -0,0 +1,65 @@ +import type { Draft } from "./types"; + +/** The folder containing an index note, derived from its vault path. */ +export function draftParentFolder(vaultPath: string): string { + return vaultPath.split("/").slice(0, -1).join("/"); +} + +/** + * The lowest common ancestor folder shared by a set of folder paths, computed + * segment-wise. Returns "" (the vault root) when there is no shared prefix. + */ +export function lowestCommonAncestorFolder(folders: string[]): string { + if (folders.length === 0) return ""; + const split = folders.map((f) => f.split("/").filter((s) => s.length > 0)); + let common = split[0]; + for (const segs of split.slice(1)) { + let i = 0; + while (i < common.length && i < segs.length && common[i] === segs[i]) { + i++; + } + common = common.slice(0, i); + } + return common.join("/"); +} + +/** + * The "project root" for a set of drafts: the lowest common ancestor of every + * draft's folder. Shared resources (e.g. metadata.json) are searched for between + * a draft's own folder and this root, inclusive. + */ +export function projectRootPath(projectDrafts: Draft[]): string { + return lowestCommonAncestorFolder( + projectDrafts.map((d) => draftParentFolder(d.vaultPath)) + ); +} + +/** + * Ordered candidate paths for a named resource, searched from `startDir` upward + * to `rootDir` (inclusive). At each level both `/` and + * `/source/` are produced. When `rootDir` is not an ancestor of + * (or equal to) `startDir`, only `startDir` is searched — so callers that don't + * know a project root degrade to the original single-folder behavior. + */ +export function projectResourceCandidatePaths( + startDir: string, + rootDir: string, + baseName: string +): string[] { + const startSegs = startDir.split("/").filter((s) => s.length > 0); + const rootSegs = (rootDir ?? "").split("/").filter((s) => s.length > 0); + + const rootIsAncestor = + rootSegs.length <= startSegs.length && + rootSegs.every((s, i) => s === startSegs[i]); + const minLen = rootIsAncestor ? rootSegs.length : startSegs.length; + + const candidates: string[] = []; + for (let len = startSegs.length; len >= minLen; len--) { + const dir = startSegs.slice(0, len).join("/"); + const prefix = dir.length > 0 ? `${dir}/` : ""; + candidates.push(`${prefix}${baseName}`); + candidates.push(`${prefix}source/${baseName}`); + } + return candidates; +} diff --git a/src/view/compile/CompileView.svelte b/src/view/compile/CompileView.svelte index b6ccc45..0fcd55e 100644 --- a/src/view/compile/CompileView.svelte +++ b/src/view/compile/CompileView.svelte @@ -22,6 +22,7 @@ selectedProjectHasMultipleDrafts, } from "src/model/stores"; import { draftTitle } from "src/model/draft-utils"; + import { projectRootPath } from "src/model/project-resources"; import CompileStepView from "./CompileStepView.svelte"; import SortableList from "../sortable/SortableList.svelte"; import AutoTextArea from "../components/AutoTextArea.svelte"; @@ -245,17 +246,19 @@ workflow: Workflow, kinds: CompileStepKind[], statusCallback: (status: CompileStatus) => void, - options?: { suppressOpenAfter?: boolean } + options?: { suppressOpenAfter?: boolean; projectRoot?: string } ) => Promise = getContext("compile"); let isCompiling = false; function doCompile() { + const projectRoot = projectRootPath($selectedProject ?? [$selectedDraft]); compile( $selectedDraft, $currentWorkflow, calculatedKinds, - onCompileStatusChange + onCompileStatusChange, + { projectRoot } ); } @@ -265,6 +268,8 @@ return; } + const projectRoot = projectRootPath(projectDrafts); + isCompiling = true; let compiledCount = 0; @@ -304,6 +309,7 @@ try { await compile(draft, workflow, kinds, wrappedStatus, { suppressOpenAfter: true, + projectRoot, }); compiledCount++; } catch (error) { diff --git a/src/view/explorer/ExplorerPane.ts b/src/view/explorer/ExplorerPane.ts index 2f2ec5a..b487abb 100644 --- a/src/view/explorer/ExplorerPane.ts +++ b/src/view/explorer/ExplorerPane.ts @@ -18,7 +18,13 @@ import ExplorerView from "./ExplorerView.svelte"; import { scenePath } from "src/model/scene-navigation"; import { migrate } from "src/model/migration"; import { get } from "svelte/store"; -import { drafts, pluginSettings, selectedDraft } from "src/model/stores"; +import { + drafts, + pluginSettings, + selectedDraft, + selectedProject, +} from "src/model/stores"; +import { projectRootPath } from "src/model/project-resources"; import { insertScene } from "src/model/draft-utils"; import NewDraftModal from "src/view/project-lifecycle/new-draft-modal"; import MetadataModal from "src/view/metadata-modal"; @@ -326,10 +332,7 @@ export class ExplorerPane extends ItemView { context.set("showMetadataModal", () => { const draft = get(selectedDraft); if (!draft) return; - const projectPath = draft.vaultPath - .split("/") - .slice(0, -1) - .join("/"); + const projectPath = projectRootPath(get(selectedProject) ?? [draft]); new MetadataModal(this.app, projectPath, draft.title).open(); }); diff --git a/test/compile/steps/write-to-note.test.ts b/test/compile/steps/write-to-note.test.ts new file mode 100644 index 0000000..af450e2 --- /dev/null +++ b/test/compile/steps/write-to-note.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { + applyTargetPlaceholders, + draftOutputName, +} from "src/compile/steps/write-to-note-utils"; +import type { Draft } from "src/model/types"; + +function makeDraft(overrides: Partial = {}): Draft { + return { + format: "scenes", + title: "PaperDraft", + titleInFrontmatter: true, + draftTitle: "Main Manuscript", + vaultPath: "submission-project/Main Manuscript (Index).md", + workflow: "Default Workflow", + sceneFolder: "manuscript", + scenes: [], + ignoredFiles: [], + unknownFiles: [], + sceneTemplate: null, + ...overrides, + } as Draft; +} + +describe("draftOutputName", () => { + it("uses draftTitle when present", () => { + expect(draftOutputName(makeDraft({ draftTitle: "Cover Letter" }))).toBe( + "Cover Letter" + ); + }); + + it("falls back to the index basename (without .md) when draftTitle is null", () => { + expect( + draftOutputName( + makeDraft({ + draftTitle: null, + vaultPath: "submission-project/Main Manuscript (Index).md", + }) + ) + ).toBe("Main Manuscript (Index)"); + }); + + it("handles a vaultPath with no folder segment", () => { + expect( + draftOutputName(makeDraft({ draftTitle: null, vaultPath: "lonely.md" })) + ).toBe("lonely"); + }); +}); + +describe("applyTargetPlaceholders", () => { + it("replaces $2 with the draft name", () => { + expect( + applyTargetPlaceholders("compiled/$2.md", makeDraft({ draftTitle: "Cover Letter" })) + ).toBe("compiled/Cover Letter.md"); + }); + + it("replaces $1 with the project title", () => { + expect(applyTargetPlaceholders("$1.md", makeDraft())).toBe("PaperDraft.md"); + }); + + it("replaces both $1 and $2 in one target", () => { + expect( + applyTargetPlaceholders( + "out/$1 - $2.md", + makeDraft({ title: "PaperDraft", draftTitle: "Response to Reviewers" }) + ) + ).toBe("out/PaperDraft - Response to Reviewers.md"); + }); + + it("replaces every occurrence of a token", () => { + expect( + applyTargetPlaceholders("$2/$2.md", makeDraft({ draftTitle: "Cover" })) + ).toBe("Cover/Cover.md"); + }); + + it("leaves a target without placeholders untouched", () => { + expect(applyTargetPlaceholders("manuscript.md", makeDraft())).toBe( + "manuscript.md" + ); + }); + + it("uses the index basename for $2 when draftTitle is null", () => { + expect( + applyTargetPlaceholders( + "$2.md", + makeDraft({ + draftTitle: null, + vaultPath: "project/An Essay.md", + }) + ) + ).toBe("An Essay.md"); + }); + + it("produces distinct names for sibling drafts of the same project", () => { + const drafts = [ + makeDraft({ draftTitle: "Main Manuscript" }), + makeDraft({ draftTitle: "Supplementary Materials" }), + makeDraft({ draftTitle: "Response to Reviewers" }), + makeDraft({ draftTitle: "Cover Letter" }), + ]; + const outputs = drafts.map((d) => applyTargetPlaceholders("compiled/$2.md", d)); + expect(new Set(outputs).size).toBe(drafts.length); + expect(outputs).toEqual([ + "compiled/Main Manuscript.md", + "compiled/Supplementary Materials.md", + "compiled/Response to Reviewers.md", + "compiled/Cover Letter.md", + ]); + }); +}); diff --git a/test/model/project-resources.test.ts b/test/model/project-resources.test.ts new file mode 100644 index 0000000..e3073bd --- /dev/null +++ b/test/model/project-resources.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; +import { + draftParentFolder, + lowestCommonAncestorFolder, + projectResourceCandidatePaths, + projectRootPath, +} from "src/model/project-resources"; +import type { Draft } from "src/model/types"; + +function draftAt(vaultPath: string): Draft { + return { + format: "scenes", + title: "Project", + titleInFrontmatter: true, + draftTitle: null, + vaultPath, + workflow: "Default Workflow", + sceneFolder: "/", + scenes: [], + ignoredFiles: [], + unknownFiles: [], + sceneTemplate: null, + } as Draft; +} + +describe("draftParentFolder", () => { + it("returns the folder containing the index", () => { + expect(draftParentFolder("project/draft A/A index.md")).toBe( + "project/draft A" + ); + }); + + it("returns empty string for a vault-root index", () => { + expect(draftParentFolder("index.md")).toBe(""); + }); +}); + +describe("lowestCommonAncestorFolder", () => { + it("finds the common ancestor of sibling folders", () => { + expect( + lowestCommonAncestorFolder(["project/draft A", "project/draft B"]) + ).toBe("project"); + }); + + it("returns the folder itself when all paths are identical", () => { + expect( + lowestCommonAncestorFolder(["submission", "submission", "submission"]) + ).toBe("submission"); + }); + + it("returns empty string when there is no shared prefix", () => { + expect(lowestCommonAncestorFolder(["alpha/x", "beta/y"])).toBe(""); + }); + + it("does not treat a partial segment match as common", () => { + // "project" and "project-two" share characters but not a path segment. + expect( + lowestCommonAncestorFolder(["project/a", "project-two/b"]) + ).toBe(""); + }); + + it("handles a single folder", () => { + expect(lowestCommonAncestorFolder(["a/b/c"])).toBe("a/b/c"); + }); + + it("returns empty for an empty list", () => { + expect(lowestCommonAncestorFolder([])).toBe(""); + }); +}); + +describe("projectRootPath", () => { + it("is the common ancestor of every draft's folder", () => { + const root = projectRootPath([ + draftAt("project/draft A/A index.md"), + draftAt("project/draft B/B index.md"), + ]); + expect(root).toBe("project"); + }); + + it("equals the shared folder when all indexes sit together", () => { + const root = projectRootPath([ + draftAt("submission/Main (Index).md"), + draftAt("submission/Cover (Index).md"), + ]); + expect(root).toBe("submission"); + }); + + it("is the index folder for a single-draft project", () => { + expect(projectRootPath([draftAt("projects/An Essay.md")])).toBe("projects"); + }); +}); + +describe("projectResourceCandidatePaths", () => { + it("walks from the draft folder up to the project root, inclusive", () => { + expect( + projectResourceCandidatePaths( + "project/draft A", + "project", + "metadata.json" + ) + ).toEqual([ + "project/draft A/metadata.json", + "project/draft A/source/metadata.json", + "project/metadata.json", + "project/source/metadata.json", + ]); + }); + + it("stops at the project root and never reaches the vault root", () => { + const paths = projectResourceCandidatePaths( + "a/b/c/draft", + "a/b", + "metadata.json" + ); + expect(paths).toContain("a/b/metadata.json"); + expect(paths).not.toContain("a/metadata.json"); + expect(paths).not.toContain("metadata.json"); + }); + + it("searches only the start folder when root equals it (no regression)", () => { + expect( + projectResourceCandidatePaths("project", "project", "metadata.json") + ).toEqual(["project/metadata.json", "project/source/metadata.json"]); + }); + + it("searches only the start folder when root is not an ancestor", () => { + expect( + projectResourceCandidatePaths("project/a", "elsewhere", "metadata.json") + ).toEqual([ + "project/a/metadata.json", + "project/a/source/metadata.json", + ]); + }); + + it("handles a vault-root resource (empty dirs)", () => { + expect(projectResourceCandidatePaths("", "", "metadata.json")).toEqual([ + "metadata.json", + "source/metadata.json", + ]); + }); + + it("respects a custom base filename", () => { + expect( + projectResourceCandidatePaths("p/d", "p", "results.json") + ).toEqual([ + "p/d/results.json", + "p/d/source/results.json", + "p/results.json", + "p/source/results.json", + ]); + }); +}); diff --git a/versions.json b/versions.json index 1e2d717..104fb65 100644 --- a/versions.json +++ b/versions.json @@ -16,5 +16,7 @@ "2.2.0-beta.1": "1.0", "2.2.0-beta.2": "1.0", "2.2.0-beta.3": "1.0", - "2.2.0-beta.4": "1.0" + "2.2.0-beta.4": "1.0", + "2.2.0-beta.5": "1.0", + "2.2.0-beta.6": "1.0" }