From 92cafb8f80373832e3d0f9568ad13347446e3fba Mon Sep 17 00:00:00 2001 From: SongshGeo Date: Sat, 6 Jun 2026 14:40:02 +0200 Subject: [PATCH 1/4] release: bump version to 2.2.0-beta.5 Prepares the multi-draft quick-compile feature for release. Tag 2.2.0-beta.5 should be pushed after this merges to main to trigger release.yml (manifest version must match the tag). Co-Authored-By: Claude Opus 4.8 --- manifest.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- versions.json | 3 ++- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/manifest.json b/manifest.json index d07bdc9..7d45a96 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.5", "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..d4ec056 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.5", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "longform", - "version": "2.2.0-beta.3", + "version": "2.2.0-beta.5", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "@popperjs/core": "^2.11.2", diff --git a/package.json b/package.json index ecf069f..7c51c16 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "longform", - "version": "2.2.0-beta.4", + "version": "2.2.0-beta.5", "description": "Write novels, screenplays, and other long projects in Obsidian (https://obsidian.md).", "main": "main.js", "scripts": { diff --git a/versions.json b/versions.json index 1e2d717..c8ac7c9 100644 --- a/versions.json +++ b/versions.json @@ -16,5 +16,6 @@ "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" } From c86b940772e1ebf134afa3bf6b089991ddf55a9e Mon Sep 17 00:00:00 2001 From: SongshGeo Date: Sat, 6 Jun 2026 14:44:37 +0200 Subject: [PATCH 2/4] test: cover Save-as-Note $1/$2 path placeholders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the output-path placeholder substitution into an obsidian-free write-to-note-utils module (matching the existing *-utils test pattern) and add unit tests for draftOutputName and applyTargetPlaceholders: $2 → draft name (draftTitle, falling back to the index basename), $1 → project title, global replacement, and distinct names across the drafts of a project. Co-Authored-By: Claude Opus 4.8 --- src/compile/steps/write-to-note-utils.ts | 26 ++++++ src/compile/steps/write-to-note.ts | 16 ++-- test/compile/steps/write-to-note.test.ts | 110 +++++++++++++++++++++++ 3 files changed, 141 insertions(+), 11 deletions(-) create mode 100644 src/compile/steps/write-to-note-utils.ts create mode 100644 test/compile/steps/write-to-note.test.ts 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/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", + ]); + }); +}); From 01d11c3135dcc65e8c7bba80a76aefc6c8374398 Mon Sep 17 00:00:00 2001 From: SongshGeo Date: Sat, 6 Jun 2026 16:06:45 +0200 Subject: [PATCH 3/4] feat: resolve shared metadata.json up to the project root Shared resource files (metadata.json for Add Zenodo Frontmatter, JSON data for Replace JSON Placeholders) were only looked up in the index note's own folder. Drafts whose index lives in a per-draft subfolder could not see a metadata.json placed at the project root, causing "metadata file not found" errors when compiling such drafts. Introduce a project root = lowest common ancestor of a project's draft folders, and search for shared resources from the draft's folder up to that root (inclusive), checking both / and /source/. The search is bounded at the project root, so it never picks up an unrelated file higher in the vault. Single-draft and same-folder projects are unaffected (root == index folder => one level only). Supports project layouts like: project/ metadata.json draft A/A index.md draft B/B index.md - New obsidian-free, unit-tested util src/model/project-resources.ts (projectRootPath, projectResourceCandidatePaths, LCA helpers). - Thread projectRoot through CompileContext and compile() options; pass it from CompileView (single + Compile All) and both compile commands. - The metadata editor now loads/saves the shared file at the project root. Co-Authored-By: Claude Opus 4.8 --- src/commands/compile.ts | 11 +- src/compile/index.ts | 3 +- src/compile/steps/abstract-compile-step.ts | 7 + src/compile/steps/add-zenodo-frontmatter.ts | 12 +- .../steps/replace-json-placeholders.ts | 12 +- src/model/project-resources.ts | 65 ++++++++ src/view/compile/CompileView.svelte | 10 +- src/view/explorer/ExplorerPane.ts | 13 +- test/model/project-resources.test.ts | 152 ++++++++++++++++++ 9 files changed, 265 insertions(+), 20 deletions(-) create mode 100644 src/model/project-resources.ts create mode 100644 test/model/project-resources.test.ts 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/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/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", + ]); + }); +}); From f46599139aaa091cf1b231bf48e55ebb0df5e31e Mon Sep 17 00:00:00 2001 From: SongshGeo Date: Sat, 6 Jun 2026 16:08:01 +0200 Subject: [PATCH 4/4] release: bump version to 2.2.0-beta.6 Ships shared-metadata resolution up to the project root. Co-Authored-By: Claude Opus 4.8 --- manifest.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- versions.json | 3 ++- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/manifest.json b/manifest.json index 7d45a96..7e88b3a 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "longform-paperbell", "name": "Longform (PaperBell)", - "version": "2.2.0-beta.5", + "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 d4ec056..d2e740a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "longform", - "version": "2.2.0-beta.5", + "version": "2.2.0-beta.6", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "longform", - "version": "2.2.0-beta.5", + "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 7c51c16..d8b725a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "longform", - "version": "2.2.0-beta.5", + "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/versions.json b/versions.json index c8ac7c9..104fb65 100644 --- a/versions.json +++ b/versions.json @@ -17,5 +17,6 @@ "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.5": "1.0" + "2.2.0-beta.5": "1.0", + "2.2.0-beta.6": "1.0" }