From fd4cc3f4fd28b0a706803e136e6370b655dbb7a6 Mon Sep 17 00:00:00 2001 From: SongshGeo Date: Tue, 8 Sep 2026 12:36:47 +0200 Subject: [PATCH 1/6] fix(pandoc-export): show that a preset overrides the Format option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setting Format on a Run Pandoc Export step that also has a preset had no effect, and nothing said so: the only signal was a console.warn. The precedence itself is deliberate and documented — this makes it visible. - The step editor greys the Format control out while a preset is set and says which preset decided the format. Declared as a generic `disabledBy` / `disabledDescription` pair on CompileStepOption, resolved by one shared helper both option renderers now use. - The Template / preset dropdown labels each preset with what it produces (`paperbell — PDF`, `manuscript-obsidian — DOCX`), read from the preset's own yaml via the existing exportTargetForDefaults. Best-effort: an unreadable preset keeps its bare name. - A Notice on export covers the paths the editor can't: workflows saved before this, and the headless `Run workflow: ` command. Closes #38 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ivDqLS8ZQvs3gVQ7qeJGC --- docs/PANDOC_EXPORT.md | 8 ++ src/compile/steps/abstract-compile-step.ts | 15 +++ src/compile/steps/pandoc-export.ts | 15 ++- src/model/pandoc-templates.ts | 46 +++++++- src/model/stores.ts | 13 +- src/view/compile/CompileStepView.svelte | 12 +- .../compile-matrix/CompileMatrix.svelte | 15 ++- src/view/compile/option-display.ts | 78 ++++++++++++ test/view/option-display.test.ts | 111 ++++++++++++++++++ 9 files changed, 294 insertions(+), 19 deletions(-) create mode 100644 src/view/compile/option-display.ts create mode 100644 test/view/option-display.test.ts diff --git a/docs/PANDOC_EXPORT.md b/docs/PANDOC_EXPORT.md index 83fd24a..5f86e9a 100644 --- a/docs/PANDOC_EXPORT.md +++ b/docs/PANDOC_EXPORT.md @@ -72,6 +72,14 @@ four *PaperBell …* workflows are unaffected — they leave *Format* blank on purpose, so their preset is still required and a missing one fails loudly instead of silently dropping the submission layout. +Rule 1 is visible in the step editor rather than only in the result: pick a +preset and the *Format* control greys out, saying which preset decided the format +for you. Each preset in the dropdown is labelled with what it produces — +`paperbell — PDF`, `manuscript-obsidian — DOCX` — so picking the one that gives +you a Word file no longer means opening its yaml. A workflow saved before this, +or run from its `Run workflow: ` command, says the same thing in a notice +when it exports. + If something is missing, the error dialog lists what's needed, which presets are installed, and offers buttons to jump straight to **Set up Pandoc export** or the asset marketplace. diff --git a/src/compile/steps/abstract-compile-step.ts b/src/compile/steps/abstract-compile-step.ts index 68026ee..2f35bd1 100644 --- a/src/compile/steps/abstract-compile-step.ts +++ b/src/compile/steps/abstract-compile-step.ts @@ -75,6 +75,21 @@ export interface CompileStepOption { * lets the step fall back to its own default behavior. Defaults to "(default)". */ emptyLabel?: string; + /** + * The id of another option **of the same step** that outranks this one: while + * that option holds a value, this control is disabled in the compile UI and + * shows {@link disabledDescription} instead of its own. + * + * For precedences the step already enforces at compile time. Declaring it here + * is what makes the precedence visible *before* the export, rather than only in + * a console warning nobody reads. + */ + disabledBy?: string; + /** + * Shown in place of `description` while {@link disabledBy} holds a value. + * `{value}` is replaced with that value. Ignored without `disabledBy`. + */ + disabledDescription?: string; } /** diff --git a/src/compile/steps/pandoc-export.ts b/src/compile/steps/pandoc-export.ts index 05a957f..9bf5ae3 100644 --- a/src/compile/steps/pandoc-export.ts +++ b/src/compile/steps/pandoc-export.ts @@ -101,7 +101,7 @@ function missingPresetHelp( template: string, templateSource: string ): string { - const installed = listPandocTemplates(app); + const installed = listPandocTemplates(app).map((t) => t.name); const where = `The preset "${template}" comes from ${templateSource}.`; if (installed.length === 0) { return ( @@ -176,6 +176,9 @@ export const RunPandocExportStep = makeBuiltinStep({ choices: [...BUILTIN_FORMATS], emptyLabel: "(require a preset)", default: "", + disabledBy: "template", + disabledDescription: + 'Ignored — the preset "{value}" decides the output format (the Template / preset dropdown above names it). Clear the preset to export with Format instead.', }, { id: "filename", @@ -252,6 +255,16 @@ export const RunPandocExportStep = makeBuiltinStep({ `("${formatOption}") are set on this step; the preset wins. Clear the ` + `Template / preset option to export with Format instead.` ); + // The step editor greys the Format control out while a preset is set, but + // that only reaches someone who opens it: a workflow saved before it did, + // or run headlessly from the `Run workflow: ` command, still arrives + // here with both. Say it where a writer will actually see it. + new Notice( + `PaperOut: the preset "${optionTemplate}" decides the format — the ` + + `Format option ("${formatOption}") is ignored. Clear the preset to ` + + `export with Format instead.`, + 8000 + ); } // Unused in built-in mode: no preset is read, and `{template}` must not diff --git a/src/model/pandoc-templates.ts b/src/model/pandoc-templates.ts index e882e24..e1411ef 100644 --- a/src/model/pandoc-templates.ts +++ b/src/model/pandoc-templates.ts @@ -1,4 +1,4 @@ -import { App, FileSystemAdapter } from "obsidian"; +import { App, FileSystemAdapter, parseYaml } from "obsidian"; import { get } from "svelte/store"; import * as fs from "fs"; import * as path from "path"; @@ -7,9 +7,22 @@ import { pandocTemplates, pluginSettings } from "./stores"; import { currentPlatformEnv, DEFAULT_ASSETS_DIR, + exportTargetForDefaults, resolveUserPath, } from "src/compile/steps/pandoc-export-utils"; +/** + * A downloaded preset, with the file extension it exports to. Knowing the + * extension is what lets the compile UI label a preset `paperbell — PDF` — until + * now the only way to learn that a preset produces Word was to open its yaml. + */ +export interface PandocTemplateChoice { + /** Basename of the preset file, without `.yaml`. The value written to the step. */ + name: string; + /** Extension the preset exports to, e.g. `".pdf"`. Empty when it couldn't be read. */ + ext: string; +} + /** * Preset basenames that aren't user-selectable manuscript templates: `crossref` * is an include fragment, `undefined` is the no-template fallback. @@ -17,11 +30,16 @@ import { const EXCLUDED = new Set(["crossref", "undefined"]); /** - * List the downloaded Pandoc presets — the basenames (without `.yaml`) of the - * files in `/defaults/`. Desktop only (needs Node fs to read outside the - * vault); returns `[]` on mobile or if the folder can't be read. + * List the downloaded Pandoc presets — the files in `/defaults/`, by + * basename, each paired with the extension it exports to. Desktop only (needs + * Node fs to read outside the vault); returns `[]` on mobile or if the folder + * can't be read. + * + * Reading each preset is best-effort, exactly as the export step's own preflight + * is (see `pandoc-export.ts`, "assuming PDF output"): one unreadable or malformed + * yaml costs that entry its format label, never the whole list. */ -export function listPandocTemplates(app: App): string[] { +export function listPandocTemplates(app: App): PandocTemplateChoice[] { const adapter = app.vault.adapter; if (!(adapter instanceof FileSystemAdapter)) return []; @@ -33,8 +51,9 @@ export function listPandocTemplates(app: App): string[] { "defaults" ); + let names: string[]; try { - return fs + names = fs .readdirSync(defaultsDir) .filter((f) => f.endsWith(".yaml")) .map((f) => f.slice(0, -".yaml".length)) @@ -43,6 +62,21 @@ export function listPandocTemplates(app: App): string[] { } catch { return []; } + + return names.map((name) => ({ + name, + ext: presetExtension(path.join(defaultsDir, name + ".yaml")), + })); +} + +/** The extension one preset exports to, or `""` when it can't be determined. */ +function presetExtension(file: string): string { + try { + return exportTargetForDefaults(parseYaml(fs.readFileSync(file, "utf8"))).ext; + } catch (e) { + console.warn(`[Pandoc Export] Could not read preset ${file}.`, e); + return ""; + } } /** Refresh the `pandocTemplates` store from the current assets folder. */ diff --git a/src/model/stores.ts b/src/model/stores.ts index 2f561ba..0522069 100644 --- a/src/model/stores.ts +++ b/src/model/stores.ts @@ -1,6 +1,8 @@ import { derived, writable } from "svelte/store"; import { groupBy, sortBy } from "lodash"; +// Type-only, so the `pandoc-templates` → `stores` import stays the only runtime edge. +import type { PandocTemplateChoice } from "./pandoc-templates"; import type { WordCountSession, Draft, @@ -62,12 +64,13 @@ export const draftWordCounts = writable({}); export const waitingForSync = writable(false); /** - * Writeable store of the basenames (without `.yaml`) of the downloaded Pandoc - * `defaults/` presets. Populates the "Template / preset" dropdown on the Run - * Pandoc Export compile step. Refreshed on layout-ready and after assets are - * downloaded via `refreshPandocTemplates`. + * Writeable store of the downloaded Pandoc `defaults/` presets — basename + * (without `.yaml`) plus the extension each exports to. Populates the + * "Template / preset" dropdown on the Run Pandoc Export compile step, which + * labels every entry with its format. Refreshed on layout-ready and after + * assets are downloaded via `refreshPandocTemplates`. */ -export const pandocTemplates = writable([]); +export const pandocTemplates = writable([]); // DERIVED STORES diff --git a/src/view/compile/CompileStepView.svelte b/src/view/compile/CompileStepView.svelte index 74f0c76..484254e 100644 --- a/src/view/compile/CompileStepView.svelte +++ b/src/view/compile/CompileStepView.svelte @@ -10,6 +10,11 @@ } from "src/compile/steps/abstract-compile-step"; import { createEventDispatcher } from "svelte"; import { pandocTemplates } from "src/model/stores"; + import { + dropdownChoices, + optionDescription, + optionIsInert, + } from "./option-display"; export let step: CompileStep; export let ordinal: number; @@ -100,11 +105,12 @@ {:else} @@ -118,7 +124,7 @@ {/if}

- {option.description} + {optionDescription(option, step.optionValues)}

{/each} diff --git a/src/view/compile/compile-matrix/CompileMatrix.svelte b/src/view/compile/compile-matrix/CompileMatrix.svelte index 4c41d11..3650d1b 100644 --- a/src/view/compile/compile-matrix/CompileMatrix.svelte +++ b/src/view/compile/compile-matrix/CompileMatrix.svelte @@ -19,6 +19,11 @@ workflows, pandocTemplates, } from "src/model/stores"; + import { + dropdownChoices, + optionDescription, + optionIsInert, + } from "../option-display"; import { draftTitle } from "src/model/draft-utils"; import { projectRootPath } from "src/model/project-resources"; import { useApp } from "../../utils"; @@ -401,14 +406,14 @@ {:else} @@ -424,7 +429,9 @@ {/if} -

{option.description}

+

+ {optionDescription(option, step.optionValues)} +

{/each} diff --git a/src/view/compile/option-display.ts b/src/view/compile/option-display.ts new file mode 100644 index 0000000..a7f8702 --- /dev/null +++ b/src/view/compile/option-display.ts @@ -0,0 +1,78 @@ +import type { CompileStepOption } from "src/compile/steps/abstract-compile-step"; +import type { PandocTemplateChoice } from "src/model/pandoc-templates"; + +/** + * How a compile step's option is presented right now: whether another option is + * currently overriding it, what description to show, and what a dropdown's + * entries are called. + * + * Lives apart from the two components that render options — the classic compile + * pane (`CompileStepView.svelte`) and the matrix step editor + * (`compile-matrix/CompileMatrix.svelte`) — because both must agree, and because + * that is what makes the rules testable without mounting Svelte. + */ + +/** One entry of a Dropdown option: the value stored, and what the user reads. */ +export interface DropdownChoice { + value: string; + label: string; +} + +/** + * Whether `option` is currently overridden by the option it declares as + * `disabledBy`. A whitespace-only value doesn't count — the steps themselves + * trim before deciding (see `resolveBuiltinFormat`). + */ +export function optionIsInert( + option: CompileStepOption, + optionValues: Record +): boolean { + return overridingValue(option, optionValues) !== null; +} + +/** + * The description to show under `option`: its `disabledDescription` (with + * `{value}` filled in) while it is overridden, otherwise its own. + */ +export function optionDescription( + option: CompileStepOption, + optionValues: Record +): string { + const value = overridingValue(option, optionValues); + if (value === null || !option.disabledDescription) return option.description; + return option.disabledDescription.replace(/\{value\}/g, value); +} + +/** The trimmed value of the option overriding this one, or null if none does. */ +function overridingValue( + option: CompileStepOption, + optionValues: Record +): string | null { + if (!option.disabledBy) return null; + const raw = optionValues?.[option.disabledBy]; + if (typeof raw !== "string") return raw ? String(raw) : null; + const value = raw.trim(); + return value ? value : null; +} + +/** + * The entries of a Dropdown option. Static `choices` are their own labels; + * `dynamicChoices: "pandoc-templates"` resolves to the downloaded presets, each + * labelled with the format it exports to — the answer to "which of these gives + * me a Word file?", which otherwise means opening the preset's yaml. + */ +export function dropdownChoices( + option: CompileStepOption, + templates: PandocTemplateChoice[] +): DropdownChoice[] { + if (option.dynamicChoices === "pandoc-templates") { + return templates.map((t) => ({ value: t.name, label: templateLabel(t) })); + } + return (option.choices ?? []).map((c) => ({ value: c, label: c })); +} + +/** `paperbell` + `.pdf` → `paperbell — PDF`; an unread preset keeps its bare name. */ +export function templateLabel(template: PandocTemplateChoice): string { + const ext = template.ext.replace(/^\./, "").toUpperCase(); + return ext ? `${template.name} — ${ext}` : template.name; +} diff --git a/test/view/option-display.test.ts b/test/view/option-display.test.ts new file mode 100644 index 0000000..fa547da --- /dev/null +++ b/test/view/option-display.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from "vitest"; +import { + CompileStepOptionType, + type CompileStepOption, +} from "src/compile/steps/abstract-compile-step"; +import { + dropdownChoices, + optionDescription, + optionIsInert, + templateLabel, +} from "src/view/compile/option-display"; + +/** The real Run Pandoc Export `format` option, trimmed to the fields these read. */ +const FORMAT: CompileStepOption = { + id: "format", + name: "Format (no preset)", + description: "Export without any preset.", + type: CompileStepOptionType.Dropdown, + choices: ["pdf", "docx", "html"], + emptyLabel: "(require a preset)", + default: "", + disabledBy: "template", + disabledDescription: 'Ignored — the preset "{value}" decides the format.', +}; + +const TEMPLATE: CompileStepOption = { + id: "template", + name: "Template / preset", + description: "Which downloaded preset to export with.", + type: CompileStepOptionType.Dropdown, + dynamicChoices: "pandoc-templates", + default: "", +}; + +describe("optionIsInert", () => { + it("is inert exactly while the overriding option holds a value", () => { + expect(optionIsInert(FORMAT, { template: "paperbell" })).toBe(true); + expect(optionIsInert(FORMAT, { template: "" })).toBe(false); + expect(optionIsInert(FORMAT, {})).toBe(false); + }); + + it("ignores a whitespace-only value, as the step's own resolver does", () => { + expect(optionIsInert(FORMAT, { template: " " })).toBe(false); + }); + + it("leaves an option that declares no override alone", () => { + expect(optionIsInert(TEMPLATE, { template: "paperbell" })).toBe(false); + }); +}); + +describe("optionDescription", () => { + it("swaps in the disabled text, naming the preset that won", () => { + expect(optionDescription(FORMAT, { template: "paperbell" })).toBe( + 'Ignored — the preset "paperbell" decides the format.' + ); + }); + + it("keeps its own description while nothing overrides it", () => { + expect(optionDescription(FORMAT, { template: "" })).toBe( + FORMAT.description + ); + }); + + it("falls back to its own description when no disabled text was given", () => { + const option: CompileStepOption = { + ...FORMAT, + disabledDescription: undefined, + }; + expect(optionDescription(option, { template: "paperbell" })).toBe( + FORMAT.description + ); + }); +}); + +describe("dropdownChoices", () => { + it("labels each downloaded preset with the format it exports to", () => { + expect( + dropdownChoices(TEMPLATE, [ + { name: "paperbell", ext: ".pdf" }, + { name: "manuscript-obsidian", ext: ".docx" }, + ]) + ).toEqual([ + { value: "paperbell", label: "paperbell — PDF" }, + { value: "manuscript-obsidian", label: "manuscript-obsidian — DOCX" }, + ]); + }); + + it("keeps a preset whose yaml could not be read selectable, unlabelled", () => { + expect(dropdownChoices(TEMPLATE, [{ name: "broken", ext: "" }])).toEqual([ + { value: "broken", label: "broken" }, + ]); + }); + + it("passes static choices through as their own labels", () => { + expect(dropdownChoices(FORMAT, [{ name: "paperbell", ext: ".pdf" }])).toEqual([ + { value: "pdf", label: "pdf" }, + { value: "docx", label: "docx" }, + { value: "html", label: "html" }, + ]); + }); + + it("yields nothing for a dropdown with neither source", () => { + expect(dropdownChoices({ ...FORMAT, choices: undefined }, [])).toEqual([]); + }); +}); + +describe("templateLabel", () => { + it("upper-cases the extension and drops its dot", () => { + expect(templateLabel({ name: "beamer", ext: ".pdf" })).toBe("beamer — PDF"); + }); +}); From 145ad665ae115435a3fc79a568fa60866ea2392e Mon Sep 17 00:00:00 2001 From: SongshGeo Date: Tue, 8 Sep 2026 12:38:46 +0200 Subject: [PATCH 2/6] test: prove the top-level `project:` key never reaches a compiled export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verification owed by #25 that unit tests could not close. Two halves, because the workflows differ: - Manuscript / SI / Response open with Strip Frontmatter, which drops the whole yaml block — asserted directly on the step, for both scene and manuscript kinds. It had no test file of its own until now. - Cover Letter has no such step: its single Run Pandoc Export hands the note's frontmatter to pandoc on purpose, since the cover_letter template reads to/date/manuscript from it. So that one gets a real pandoc run, gated on the assets like the response-letter golden, asserting the template does read the frontmatter and that `project:` still emits nothing. Refs #28 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ivDqLS8ZQvs3gVQ7qeJGC --- test/compile/steps/strip-frontmatter.test.ts | 91 ++++++++++++++++++++ test/pandoc/cover-letter-golden.test.ts | 86 ++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 test/compile/steps/strip-frontmatter.test.ts create mode 100644 test/pandoc/cover-letter-golden.test.ts diff --git a/test/compile/steps/strip-frontmatter.test.ts b/test/compile/steps/strip-frontmatter.test.ts new file mode 100644 index 0000000..c7717d3 --- /dev/null +++ b/test/compile/steps/strip-frontmatter.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { StripFrontmatterStep } from "src/compile/steps/strip-frontmatter"; +import { + CompileStepKind, + type CompileContext, + type CompileManuscriptInput, + type CompileSceneInput, +} from "src/compile/steps/abstract-compile-step"; + +/** + * The step reads nothing from the context but `kind`, so a cast is honest here — + * building a whole App/Draft would only obscure what is under test. + */ +function context(kind: CompileStepKind): CompileContext { + return { kind } as CompileContext; +} + +/** + * A scaffolded draft index note: the `project:` key #25 added sits OUTSIDE the + * `longform:` block, as a plain top-level key. This asserts what that change's + * plan could not close by unit test — that the new key leaves with the rest of + * the frontmatter and reaches no compiled output. + */ +const INDEX_NOTE = [ + "---", + "longform:", + " format: scenes", + " title: Sea Level Memory", + " draftTitle: Main Manuscript", + " workflow: PaperBell Manuscript", + " sceneFolder: manuscript", + " scenes:", + " - introduction", + " ignoredFiles: []", + "project: ColMemo", + "---", + "", + "# Introduction", + "", + "Body text mentioning nothing sensitive.", + "", +].join("\n"); + +function scene(contents: string): CompileSceneInput { + return { + path: "Sea Level Memory/Main Manuscript.md", + name: "Main Manuscript", + contents, + metadata: {} as CompileSceneInput["metadata"], + indentationLevel: 0, + numbering: [1], + }; +} + +describe("Strip Frontmatter", () => { + it("strips the whole block, top-level `project:` included, from a scene", () => { + const [out] = StripFrontmatterStep.compile( + [scene(INDEX_NOTE)], + context(CompileStepKind.Scene) + ) as CompileSceneInput[]; + + expect(out.contents).not.toContain("project:"); + expect(out.contents).not.toContain("ColMemo"); + expect(out.contents).not.toContain("longform:"); + expect(out.contents.trim()).toBe( + "# Introduction\n\nBody text mentioning nothing sensitive." + ); + }); + + it("strips it from a joined manuscript too", () => { + const out = StripFrontmatterStep.compile( + { contents: INDEX_NOTE }, + context(CompileStepKind.Manuscript) + ) as CompileManuscriptInput; + + expect(out.contents).not.toContain("ColMemo"); + expect(out.contents.trim()).toBe( + "# Introduction\n\nBody text mentioning nothing sensitive." + ); + }); + + it("leaves a `project:` written in the body alone", () => { + // Only the frontmatter block goes; prose that happens to say the word stays. + const out = StripFrontmatterStep.compile( + { contents: "---\nproject: ColMemo\n---\n\nWe call the project: ColMemo.\n" }, + context(CompileStepKind.Manuscript) + ) as CompileManuscriptInput; + + expect(out.contents.trim()).toBe("We call the project: ColMemo."); + }); +}); diff --git a/test/pandoc/cover-letter-golden.test.ts b/test/pandoc/cover-letter-golden.test.ts new file mode 100644 index 0000000..f344bbe --- /dev/null +++ b/test/pandoc/cover-letter-golden.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { execFileSync } from "child_process"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +/** + * Owed verification from the change that added a top-level `project:` key to + * every scaffolded index note (#25, #28). + * + * The other three PaperBell workflows open with Strip Frontmatter, so their half + * is settled by a unit test (`test/compile/steps/strip-frontmatter.test.ts`). + * **Cover Letter is the exception**: its workflow is a single Run Pandoc Export, + * so the note's own frontmatter is handed to pandoc as metadata — deliberately, + * since the `cover_letter` template reads `to:` / `date:` / `manuscript:` from + * it. That makes it the one place a stray `project:` could surface, so it gets a + * real pandoc run rather than an argument that it shouldn't. + * + * Gated like the response-letter golden: skips without pandoc and the synced + * assets. LaTeX output only — no xelatex needed. + */ +function hasBin(name: string): boolean { + try { + execFileSync("which", [name], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +const ASSETS = path.resolve(process.cwd(), "pandoc-assets"); +const COVER_YAML = path.join(ASSETS, "defaults", "cover_letter.yaml"); +const present = hasBin("pandoc") && fs.existsSync(COVER_YAML); + +/** The scaffolded cover letter note, verbatim in shape, with a project picked. */ +const NOTE = [ + "---", + "longform:", + " format: single", + " title: Sea Level Memory", + " draftTitle: Cover Letter", + " workflow: PaperBell Cover Letter", + "title: Cover letter", + "manuscript: Sea Level Memory", + "acronym: SLM", + "project: ColMemo", + "date:", + "to: Dear Editor,", + "corresponding: Lastname, Firstname (you@example.com)", + "---", + "", + "We are pleased to submit our manuscript for consideration.", + "", +].join("\n"); + +describe.skipIf(!present)("cover letter golden (real pandoc)", () => { + let out = ""; + let tmp = ""; + + beforeAll(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "longform-cl-golden-")); + fs.writeFileSync(path.join(tmp, "Cover Letter.md"), NOTE); + + out = execFileSync( + "pandoc", + [path.join(tmp, "Cover Letter.md"), "--defaults=" + COVER_YAML, "-t", "latex"], + { cwd: tmp, encoding: "utf8" } + ); + }); + + afterAll(() => { + if (tmp) fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("reads the note's own frontmatter — the reason this workflow keeps it", () => { + // Proves the metadata path is live, so the assertion below is not vacuous. + expect(out).toContain("\\recipient{Dear Editor,}"); + expect(out).toContain("We are pleased to submit our manuscript"); + }); + + it("emits nothing for the top-level `project:` key", () => { + expect(out).not.toContain("ColMemo"); + // Nor the raw key, which would mean the yaml block leaked wholesale. + expect(out).not.toContain("project:"); + }); +}); From a87a7c4733349f688585b18af1c72eeb75f9d468 Mon Sep 17 00:00:00 2001 From: SongshGeo Date: Tue, 8 Sep 2026 12:40:53 +0200 Subject: [PATCH 3/6] feat(paperbell): refresh host capabilities on plugins-changed The `paperbell:plugins-changed` event constant was vendored but never subscribed. It is the only consent-free push the host gives us between ready events, so take it as a cue to re-read plugin-info: capabilities the host gained or dropped mid-session no longer sit stale until it reloads. Registers nothing and prompts for nothing. The capabilities read moves out of `attach()` into a small method both paths share. Refs #33 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ivDqLS8ZQvs3gVQ7qeJGC --- docs/PAPERBELL_INTEGRATION.md | 5 +++ src/paperbell/client.ts | 50 ++++++++++++++++++++--------- test/paperbell/client.test.ts | 59 +++++++++++++++++++++++++++++++++-- 3 files changed, 98 insertions(+), 16 deletions(-) diff --git a/docs/PAPERBELL_INTEGRATION.md b/docs/PAPERBELL_INTEGRATION.md index 16ff00d..9b1324c 100644 --- a/docs/PAPERBELL_INTEGRATION.md +++ b/docs/PAPERBELL_INTEGRATION.md @@ -43,6 +43,11 @@ invisible to the user: language the host changed while our handle was dead. A first connect stays scope-free — see *Deferred consent* under **Scopes** below. +We also subscribe to `paperbell:plugins-changed`, which the host broadcasts when its registered +sub-plugin list changes (its own use is refreshing the settings card list). We take it as a cue +to re-read `getPluginInfo()`, so capabilities the host gained or dropped mid-session don't sit +stale until the next ready event. It is consent-free and re-registers nothing. + Note there is no "same host object, skip the handshake" shortcut. Whether a reloaded host hands back a fresh `api` is its business, and guessing wrong would leave us on a dead handle forever — the exact bug this replaced. A redundant re-register costs one `unregister()` and one diff --git a/src/paperbell/client.ts b/src/paperbell/client.ts index 6075baf..24f3730 100644 --- a/src/paperbell/client.ts +++ b/src/paperbell/client.ts @@ -2,6 +2,7 @@ import type { App } from "obsidian"; import type LongformPlugin from "../main"; import { + PPB_PLUGINS_CHANGED_EVENT, PPB_READY_EVENT, PPB_SCHEMA_VERSION, type PPBHostApi, @@ -90,6 +91,22 @@ export class PaperBellClient { } }) as never) ); + + // The host broadcasts this when its registered sub-plugin list changes — a + // sibling loading or unloading. Its own use is refreshing the settings card + // list, but it is also the only consent-free push we get between host + // reloads, so we take it as a cue to re-read plugin-info: capabilities the + // host gained or dropped in the meantime would otherwise stay stale until + // the next ready event. Cheap, and it prompts for nothing. + this.plugin.registerEvent( + this.app.workspace.on(PPB_PLUGINS_CHANGED_EVENT as never, (() => { + if (!this.client) return; + const host = this.lookupHost(); + if (host) { + this.refreshCapabilities(host); + } + }) as never) + ); } private lookupHost(): PPBHostApi | null { @@ -137,23 +154,11 @@ export class PaperBellClient { } this.client = handle; - // plugin-info is consent-free; use it to gate features (e.g. llm-invoke). - let capabilities = DISCONNECTED.capabilities; - try { - capabilities = host.getPluginInfo()?.capabilities ?? []; - } catch (e) { - console.warn("[PaperOut] Could not read PaperBell plugin info:", e); - } - - this.capabilities = capabilities; // Keep whatever config we already had: on a reconnect it is the last value the host // gave us, and dropping it would flip the UI back to the fallback language for as // long as it takes to fetch a fresh one. - paperbell.update((s) => ({ - ...s, - connected: true, - capabilities, - })); + paperbell.update((s) => ({ ...s, connected: true })); + this.refreshCapabilities(host); console.log( reconnecting ? "[PaperOut] Reconnected to PaperBell host after it reloaded." @@ -179,6 +184,23 @@ export class PaperBellClient { } } + /** + * Re-read the host's advertised scopes and mirror them into the store; they gate + * features (e.g. llm-invoke). plugin-info needs no consent, so this is safe to + * call on any host signal. + */ + private refreshCapabilities(host: PPBHostApi): PPBScope[] { + let capabilities = DISCONNECTED.capabilities; + try { + capabilities = host.getPluginInfo()?.capabilities ?? []; + } catch (e) { + console.warn("[PaperOut] Could not read PaperBell plugin info:", e); + } + this.capabilities = capabilities; + paperbell.update((s) => ({ ...s, capabilities })); + return capabilities; + } + /** Whether the user has already granted us `scope`, per the host's grant list. */ private hasGrant(host: PPBHostApi, scope: PPBScope): boolean { try { diff --git a/test/paperbell/client.test.ts b/test/paperbell/client.test.ts index 404912c..ff06f07 100644 --- a/test/paperbell/client.test.ts +++ b/test/paperbell/client.test.ts @@ -3,7 +3,10 @@ import { get } from "svelte/store"; import { PaperBellClient } from "src/paperbell/client"; import { paperbell } from "src/paperbell/store"; -import { PPB_READY_EVENT } from "src/paperbell/shared-config"; +import { + PPB_PLUGINS_CHANGED_EVENT, + PPB_READY_EVENT, +} from "src/paperbell/shared-config"; import type { PaperBellRestrictedConfig, PPBCompletionResult, @@ -49,7 +52,19 @@ describe("PaperBellClient — standalone (no host)", () => { expect(get(paperbell).connected).toBe(false); // Waits for the host to announce itself later. expect(plugin.app.workspace.handlerCount(PPB_READY_EVENT)).toBe(1); - expect(plugin.registeredEventRefs).toHaveLength(1); + expect(plugin.app.workspace.handlerCount(PPB_PLUGINS_CHANGED_EVENT)).toBe(1); + // Both are registered through the plugin, so both die with it. + expect(plugin.registeredEventRefs).toHaveLength(2); + }); + + it("ignores a plugins-changed event while no host is connected", () => { + const { client, plugin } = newClient(); + client.init(); + + expect(() => + plugin.app.workspace.trigger(PPB_PLUGINS_CHANGED_EVENT) + ).not.toThrow(); + expect(get(paperbell).capabilities).toEqual([]); }); it("no-ops all host-backed calls (returns null)", async () => { @@ -204,6 +219,46 @@ describe("PaperBellClient — handshake", () => { }); }); +describe("PaperBellClient — sibling plugins come and go", () => { + it("re-reads the host's capabilities when it broadcasts plugins-changed", () => { + const { client, plugin } = newClient(); + const host = new MockPaperBellHost({ capabilities: ["plugin-info"] }); + plugin.app.installHost(host); + client.init(); + expect(get(paperbell).capabilities).toEqual(["plugin-info"]); + + // The host opened up a scope while we were already connected. Without this + // subscription the stale list would stand until the next ready event. + host.pluginInfo.capabilities = ["plugin-info", "llm-invoke"]; + plugin.app.workspace.trigger(PPB_PLUGINS_CHANGED_EVENT); + + expect(get(paperbell).capabilities).toEqual(["plugin-info", "llm-invoke"]); + // Consent-free: nothing was re-registered and no scope was requested. + expect(host.registeredSources).toHaveLength(1); + }); + + it("survives a host that throws while reporting plugin info", () => { + const warnSpy = vi + .spyOn(console, "warn") + .mockImplementation((): void => undefined); + const { client, plugin } = newClient(); + const host = new MockPaperBellHost(); + plugin.app.installHost(host); + client.init(); + + host.getPluginInfo = () => { + throw new Error("host exploded"); + }; + expect(() => + plugin.app.workspace.trigger(PPB_PLUGINS_CHANGED_EVENT) + ).not.toThrow(); + + expect(get(paperbell).capabilities).toEqual([]); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); +}); + describe("PaperBellClient — recovery after the host reloads", () => { /** * Connect, then put the client in the state a PaperBell update leaves it in: a live From 8f05339c000ddc2aede2dc479291a47117a5433f Mon Sep 17 00:00:00 2001 From: SongshGeo Date: Tue, 8 Sep 2026 12:44:05 +0200 Subject: [PATCH 4/6] feat(scaffold): pre-fill the lead author from the host profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every new paper started by hand-correcting four fields the PaperBell host already knows. Its v2 `profile` now fills `creators[0]` in both metadata.json files and the cover letter's `corresponding:` line, each field falling back to its own placeholder — no host, an older host, a half-filled profile and an ungranted scope all still produce today's scaffold byte for byte. `profileIfGranted()` reads the profile only when that costs no consent prompt: the config the host already pushed, or a fetch when listGrants() says `config` is already granted. A dialog raised by opening the new-paper modal could outlive it (#31), and a pre-filled author is not worth that. The name is written exactly as the host gave it — it is a display name and metadata.json's placeholder follows Zenodo's "Lastname, Firstname", but reordering it would be a guess. ORCID stays a placeholder; the host has no such field. Closes #30 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ivDqLS8ZQvs3gVQ7qeJGC --- docs/PAPERBELL_INTEGRATION.md | 8 ++ docs/PAPERBELL_SUITE.md | 7 +- src/model/scaffold/index.ts | 2 +- src/model/scaffold/paperbell-scaffold.ts | 47 ++++++++--- src/model/scaffold/parts.ts | 14 +++- src/paperbell/client.ts | 30 +++++++ .../new-paper-modal/index.ts | 26 +++++++ test/commands/add-components.test.ts | 6 ++ test/model/paperbell-scaffold.test.ts | 78 +++++++++++++++++++ test/paperbell/client.test.ts | 67 ++++++++++++++++ 10 files changed, 270 insertions(+), 15 deletions(-) diff --git a/docs/PAPERBELL_INTEGRATION.md b/docs/PAPERBELL_INTEGRATION.md index 9b1324c..6c7d1fc 100644 --- a/docs/PAPERBELL_INTEGRATION.md +++ b/docs/PAPERBELL_INTEGRATION.md @@ -43,6 +43,14 @@ invisible to the user: language the host changed while our handle was dead. A first connect stays scope-free — see *Deferred consent* under **Scopes** below. +New paper projects pre-fill their lead author from the host's `profile` (`name` → +`creators[0].name`, `institution` → `affiliation`, `email` → `email` and the cover letter's +`corresponding:`), each field falling back to its placeholder on its own. `profileIfGranted()` +reads it **only** when that costs nothing: the config the host already pushed, or a fetch when +`listGrants()` says `config` is already granted. It never prompts — a consent dialog raised by +opening the new-paper modal could outlive the modal, and a pre-filled author is not worth that. +ORCID stays a placeholder; the host has no such field. + We also subscribe to `paperbell:plugins-changed`, which the host broadcasts when its registered sub-plugin list changes (its own use is refreshing the settings card list). We take it as a cue to re-read `getPluginInfo()`, so capabilities the host gained or dropped mid-session don't sit diff --git a/docs/PAPERBELL_SUITE.md b/docs/PAPERBELL_SUITE.md index 23c3550..7babd21 100644 --- a/docs/PAPERBELL_SUITE.md +++ b/docs/PAPERBELL_SUITE.md @@ -88,8 +88,11 @@ ready to pick up. ### Direction 1 — Consume concept / scholar / publication data -- **Pre-fill authors into `metadata.json`.** Source an explicit co-author list (host-provided - or a designated note) at scaffold time. +- **Pre-fill authors into `metadata.json`.** *The submitting user is done* — the host's v2 + `profile` (name / institution / email) fills `creators[0]` and the cover letter's + `corresponding:` when reading it costs no consent prompt, falling back per field to the + placeholders. What remains is an explicit **co-author** list (host-provided or a designated + note); deliberately not the "scholars you track" pool — tracking others ≠ authorship. Change sites: `src/model/scaffold/paperbell-scaffold.ts` (`mainMetadata`/`supplementaryMetadata`), `src/model/metadata-resolver.ts`, `src/view/project-lifecycle/new-paper-modal/`. - **Material/citation suggestions from concepts.** Read the manuscript's `concepts:` and diff --git a/src/model/scaffold/index.ts b/src/model/scaffold/index.ts index 70d8035..28926ca 100644 --- a/src/model/scaffold/index.ts +++ b/src/model/scaffold/index.ts @@ -20,7 +20,7 @@ export { scaffoldContext, SCAFFOLD_PRIMARY_DRAFT, } from "./paperbell-scaffold"; -export type { ScaffoldOptions } from "./paperbell-scaffold"; +export type { ScaffoldOptions, ScaffoldProfile } from "./paperbell-scaffold"; export { ALL_PAPER_PARTS, PAPER_PARTS, diff --git a/src/model/scaffold/paperbell-scaffold.ts b/src/model/scaffold/paperbell-scaffold.ts index cbf4361..943b8ed 100644 --- a/src/model/scaffold/paperbell-scaffold.ts +++ b/src/model/scaffold/paperbell-scaffold.ts @@ -49,13 +49,37 @@ export interface ScaffoldOptions { * the same reason as `parts`; body text adapts to it. */ examples: boolean; + /** + * Who the PaperBell host says the user is, when it says anything at all. Each + * field present replaces its placeholder in `metadata.json` and the cover + * letter; each field absent leaves the placeholder exactly as it was, which is + * also the whole no-host case. + * + * Passed in rather than fetched here on purpose: this builder is pure, and its + * output is what the unit tests pin down. + */ + profile?: ScaffoldProfile; +} + +/** + * The slice of the host's user profile a manuscript can use. Mirrors the fields + * of `PaperBellUserProfile` that map onto an author entry — `title` and `avatar` + * have nowhere to go, and ORCID the host does not know. + */ +export interface ScaffoldProfile { + name?: string; + institution?: string; + email?: string; } /** - * Stand-in for the lead author. Left as a placeholder for the user to replace in - * metadata.json, which is the single authority for publication metadata. + * Stand-ins for the lead author, used whenever the host has not told us who that + * is. Left as placeholders for the user to replace in metadata.json, which is the + * single authority for publication metadata. */ const PLACEHOLDER_AUTHOR = "Lastname, Firstname"; +const PLACEHOLDER_AFFILIATION = "Your Institution"; +const PLACEHOLDER_EMAIL = "you@example.com"; /** Initials of a title, upper-cased, digits kept — "Sea Level Memory" → "SLM". */ export function acronymFromTitle(title: string): string { @@ -70,7 +94,8 @@ export function acronymFromTitle(title: string): string { return initials || "PAPER"; } -function mainMetadata(title: string, acronym: string, author: string): string { +function mainMetadata(ctx: PartContext): string { + const { title, acronym, author } = ctx; return json({ title, publication_date: "", @@ -81,9 +106,9 @@ function mainMetadata(title: string, acronym: string, author: string): string { creators: [ { name: author, - affiliation: "Your Institution", + affiliation: ctx.affiliation, orcid: "0000-0000-0000-0000", - email: "you@example.com", + email: ctx.email, }, ], keywords: ["keyword-one", "keyword-two"], @@ -96,8 +121,7 @@ function mainMetadata(title: string, acronym: string, author: string): string { lineno: true, figures_at_end: false, corresponding: [author], - extra_yaml: - "corresponding_email: you@example.com\nnumbersections: true\n", + extra_yaml: `corresponding_email: ${ctx.email}\nnumbersections: true\n`, }, }); } @@ -222,7 +246,7 @@ export function commonScaffoldFiles(ctx: PartContext): ScaffoldFile[] { return [ { path: "metadata.json", - text: mainMetadata(ctx.title, ctx.acronym, ctx.author), + text: mainMetadata(ctx), }, { path: "results.json", text: RESULTS_JSON }, { path: "references.bib", text: REFERENCES_BIB }, @@ -239,10 +263,15 @@ export function exampleAssetFiles(): ScaffoldFile[] { /** Normalize the caller's options into the context every part builder takes. */ export function scaffoldContext(opts: ScaffoldOptions): PartContext { + const profile = opts.profile; return { title: opts.title.trim(), acronym: (opts.acronym || acronymFromTitle(opts.title.trim())).trim(), - author: PLACEHOLDER_AUTHOR, + // Each field falls back on its own: a host that knows a name but no + // institution should still save the user from retyping the name. + author: profile?.name?.trim() || PLACEHOLDER_AUTHOR, + affiliation: profile?.institution?.trim() || PLACEHOLDER_AFFILIATION, + email: profile?.email?.trim() || PLACEHOLDER_EMAIL, project: opts.project?.trim() || undefined, examples: opts.examples, present: new Set(opts.parts), diff --git a/src/model/scaffold/parts.ts b/src/model/scaffold/parts.ts index e918eb8..137ed93 100644 --- a/src/model/scaffold/parts.ts +++ b/src/model/scaffold/parts.ts @@ -30,7 +30,15 @@ export type ProjectForm = "legacy" | "project"; export interface PartContext { title: string; acronym: string; + /** + * Lead author, and the affiliation / address to reach them at. Placeholders + * unless the PaperBell host told us who the user is — see `ScaffoldOptions.profile`. + * `metadata.json` stays the single authority for publication metadata either way; + * these only decide what the user finds pre-filled there. + */ author: string; + affiliation: string; + email: string; /** * The PaperBell project this paper is a deliverable of, written as the top-level * `project:` frontmatter key on every index note. This is the *project's* acronym @@ -335,7 +343,7 @@ manuscript: ${ctx.title} acronym: ${ctx.acronym} ${projectLine(ctx)}date: to: Dear Editor, -corresponding: ${ctx.author} (you@example.com) +corresponding: ${ctx.author} (${ctx.email}) --- We are pleased to submit our manuscript, *{{manuscript}}*, for consideration for publication in *{{JournalName}}*. @@ -361,9 +369,9 @@ function supplementaryMetadata(ctx: PartContext): string { creators: [ { name: ctx.author, - affiliation: "Your Institution", + affiliation: ctx.affiliation, orcid: "0000-0000-0000-0000", - email: "you@example.com", + email: ctx.email, }, ], keywords: ["keyword-one", "keyword-two"], diff --git a/src/paperbell/client.ts b/src/paperbell/client.ts index 24f3730..3ae70be 100644 --- a/src/paperbell/client.ts +++ b/src/paperbell/client.ts @@ -1,4 +1,5 @@ import type { App } from "obsidian"; +import { get } from "svelte/store"; import type LongformPlugin from "../main"; import { @@ -17,6 +18,7 @@ import { type PPBProjectsQuery, type PaperBellAccountInfo, type PaperBellRestrictedConfig, + type PaperBellUserProfile, type PPBScope, } from "./shared-config"; import { paperbell, DISCONNECTED } from "./store"; @@ -228,6 +230,34 @@ export class PaperBellClient { return config; } + /** + * The host's user profile — **only if reading it costs no consent prompt**: the + * store's last pushed config, else a fresh fetch when `listGrants()` says the user + * already granted `config`. Returns null for a missing host, an older host, a + * profile the user never filled in, and (deliberately) an ungranted `config`. + * + * That last case is the point. The one caller is the new-paper modal, where a + * prompt would fire on open, for a field the user may not care about, and could + * outlive the modal — the contract has no way to cancel it (see + * docs/PROPOSAL_PROJECTS_SCOPE.md §3). Pre-filling an author is not worth that; + * the placeholder it falls back to is what everyone gets today. + */ + async profileIfGranted(): Promise { + if (!this.client) return null; + + const known = get(paperbell).config?.profile; + if (known) return known; + + const host = this.lookupHost(); + if (!host || !this.hasGrant(host, "config")) return null; + try { + return (await this.fetchSharedConfig())?.profile ?? null; + } catch (e) { + console.warn("[PaperOut] Could not read the PaperBell profile:", e); + return null; + } + } + /** Request the host's account info (scope: `account`). First call prompts for consent. */ async fetchAccountInfo(): Promise { return this.client ? this.client.requestAccountInfo() : null; diff --git a/src/view/project-lifecycle/new-paper-modal/index.ts b/src/view/project-lifecycle/new-paper-modal/index.ts index 719eac0..e3c714a 100644 --- a/src/view/project-lifecycle/new-paper-modal/index.ts +++ b/src/view/project-lifecycle/new-paper-modal/index.ts @@ -16,6 +16,7 @@ import { PAPER_PARTS, writePaperbellScaffold, type PaperPartId, + type ScaffoldProfile, } from "src/model/scaffold"; import { projectOptions, type ProjectOption } from "./project-options"; @@ -54,6 +55,11 @@ export default class NewPaperModal extends Modal { * one-way door — the text field offers a button back to the list. */ private hostProjects: ProjectOption[] = []; + /** + * Who the host says the user is, when it can tell us without a consent prompt. + * Null until it answers — and it may never answer, which is the no-host case. + */ + private hostProfile: ScaffoldProfile | null = null; /** Main is mandatory — see the note on the toggle below. */ private parts = new Set(["main"]); private examples = true; @@ -123,6 +129,7 @@ export default class NewPaperModal extends Modal { .setDesc(translate("scaffold.projectDesc")); this.renderProjectTextInput(); void this.loadHostProjects(); + void this.loadHostProfile(); contentEl.createEl("h4", { text: translate("scaffold.partsHeading") }); @@ -186,6 +193,24 @@ export default class NewPaperModal extends Modal { this.renderProjectDropdown(); } + /** + * Ask the host who the user is, to pre-fill the lead author instead of leaving + * `Lastname, Firstname` for them to correct. Fire-and-forget like the project + * list, and silent in every failure: `profileIfGranted` returns null rather than + * prompt for the `config` scope, so opening this modal still costs no consent + * dialog. Nothing here can delay or fail the create — a profile that lands after + * the user clicked simply misses this paper. + */ + private async loadHostProfile(): Promise { + const profile = await this.plugin.paperBell?.profileIfGranted(); + if (!profile) return; + this.hostProfile = { + name: profile.name, + institution: profile.institution, + email: profile.email, + }; + } + /** Swap the project field's control, keeping `projectValue` as the source of truth. */ private replaceProjectControl(render: (setting: Setting) => void): void { const setting = this.projectSetting; @@ -258,6 +283,7 @@ export default class NewPaperModal extends Modal { project: this.projectValue.trim() || undefined, parts: [...this.parts], examples: this.examples, + profile: this.hostProfile ?? undefined, }); selectedDraftVaultPath.set(primaryPath); selectedTab.set("Scenes"); diff --git a/test/commands/add-components.test.ts b/test/commands/add-components.test.ts index 7f41e5a..bdfc908 100644 --- a/test/commands/add-components.test.ts +++ b/test/commands/add-components.test.ts @@ -242,6 +242,8 @@ describe("part builders in project form", () => { title: "My Paper", acronym: "MP", author: "Lastname, Firstname", + affiliation: "Your Institution", + email: "you@example.com", examples: true, present: new Set<"main" | "supplementary" | "cover" | "response">(["main"]), }; @@ -351,6 +353,8 @@ describe("the context handed to part builders", () => { title: "My Paper", acronym: "MP", author: "A, B", + affiliation: "Your Institution", + email: "you@example.com", examples: false, present, }, @@ -366,6 +370,8 @@ describe("the context handed to part builders", () => { title: "My Paper", acronym: "MP", author: "A, B", + affiliation: "Your Institution", + email: "you@example.com", examples: false, present: new Set<"main" | "supplementary" | "cover" | "response">([ "response", diff --git a/test/model/paperbell-scaffold.test.ts b/test/model/paperbell-scaffold.test.ts index b581871..4b53e9d 100644 --- a/test/model/paperbell-scaffold.test.ts +++ b/test/model/paperbell-scaffold.test.ts @@ -341,6 +341,82 @@ describe("buildPaperbellScaffold — the PaperBell project link", () => { }); }); +describe("buildPaperbellScaffold — the lead author from the host profile", () => { + const build = (profile?: { + name?: string; + institution?: string; + email?: string; + }) => + buildPaperbellScaffold({ + title: "My Paper", + parts: ALL, + examples: false, + profile, + }); + + const metadataOf = (files: ScaffoldFile[], path: string) => + JSON.parse(textOf(files, path)); + + it("fills the lead author in both metadata.json files and the cover letter", () => { + const files = build({ + name: "Song, Shuang", + institution: "Max Planck Institute of Geoanthropology", + email: "song@gea.mpg.de", + }); + + for (const path of ["metadata.json", "supplementary/metadata.json"]) { + const meta = metadataOf(files, path); + expect(meta.creators[0].name, path).toBe("Song, Shuang"); + expect(meta.creators[0].affiliation, path).toBe( + "Max Planck Institute of Geoanthropology" + ); + expect(meta.creators[0].email, path).toBe("song@gea.mpg.de"); + expect(meta._longform.corresponding, path).toEqual(["Song, Shuang"]); + // ORCID has no profile field, so it stays a placeholder to fill in. + expect(meta.creators[0].orcid, path).toBe("0000-0000-0000-0000"); + } + expect(metadataOf(files, "metadata.json")._longform.extra_yaml).toContain( + "corresponding_email: song@gea.mpg.de" + ); + expect(textOf(files, "Cover Letter.md")).toContain( + "corresponding: Song, Shuang (song@gea.mpg.de)" + ); + }); + + it("writes the name exactly as the host gave it", () => { + // The host's `name` is a display name; metadata.json's placeholder follows + // Zenodo's "Lastname, Firstname". Reordering it blindly would be a guess, so + // the user fixes it in the one file that is authoritative. + const meta = metadataOf(build({ name: "Shuang Song" }), "metadata.json"); + expect(meta.creators[0].name).toBe("Shuang Song"); + }); + + it("falls back per field, not all-or-nothing", () => { + const meta = metadataOf(build({ name: "Song, Shuang" }), "metadata.json"); + expect(meta.creators[0].name).toBe("Song, Shuang"); + expect(meta.creators[0].affiliation).toBe("Your Institution"); + expect(meta.creators[0].email).toBe("you@example.com"); + }); + + it("is byte-identical to today's scaffold when there is no profile", () => { + // No host, an older host, or an ungranted `config` scope — all land here. + const none = build(); + const empty = build({ name: " ", institution: "", email: undefined }); + expect(empty).toEqual(none); + + const meta = metadataOf(none, "metadata.json"); + expect(meta.creators[0].name).toBe("Lastname, Firstname"); + expect(meta.creators[0].affiliation).toBe("Your Institution"); + expect(meta.creators[0].email).toBe("you@example.com"); + expect(meta._longform.extra_yaml).toContain( + "corresponding_email: you@example.com" + ); + expect(textOf(none, "Cover Letter.md")).toContain( + "corresponding: Lastname, Firstname (you@example.com)" + ); + }); +}); + describe("yamlScalar", () => { it("writes plain identifiers bare", () => { for (const value of ["ColMemo", "PROJ-1", "my project", "v1.0", "A_B"]) { @@ -410,6 +486,8 @@ describe("buildPaperbellScaffold — invariants across every selection", () => { title: "My Paper", acronym: "MP", author: "A, B", + affiliation: "Your Institution", + email: "you@example.com", examples: true, present: new Set(ALL), }; diff --git a/test/paperbell/client.test.ts b/test/paperbell/client.test.ts index ff06f07..a00450d 100644 --- a/test/paperbell/client.test.ts +++ b/test/paperbell/client.test.ts @@ -219,6 +219,73 @@ describe("PaperBellClient — handshake", () => { }); }); +describe("PaperBellClient — the user profile (consent-free only)", () => { + const PROFILE = { + name: "Song, Shuang", + institution: "Max Planck Institute of Geoanthropology", + email: "song@gea.mpg.de", + }; + + it("returns null with no host at all", async () => { + const { client } = newClient(); + client.init(); + expect(await client.profileIfGranted()).toBeNull(); + }); + + it("uses the config the host already pushed, without asking again", async () => { + const { client, plugin } = newClient(); + const host = new MockPaperBellHost({ + sharedConfig: makeRestrictedConfig({ profile: PROFILE }), + }); + plugin.app.installHost(host); + client.init(); + // As a real host does over `onConfigChange` once `config` was granted. + host.emitConfigChange(makeRestrictedConfig({ profile: PROFILE })); + + expect(await client.profileIfGranted()).toEqual(PROFILE); + }); + + it("fetches once when `config` is already granted", async () => { + const { client, plugin } = newClient(); + const host = new MockPaperBellHost({ + sharedConfig: makeRestrictedConfig({ profile: PROFILE }), + grants: [grantFor("config")], + }); + plugin.app.installHost(host); + client.init(); + await flush(); + + expect(await client.profileIfGranted()).toEqual(PROFILE); + }); + + it("does NOT fetch — so does not prompt — when `config` is not granted", async () => { + const { client, plugin } = newClient(); + const host = new MockPaperBellHost({ + sharedConfig: makeRestrictedConfig({ profile: PROFILE }), + grants: [], + }); + plugin.app.installHost(host); + client.init(); + + // The whole point: a prompt here would fire on opening the new-paper modal, + // for a field the user may not care about, and could outlive the modal. + expect(await client.profileIfGranted()).toBeNull(); + expect(get(paperbell).config).toBeNull(); + }); + + it("returns null when the host has a config but no profile in it", async () => { + const { client, plugin } = newClient(); + const host = new MockPaperBellHost({ + sharedConfig: makeRestrictedConfig(), + grants: [grantFor("config")], + }); + plugin.app.installHost(host); + client.init(); + + expect(await client.profileIfGranted()).toBeNull(); + }); +}); + describe("PaperBellClient — sibling plugins come and go", () => { it("re-reads the host's capabilities when it broadcasts plugins-changed", () => { const { client, plugin } = newClient(); From 1dfd5e03b876ca9a8695ab9722bb31bd4da34787 Mon Sep 17 00:00:00 2001 From: SongshGeo Date: Tue, 8 Sep 2026 12:44:22 +0200 Subject: [PATCH 5/6] docs: settle how feature PRs reach main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release process documented the release PR but said nothing about how the feature PR before it should be merged — which is what let a merge commit inheriting a `feat:` PR title double-count in 2.4.0-beta.5's changelog. Squash, and say why. Closes #34 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ivDqLS8ZQvs3gVQ7qeJGC --- MAINTAINING.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/MAINTAINING.md b/MAINTAINING.md index 1f5552b..7865e7e 100644 --- a/MAINTAINING.md +++ b/MAINTAINING.md @@ -122,6 +122,15 @@ and skips entirely when no bundle is installed. that PR *is* the release: it tags, publishes the GitHub release, attaches `main.js`, `manifest.json`, `styles.css`, and appends the `versions.json` entry. Merging any other PR never cuts a version. +- **Squash-merge feature PRs into `main`.** A `dev` → `main` merge commit inherits the PR + title, and a PR titled `feat: …` is itself a conventional commit — release-please walks + every commit since the last tag and counts the merge *and* the original as one feature + each, which is how `2.4.0-beta.5` shipped with its one feature listed twice. Squashing + leaves one commit whose subject is the PR title, which is what the tooling expects. + (GitHub's *default* merge subject, `Merge pull request #NN from …`, is ignored by + release-please — but that only holds while nobody overrides it, and overriding it is the + normal thing to do.) The repo allows squash merges; setting it as the repository's + default merge method takes the choice off whoever clicks the button. - **Write commit subjects release-please can read.** `fix:` → patch, `feat:` → minor, `!` or a `BREAKING CHANGE:` footer → major — all within the prerelease line configured in `release-please-config.json` (currently `2.4.0-beta.N`). To pin a version by hand, put From 008c02f07b2fccfe187435bae1206d7293006199 Mon Sep 17 00:00:00 2001 From: SongshGeo Date: Tue, 8 Sep 2026 16:54:53 +0200 Subject: [PATCH 6/6] refactor(pandoc-export): one sentence for the preset/Format precedence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the review of this branch. - Split the preset listing along the seam MAINTAINING.md documents: the fs + parseYaml half stays in pandoc-templates.ts, the pairing and its degradation move to a pandoc-templates-utils.ts sibling that vitest can load — so "an unreadable preset keeps its name and loses its label" is now asserted rather than asserted-to-be-obvious. - The step editor's disabled description now names the format the preset imposes ("decides the output format (PDF)"), which is what #38 asked for. The export-time warning and notice are filled from that same string via `fillOptionText`, so the three copies of the sentence become one declaration and three consumers. The notice moves after the target is resolved, which is where the format is known. - `leadCreator(ctx)` replaces the creators[0] block duplicated between the main and supplementary metadata. - `listPandocTemplateNames` for the error path that only wants names; `dynamicChoices` narrowed to its one literal; assorted one-liners (discarded return, over-defensive branches, mixed ctx access). - `hasBin`/`ASSETS` for the golden tests extracted to a shared harness rather than copied a third time. Left alone deliberately: the notice fires on every export (a saved misconfiguration is worth repeating), compile-step strings stay English (file-local precedent), and ScaffoldProfile keeps copying fields rather than aliasing the vendored contract type — that decoupling is the point. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ivDqLS8ZQvs3gVQ7qeJGC --- docs/PANDOC_EXPORT.md | 5 +- src/compile/steps/abstract-compile-step.ts | 29 +++++- src/compile/steps/pandoc-export.ts | 80 +++++++++------- src/model/pandoc-templates-utils.ts | 67 ++++++++++++++ src/model/pandoc-templates.ts | 55 +++++------ src/model/scaffold/paperbell-scaffold.ts | 17 +--- src/model/scaffold/parts.ts | 25 +++-- src/model/stores.ts | 3 +- src/paperbell/client.ts | 10 +- src/view/compile/CompileStepView.svelte | 2 +- .../compile-matrix/CompileMatrix.svelte | 2 +- src/view/compile/option-display.ts | 41 ++++++--- .../new-paper-modal/index.ts | 4 +- test/model/pandoc-templates-utils.test.ts | 91 +++++++++++++++++++ test/model/paperbell-scaffold.test.ts | 7 +- test/pandoc/builtin-export-golden.test.ts | 11 +-- test/pandoc/cover-letter-golden.test.ts | 12 +-- test/pandoc/golden-harness.ts | 29 ++++++ test/pandoc/response-letter-golden.test.ts | 12 +-- test/view/option-display.test.ts | 43 +++++++-- 20 files changed, 383 insertions(+), 162 deletions(-) create mode 100644 src/model/pandoc-templates-utils.ts create mode 100644 test/model/pandoc-templates-utils.test.ts create mode 100644 test/pandoc/golden-harness.ts diff --git a/docs/PANDOC_EXPORT.md b/docs/PANDOC_EXPORT.md index 5f86e9a..5ade46b 100644 --- a/docs/PANDOC_EXPORT.md +++ b/docs/PANDOC_EXPORT.md @@ -73,8 +73,9 @@ purpose, so their preset is still required and a missing one fails loudly instea of silently dropping the submission layout. Rule 1 is visible in the step editor rather than only in the result: pick a -preset and the *Format* control greys out, saying which preset decided the format -for you. Each preset in the dropdown is labelled with what it produces — +preset and the *Format* control greys out, naming the preset that decided for you +and the format it produces — *"Ignored — the preset `paperbell` decides the +output format (PDF)."* Each preset in the dropdown is labelled with what it produces — `paperbell — PDF`, `manuscript-obsidian — DOCX` — so picking the one that gives you a Word file no longer means opening its yaml. A workflow saved before this, or run from its `Run workflow: ` command, says the same thing in a notice diff --git a/src/compile/steps/abstract-compile-step.ts b/src/compile/steps/abstract-compile-step.ts index 2f35bd1..a270ca3 100644 --- a/src/compile/steps/abstract-compile-step.ts +++ b/src/compile/steps/abstract-compile-step.ts @@ -67,9 +67,10 @@ export interface CompileStepOption { choices?: string[]; /** * For `Dropdown` options: an identifier the compile UI resolves to a live list - * of choices (e.g. `"pandoc-templates"` → the downloaded Pandoc presets). + * of choices. A closed set — one provider — so the sentinel is type-checked + * rather than a string compared hopefully. */ - dynamicChoices?: string; + dynamicChoices?: DynamicChoiceSource; /** * For `Dropdown` options: the label shown for the empty (`""`) choice, which * lets the step fall back to its own default behavior. Defaults to "(default)". @@ -86,12 +87,32 @@ export interface CompileStepOption { */ disabledBy?: string; /** - * Shown in place of `description` while {@link disabledBy} holds a value. - * `{value}` is replaced with that value. Ignored without `disabledBy`. + * Shown in place of `description` while {@link disabledBy} holds a value, and + * reused verbatim by any step that reports the same precedence at compile time + * — one sentence, so the editor and the export cannot drift apart. + * + * Filled by {@link fillOptionText}: `{value}` is the overriding option's value, + * and a step may offer further placeholders of its own. Ignored without + * `disabledBy`. */ disabledDescription?: string; } +/** The live choice lists the compile UI knows how to resolve. */ +export type DynamicChoiceSource = "pandoc-templates"; + +/** + * Fill `{placeholder}`s in an option's text. A placeholder with no value is left + * standing rather than blanked, so a typo shows up as itself instead of as a + * hole in the sentence. + */ +export function fillOptionText( + text: string, + vars: Record +): string { + return text.replace(/\{(\w+)\}/g, (whole, name) => vars[name] ?? whole); +} + /** * An object describing a compile step: the skeleton of a step without its * instantiated values. Used to display a gallery of steps and to instantiate them. diff --git a/src/compile/steps/pandoc-export.ts b/src/compile/steps/pandoc-export.ts index 9bf5ae3..06fd6c4 100644 --- a/src/compile/steps/pandoc-export.ts +++ b/src/compile/steps/pandoc-export.ts @@ -8,7 +8,9 @@ import type { CompileContext, CompileManuscriptInput } from ".."; import { CompileStepKind, CompileStepOptionType, + fillOptionText, makeBuiltinStep, + type CompileStepOption, } from "./abstract-compile-step"; import { binSearchDirs, @@ -39,7 +41,8 @@ import { import { pandocSetupError } from "../recoverable"; import { pluginSettings } from "src/model/stores"; import { projectResourceCandidatePaths } from "src/model/project-resources"; -import { listPandocTemplates } from "src/model/pandoc-templates"; +import { listPandocTemplateNames } from "src/model/pandoc-templates"; +import { formatAside } from "src/model/pandoc-templates-utils"; function line(ok: boolean, label: string, detail: string): string { return `[${ok ? "✓" : "✗"}] ${label}` + (detail ? `\n ${detail}` : ""); @@ -101,7 +104,7 @@ function missingPresetHelp( template: string, templateSource: string ): string { - const installed = listPandocTemplates(app).map((t) => t.name); + const installed = listPandocTemplateNames(app); const where = `The preset "${template}" comes from ${templateSource}.`; if (installed.length === 0) { return ( @@ -149,6 +152,31 @@ function attachmentResourcePaths( return paths; } +/** + * Why a Format the user set had no effect. Declared once, because two places + * report it: the step editor greys the control out and shows this, and an export + * that runs with both set (a workflow saved before the control was greyed, or + * the headless `Run workflow:` command) says the same thing in a notice. + * + * `{value}` is the preset; `{format}` the aside naming what it produces. + */ +const FORMAT_OVERRIDDEN = + 'the preset "{value}" decides the output format{format}. Clear the preset to ' + + "export with Format instead."; + +const FORMAT_OPTION: CompileStepOption = { + id: "format", + name: "Format (no preset)", + description: + "Export without any preset, using pandoc on its own — no downloaded assets needed. Word needs nothing but pandoc; PDF also needs a TeX engine (xelatex, for CJK). Setting this overrides the note's `template:` frontmatter; only the Template / preset option above wins over it. Leave blank to require a preset, as the PaperBell pipelines do.", + type: CompileStepOptionType.Dropdown, + choices: [...BUILTIN_FORMATS], + emptyLabel: "(require a preset)", + default: "", + disabledBy: "template", + disabledDescription: `Ignored — ${FORMAT_OVERRIDDEN}`, +}; + export const RunPandocExportStep = makeBuiltinStep({ id: "run-pandoc-export", description: { @@ -167,19 +195,7 @@ export const RunPandocExportStep = makeBuiltinStep({ emptyLabel: "(use metadata template)", default: "", }, - { - id: "format", - name: "Format (no preset)", - description: - "Export without any preset, using pandoc on its own — no downloaded assets needed. Word needs nothing but pandoc; PDF also needs a TeX engine (xelatex, for CJK). Setting this overrides the note's `template:` frontmatter; only the Template / preset option above wins over it. Leave blank to require a preset, as the PaperBell pipelines do.", - type: CompileStepOptionType.Dropdown, - choices: [...BUILTIN_FORMATS], - emptyLabel: "(require a preset)", - default: "", - disabledBy: "template", - disabledDescription: - 'Ignored — the preset "{value}" decides the output format (the Template / preset dropdown above names it). Clear the preset to export with Format instead.', - }, + FORMAT_OPTION, { id: "filename", name: "File name", @@ -249,23 +265,7 @@ export const RunPandocExportStep = makeBuiltinStep({ // assets. See `resolveBuiltinFormat` for the precedence. const formatOption = String(context.optionValues["format"] ?? "").trim(); const builtinFormat = resolveBuiltinFormat(optionTemplate, formatOption); - if (!builtinFormat && optionTemplate && formatOption) { - console.warn( - `[Pandoc Export] Both a preset ("${optionTemplate}") and a Format ` + - `("${formatOption}") are set on this step; the preset wins. Clear the ` + - `Template / preset option to export with Format instead.` - ); - // The step editor greys the Format control out while a preset is set, but - // that only reaches someone who opens it: a workflow saved before it did, - // or run headlessly from the `Run workflow: ` command, still arrives - // here with both. Say it where a writer will actually see it. - new Notice( - `PaperOut: the preset "${optionTemplate}" decides the format — the ` + - `Format option ("${formatOption}") is ignored. Clear the preset to ` + - `export with Format instead.`, - 8000 - ); - } + const formatOverridden = !builtinFormat && !!optionTemplate && !!formatOption; // Unused in built-in mode: no preset is read, and `{template}` must not // expand to a name that had no effect on the output. @@ -321,6 +321,22 @@ export const RunPandocExportStep = makeBuiltinStep({ } } + // The step editor greys the Format control out while a preset is set, but that + // only reaches someone who opens it: a workflow saved before it did, or run + // headlessly from the `Run workflow: ` command, still arrives here with + // both. Say it where a writer will see it — and say it in the editor's own + // words, now that the target is known and can name the format. + if (formatOverridden) { + const why = fillOptionText(FORMAT_OVERRIDDEN, { + value: optionTemplate, + format: formatAside(target.ext), + }); + console.warn( + `[Pandoc Export] Format "${formatOption}" is set alongside a preset: ${why}` + ); + new Notice(`PaperOut: the Format option is ignored — ${why}`, 8000); + } + // Only require the tools this preset actually asks for. A docx preset needs // neither a TeX engine nor pandoc-crossref. // A preset that produces a PDF without naming an engine leaves pandoc to diff --git a/src/model/pandoc-templates-utils.ts b/src/model/pandoc-templates-utils.ts new file mode 100644 index 0000000..72d7810 --- /dev/null +++ b/src/model/pandoc-templates-utils.ts @@ -0,0 +1,67 @@ +import { exportTargetForDefaults } from "src/compile/steps/pandoc-export-utils"; + +/** + * A downloaded preset, with the file extension it exports to. Knowing the + * extension is what lets the compile UI label a preset `paperbell — PDF` and + * name the format a preset imposes on the Format option — until now the only way + * to learn that a preset produces Word was to open its yaml. + */ +export interface PandocTemplateChoice { + /** Basename of the preset file, without `.yaml`. The value written to the step. */ + name: string; + /** Extension the preset exports to, e.g. `".pdf"`. Empty when it couldn't be read. */ + ext: string; +} + +/** + * Pair each preset name with the extension it exports to, given a `readPreset` + * that yields one preset's *parsed* yaml. + * + * Reading is best-effort, exactly as the export step's own preflight is (see + * `pandoc-export.ts`, "assuming PDF output"): a preset that cannot be read or + * parsed loses its format label and nothing else — it stays in the list and + * stays selectable, because the export may well succeed where our peek failed. + * + * The reader is injected because the real one needs `fs` and Obsidian's + * `parseYaml`, neither of which loads under vitest; this half is the half worth + * testing. See `pandoc-templates.ts` for the wiring. + */ +export function pandocTemplateChoices( + names: string[], + readPreset: (name: string) => unknown +): PandocTemplateChoice[] { + return names.map((name) => ({ name, ext: presetExtension(name, readPreset) })); +} + +function presetExtension( + name: string, + readPreset: (name: string) => unknown +): string { + try { + return exportTargetForDefaults(readPreset(name)).ext; + } catch (e) { + console.warn(`[Pandoc Export] Could not read the preset ${name}.yaml.`, e); + return ""; + } +} + +/** `paperbell` + `.pdf` → `paperbell — PDF`; an unread preset keeps its bare name. */ +export function templateLabel(template: PandocTemplateChoice): string { + const ext = formatName(template.ext); + return ext ? `${template.name} — ${ext}` : template.name; +} + +/** `.pdf` → `PDF`, for prose and labels. `""` when the extension is unknown. */ +export function formatName(ext: string): string { + return ext.replace(/^\./, "").toUpperCase(); +} + +/** + * The same format as a parenthetical aside — `" (PDF)"`, or `""` when we could + * not read it. It carries its own leading space so a sentence can end with + * `…output format{format}.` and read correctly either way. + */ +export function formatAside(ext: string): string { + const name = formatName(ext); + return name ? ` (${name})` : ""; +} diff --git a/src/model/pandoc-templates.ts b/src/model/pandoc-templates.ts index e1411ef..393e1d2 100644 --- a/src/model/pandoc-templates.ts +++ b/src/model/pandoc-templates.ts @@ -7,21 +7,12 @@ import { pandocTemplates, pluginSettings } from "./stores"; import { currentPlatformEnv, DEFAULT_ASSETS_DIR, - exportTargetForDefaults, resolveUserPath, } from "src/compile/steps/pandoc-export-utils"; - -/** - * A downloaded preset, with the file extension it exports to. Knowing the - * extension is what lets the compile UI label a preset `paperbell — PDF` — until - * now the only way to learn that a preset produces Word was to open its yaml. - */ -export interface PandocTemplateChoice { - /** Basename of the preset file, without `.yaml`. The value written to the step. */ - name: string; - /** Extension the preset exports to, e.g. `".pdf"`. Empty when it couldn't be read. */ - ext: string; -} +import { + pandocTemplateChoices, + type PandocTemplateChoice, +} from "./pandoc-templates-utils"; /** * Preset basenames that aren't user-selectable manuscript templates: `crossref` @@ -35,9 +26,9 @@ const EXCLUDED = new Set(["crossref", "undefined"]); * Node fs to read outside the vault); returns `[]` on mobile or if the folder * can't be read. * - * Reading each preset is best-effort, exactly as the export step's own preflight - * is (see `pandoc-export.ts`, "assuming PDF output"): one unreadable or malformed - * yaml costs that entry its format label, never the whole list. + * This is the Obsidian-bound half: locate the folder, read the files. The + * pairing and its degradation live in `pandoc-templates-utils.ts`, where they + * can be tested. */ export function listPandocTemplates(app: App): PandocTemplateChoice[] { const adapter = app.vault.adapter; @@ -51,9 +42,22 @@ export function listPandocTemplates(app: App): PandocTemplateChoice[] { "defaults" ); - let names: string[]; + return pandocTemplateChoices(presetNames(defaultsDir), (name) => + parseYaml(fs.readFileSync(path.join(defaultsDir, name + ".yaml"), "utf8")) + ); +} + +/** + * Just the preset names, for callers that have no use for the formats — reading + * every yaml to throw the answer away would be silly on an error path. + */ +export function listPandocTemplateNames(app: App): string[] { + return listPandocTemplates(app).map((t) => t.name); +} + +function presetNames(defaultsDir: string): string[] { try { - names = fs + return fs .readdirSync(defaultsDir) .filter((f) => f.endsWith(".yaml")) .map((f) => f.slice(0, -".yaml".length)) @@ -62,21 +66,6 @@ export function listPandocTemplates(app: App): PandocTemplateChoice[] { } catch { return []; } - - return names.map((name) => ({ - name, - ext: presetExtension(path.join(defaultsDir, name + ".yaml")), - })); -} - -/** The extension one preset exports to, or `""` when it can't be determined. */ -function presetExtension(file: string): string { - try { - return exportTargetForDefaults(parseYaml(fs.readFileSync(file, "utf8"))).ext; - } catch (e) { - console.warn(`[Pandoc Export] Could not read preset ${file}.`, e); - return ""; - } } /** Refresh the `pandocTemplates` store from the current assets folder. */ diff --git a/src/model/scaffold/paperbell-scaffold.ts b/src/model/scaffold/paperbell-scaffold.ts index 943b8ed..9ff6724 100644 --- a/src/model/scaffold/paperbell-scaffold.ts +++ b/src/model/scaffold/paperbell-scaffold.ts @@ -4,6 +4,7 @@ import { } from "./assets"; import { json, + leadCreator, PAPER_PARTS, type PaperPartId, type PartContext, @@ -95,32 +96,24 @@ export function acronymFromTitle(title: string): string { } function mainMetadata(ctx: PartContext): string { - const { title, acronym, author } = ctx; return json({ - title, + title: ctx.title, publication_date: "", upload_type: "publication", publication_type: "article", description: "One-paragraph summary of the paper. Fill this in — it is emitted into the compiled manuscript's frontmatter and (for Zenodo) the deposit description.", - creators: [ - { - name: author, - affiliation: ctx.affiliation, - orcid: "0000-0000-0000-0000", - email: ctx.email, - }, - ], + creators: [leadCreator(ctx)], keywords: ["keyword-one", "keyword-two"], journal_title: "Target Journal", version: "v1.0", _longform: { - acronym, + acronym: ctx.acronym, csl: "nature", template: "paperbell", lineno: true, figures_at_end: false, - corresponding: [author], + corresponding: [ctx.author], extra_yaml: `corresponding_email: ${ctx.email}\nnumbersections: true\n`, }, }); diff --git a/src/model/scaffold/parts.ts b/src/model/scaffold/parts.ts index 137ed93..913e0cd 100644 --- a/src/model/scaffold/parts.ts +++ b/src/model/scaffold/parts.ts @@ -95,6 +95,22 @@ export function json(value: unknown): string { return JSON.stringify(value, null, 2) + "\n"; } +/** + * The lead author entry both `metadata.json` files carry. One shape, so the + * supplementary deposit never drifts from the main one — Zenodo reads them as + * two records of the same paper. + * + * ORCID stays a placeholder: it is the one field nothing upstream knows. + */ +export function leadCreator(ctx: PartContext): Record { + return { + name: ctx.author, + affiliation: ctx.affiliation, + orcid: "0000-0000-0000-0000", + email: ctx.email, + }; +} + /** Characters safe to write bare in YAML — no quoting, no escaping, no ambiguity. */ const PLAIN_YAML_SCALAR = /^[A-Za-z0-9][A-Za-z0-9 ._-]*$/; @@ -366,14 +382,7 @@ function supplementaryMetadata(ctx: PartContext): string { publication_type: "article", description: "Supplementary information for the paper. Shares the main manuscript's metadata but adds supplementary: true so figures and tables receive an S prefix.", - creators: [ - { - name: ctx.author, - affiliation: ctx.affiliation, - orcid: "0000-0000-0000-0000", - email: ctx.email, - }, - ], + creators: [leadCreator(ctx)], keywords: ["keyword-one", "keyword-two"], journal_title: "Target Journal", version: "v1.0", diff --git a/src/model/stores.ts b/src/model/stores.ts index 0522069..cc1ecaa 100644 --- a/src/model/stores.ts +++ b/src/model/stores.ts @@ -1,8 +1,7 @@ import { derived, writable } from "svelte/store"; import { groupBy, sortBy } from "lodash"; -// Type-only, so the `pandoc-templates` → `stores` import stays the only runtime edge. -import type { PandocTemplateChoice } from "./pandoc-templates"; +import type { PandocTemplateChoice } from "./pandoc-templates-utils"; import type { WordCountSession, Draft, diff --git a/src/paperbell/client.ts b/src/paperbell/client.ts index 3ae70be..23c2b76 100644 --- a/src/paperbell/client.ts +++ b/src/paperbell/client.ts @@ -102,11 +102,8 @@ export class PaperBellClient { // the next ready event. Cheap, and it prompts for nothing. this.plugin.registerEvent( this.app.workspace.on(PPB_PLUGINS_CHANGED_EVENT as never, (() => { - if (!this.client) return; - const host = this.lookupHost(); - if (host) { - this.refreshCapabilities(host); - } + const host = this.client ? this.lookupHost() : null; + if (host) this.refreshCapabilities(host); }) as never) ); } @@ -191,7 +188,7 @@ export class PaperBellClient { * features (e.g. llm-invoke). plugin-info needs no consent, so this is safe to * call on any host signal. */ - private refreshCapabilities(host: PPBHostApi): PPBScope[] { + private refreshCapabilities(host: PPBHostApi): void { let capabilities = DISCONNECTED.capabilities; try { capabilities = host.getPluginInfo()?.capabilities ?? []; @@ -200,7 +197,6 @@ export class PaperBellClient { } this.capabilities = capabilities; paperbell.update((s) => ({ ...s, capabilities })); - return capabilities; } /** Whether the user has already granted us `scope`, per the host's grant list. */ diff --git a/src/view/compile/CompileStepView.svelte b/src/view/compile/CompileStepView.svelte index 484254e..07fd1ee 100644 --- a/src/view/compile/CompileStepView.svelte +++ b/src/view/compile/CompileStepView.svelte @@ -124,7 +124,7 @@ {/if}

- {optionDescription(option, step.optionValues)} + {optionDescription(option, step.optionValues, $pandocTemplates)}

{/each} diff --git a/src/view/compile/compile-matrix/CompileMatrix.svelte b/src/view/compile/compile-matrix/CompileMatrix.svelte index 3650d1b..711fbc5 100644 --- a/src/view/compile/compile-matrix/CompileMatrix.svelte +++ b/src/view/compile/compile-matrix/CompileMatrix.svelte @@ -430,7 +430,7 @@ {/if}

- {optionDescription(option, step.optionValues)} + {optionDescription(option, step.optionValues, $pandocTemplates)}

{/each} diff --git a/src/view/compile/option-display.ts b/src/view/compile/option-display.ts index a7f8702..48c0549 100644 --- a/src/view/compile/option-display.ts +++ b/src/view/compile/option-display.ts @@ -1,5 +1,12 @@ -import type { CompileStepOption } from "src/compile/steps/abstract-compile-step"; -import type { PandocTemplateChoice } from "src/model/pandoc-templates"; +import { + fillOptionText, + type CompileStepOption, +} from "src/compile/steps/abstract-compile-step"; +import { + formatAside, + templateLabel, + type PandocTemplateChoice, +} from "src/model/pandoc-templates-utils"; /** * How a compile step's option is presented right now: whether another option is @@ -31,16 +38,25 @@ export function optionIsInert( } /** - * The description to show under `option`: its `disabledDescription` (with - * `{value}` filled in) while it is overridden, otherwise its own. + * The description to show under `option`: its `disabledDescription` while it is + * overridden, otherwise its own. + * + * `templates` answers the `{format}` placeholder — a preset that overrides the + * Format option should say *which* format it imposes, and only the preset list + * knows that. Absent or unreadable, the sentence simply omits the aside. */ export function optionDescription( option: CompileStepOption, - optionValues: Record + optionValues: Record, + templates: PandocTemplateChoice[] = [] ): string { const value = overridingValue(option, optionValues); if (value === null || !option.disabledDescription) return option.description; - return option.disabledDescription.replace(/\{value\}/g, value); + const ext = templates.find((t) => t.name === value)?.ext ?? ""; + return fillOptionText(option.disabledDescription, { + value, + format: formatAside(ext), + }); } /** The trimmed value of the option overriding this one, or null if none does. */ @@ -49,9 +65,10 @@ function overridingValue( optionValues: Record ): string | null { if (!option.disabledBy) return null; - const raw = optionValues?.[option.disabledBy]; - if (typeof raw !== "string") return raw ? String(raw) : null; - const value = raw.trim(); + const raw = optionValues[option.disabledBy]; + // Every option that can override one today is a text field or a dropdown, so a + // non-string value means the step declared a `disabledBy` it shouldn't have. + const value = typeof raw === "string" ? raw.trim() : ""; return value ? value : null; } @@ -70,9 +87,3 @@ export function dropdownChoices( } return (option.choices ?? []).map((c) => ({ value: c, label: c })); } - -/** `paperbell` + `.pdf` → `paperbell — PDF`; an unread preset keeps its bare name. */ -export function templateLabel(template: PandocTemplateChoice): string { - const ext = template.ext.replace(/^\./, "").toUpperCase(); - return ext ? `${template.name} — ${ext}` : template.name; -} diff --git a/src/view/project-lifecycle/new-paper-modal/index.ts b/src/view/project-lifecycle/new-paper-modal/index.ts index e3c714a..6401450 100644 --- a/src/view/project-lifecycle/new-paper-modal/index.ts +++ b/src/view/project-lifecycle/new-paper-modal/index.ts @@ -59,7 +59,7 @@ export default class NewPaperModal extends Modal { * Who the host says the user is, when it can tell us without a consent prompt. * Null until it answers — and it may never answer, which is the no-host case. */ - private hostProfile: ScaffoldProfile | null = null; + private hostProfile: ScaffoldProfile | undefined = undefined; /** Main is mandatory — see the note on the toggle below. */ private parts = new Set(["main"]); private examples = true; @@ -283,7 +283,7 @@ export default class NewPaperModal extends Modal { project: this.projectValue.trim() || undefined, parts: [...this.parts], examples: this.examples, - profile: this.hostProfile ?? undefined, + profile: this.hostProfile, }); selectedDraftVaultPath.set(primaryPath); selectedTab.set("Scenes"); diff --git a/test/model/pandoc-templates-utils.test.ts b/test/model/pandoc-templates-utils.test.ts new file mode 100644 index 0000000..b11aeb6 --- /dev/null +++ b/test/model/pandoc-templates-utils.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from "vitest"; +import { + formatAside, + formatName, + pandocTemplateChoices, + templateLabel, +} from "src/model/pandoc-templates-utils"; + +/** + * The half of the preset listing that can be tested: pairing each name with the + * format its yaml exports to, and what happens when a yaml won't read. The + * mapping itself (`exportTargetForDefaults`) is covered against every shipped + * preset shape in `test/compile/steps/pandoc-export.test.ts`; the fs + `parseYaml` + * half stays in `pandoc-templates.ts`, which vitest cannot load. + */ +describe("pandocTemplateChoices", () => { + /** Parsed yaml of the presets the asset repo actually ships. */ + const PRESETS: Record = { + paperbell: { "output-file": "output.pdf", "pdf-engine": "xelatex" }, + "manuscript-obsidian": { to: "docx" }, + cover_letter: { to: "latex", "pdf-engine": "xelatex" }, + }; + + it("pairs every name with the extension its preset exports to", () => { + expect( + pandocTemplateChoices(Object.keys(PRESETS), (name) => PRESETS[name]) + ).toEqual([ + { name: "paperbell", ext: ".pdf" }, + { name: "manuscript-obsidian", ext: ".docx" }, + { name: "cover_letter", ext: ".pdf" }, + ]); + }); + + it("keeps an unreadable preset in the list, without a format", () => { + // A yaml we cannot peek at may still export fine — dropping it from the + // dropdown would take away a preset that works. + const warn = vi.spyOn(console, "warn").mockImplementation((): void => undefined); + const choices = pandocTemplateChoices( + ["paperbell", "broken"], + (name): unknown => { + if (name === "broken") throw new Error("ENOENT"); + return PRESETS[name]; + } + ); + + expect(choices).toEqual([ + { name: "paperbell", ext: ".pdf" }, + { name: "broken", ext: "" }, + ]); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it("survives a preset that parses to nothing at all", () => { + // An empty or comment-only yaml parses to null, which must not throw. + expect(pandocTemplateChoices(["empty"], () => null)).toEqual([ + { name: "empty", ext: ".pdf" }, + ]); + }); + + it("has nothing to say about an empty assets folder", () => { + expect( + pandocTemplateChoices([], (): unknown => { + throw new Error("should not be called"); + }) + ).toEqual([]); + }); +}); + +describe("templateLabel", () => { + it("names the format beside the preset", () => { + expect(templateLabel({ name: "beamer", ext: ".pdf" })).toBe("beamer — PDF"); + }); + + it("leaves an unread preset its bare name", () => { + expect(templateLabel({ name: "broken", ext: "" })).toBe("broken"); + }); +}); + +describe("formatName / formatAside", () => { + it("drops the dot and upper-cases", () => { + expect(formatName(".docx")).toBe("DOCX"); + expect(formatAside(".docx")).toBe(" (DOCX)"); + }); + + it("yields nothing at all for an unknown extension", () => { + // So a sentence ending `…output format{format}.` still reads correctly. + expect(formatName("")).toBe(""); + expect(formatAside("")).toBe(""); + }); +}); diff --git a/test/model/paperbell-scaffold.test.ts b/test/model/paperbell-scaffold.test.ts index 4b53e9d..8be4559 100644 --- a/test/model/paperbell-scaffold.test.ts +++ b/test/model/paperbell-scaffold.test.ts @@ -5,6 +5,7 @@ import { acronymFromTitle, renderTree, SCAFFOLD_PRIMARY_DRAFT, + type ScaffoldProfile, } from "src/model/scaffold/paperbell-scaffold"; import { ALL_PAPER_PARTS, @@ -342,11 +343,7 @@ describe("buildPaperbellScaffold — the PaperBell project link", () => { }); describe("buildPaperbellScaffold — the lead author from the host profile", () => { - const build = (profile?: { - name?: string; - institution?: string; - email?: string; - }) => + const build = (profile?: ScaffoldProfile) => buildPaperbellScaffold({ title: "My Paper", parts: ALL, diff --git a/test/pandoc/builtin-export-golden.test.ts b/test/pandoc/builtin-export-golden.test.ts index a80e138..383eaf7 100644 --- a/test/pandoc/builtin-export-golden.test.ts +++ b/test/pandoc/builtin-export-golden.test.ts @@ -4,6 +4,8 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; +import { hasBin } from "./golden-harness"; + import { buildPandocArgs, builtinExportTarget, @@ -22,15 +24,6 @@ import { * Gated on the binaries, not on the assets (that is the point). Word needs * pandoc alone; the PDF case additionally needs xelatex, so it gates separately. */ -function hasBin(name: string): boolean { - try { - execFileSync("which", [name], { stdio: "ignore" }); - return true; - } catch { - return false; - } -} - const havePandoc = hasBin("pandoc"); const haveXelatex = hasBin("xelatex"); diff --git a/test/pandoc/cover-letter-golden.test.ts b/test/pandoc/cover-letter-golden.test.ts index f344bbe..135895c 100644 --- a/test/pandoc/cover-letter-golden.test.ts +++ b/test/pandoc/cover-letter-golden.test.ts @@ -4,6 +4,8 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; +import { ASSETS, hasBin } from "./golden-harness"; + /** * Owed verification from the change that added a top-level `project:` key to * every scaffolded index note (#25, #28). @@ -19,16 +21,6 @@ import * as path from "path"; * Gated like the response-letter golden: skips without pandoc and the synced * assets. LaTeX output only — no xelatex needed. */ -function hasBin(name: string): boolean { - try { - execFileSync("which", [name], { stdio: "ignore" }); - return true; - } catch { - return false; - } -} - -const ASSETS = path.resolve(process.cwd(), "pandoc-assets"); const COVER_YAML = path.join(ASSETS, "defaults", "cover_letter.yaml"); const present = hasBin("pandoc") && fs.existsSync(COVER_YAML); diff --git a/test/pandoc/golden-harness.ts b/test/pandoc/golden-harness.ts new file mode 100644 index 0000000..1d1bb9b --- /dev/null +++ b/test/pandoc/golden-harness.ts @@ -0,0 +1,29 @@ +import { execFileSync } from "child_process"; +import * as path from "path"; + +/** + * Shared gating for the golden tests in this folder. Each of them runs the REAL + * pandoc — asserting the flags we build would only prove we build the string we + * think we build, not that pandoc accepts it — so each has to decide whether the + * toolchain it needs is present, and skip rather than fail when it isn't. + * + * What they need differs (pandoc alone, plus xelatex, plus a synced preset), so + * the gate itself stays in each test; only the probing lives here. + */ + +/** Whether a binary is on PATH. */ +export function hasBin(name: string): boolean { + try { + execFileSync("which", [name], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +/** + * The synced Pandoc assets at the repo root — presets, filters, templates, CSL. + * Absent on a machine that never ran the download, which is why every test that + * needs a preset gates on the specific file it uses. + */ +export const ASSETS = path.resolve(process.cwd(), "pandoc-assets"); diff --git a/test/pandoc/response-letter-golden.test.ts b/test/pandoc/response-letter-golden.test.ts index 0f4f178..6cf127a 100644 --- a/test/pandoc/response-letter-golden.test.ts +++ b/test/pandoc/response-letter-golden.test.ts @@ -4,6 +4,8 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; +import { ASSETS, hasBin } from "./golden-harness"; + /** * End-to-end golden test of the response-letter Lua engine (manuscript_include + * responseletter). Given a manuscript with a span + a figure, a @@ -15,16 +17,6 @@ import * as path from "path"; * without the Pandoc toolchain stays green; runs locally). No xelatex needed — * we assert the generated LaTeX, not a PDF. */ -function hasBin(name: string): boolean { - try { - execFileSync("which", [name], { stdio: "ignore" }); - return true; - } catch { - return false; - } -} - -const ASSETS = path.resolve(process.cwd(), "pandoc-assets"); const RESP_YAML = path.join(ASSETS, "defaults", "response-letter.yaml"); const CSL = path.join(ASSETS, "csl", "nature.csl"); const present = hasBin("pandoc") && fs.existsSync(RESP_YAML) && fs.existsSync(CSL); diff --git a/test/view/option-display.test.ts b/test/view/option-display.test.ts index fa547da..a0d027a 100644 --- a/test/view/option-display.test.ts +++ b/test/view/option-display.test.ts @@ -7,7 +7,6 @@ import { dropdownChoices, optionDescription, optionIsInert, - templateLabel, } from "src/view/compile/option-display"; /** The real Run Pandoc Export `format` option, trimmed to the fields these read. */ @@ -20,7 +19,8 @@ const FORMAT: CompileStepOption = { emptyLabel: "(require a preset)", default: "", disabledBy: "template", - disabledDescription: 'Ignored — the preset "{value}" decides the format.', + disabledDescription: + 'Ignored — the preset "{value}" decides the output format{format}.', }; const TEMPLATE: CompileStepOption = { @@ -51,16 +51,47 @@ describe("optionIsInert", () => { describe("optionDescription", () => { it("swaps in the disabled text, naming the preset that won", () => { expect(optionDescription(FORMAT, { template: "paperbell" })).toBe( - 'Ignored — the preset "paperbell" decides the format.' + 'Ignored — the preset "paperbell" decides the output format.' ); }); + it("names the format that preset produces, when the list knows it", () => { + // The whole point of the disabled state: not just "you don't decide this", + // but what was decided instead. + expect( + optionDescription(FORMAT, { template: "manuscript-obsidian" }, [ + { name: "paperbell", ext: ".pdf" }, + { name: "manuscript-obsidian", ext: ".docx" }, + ]) + ).toBe( + 'Ignored — the preset "manuscript-obsidian" decides the output format (DOCX).' + ); + }); + + it("omits the aside for a preset whose yaml could not be read", () => { + expect( + optionDescription(FORMAT, { template: "broken" }, [ + { name: "broken", ext: "" }, + ]) + ).toBe('Ignored — the preset "broken" decides the output format.'); + }); + it("keeps its own description while nothing overrides it", () => { expect(optionDescription(FORMAT, { template: "" })).toBe( FORMAT.description ); }); + it("leaves an unknown placeholder standing rather than blanking it", () => { + const option: CompileStepOption = { + ...FORMAT, + disabledDescription: "Set by {value}, see {nowhere}.", + }; + expect(optionDescription(option, { template: "paperbell" })).toBe( + "Set by paperbell, see {nowhere}." + ); + }); + it("falls back to its own description when no disabled text was given", () => { const option: CompileStepOption = { ...FORMAT, @@ -103,9 +134,3 @@ describe("dropdownChoices", () => { expect(dropdownChoices({ ...FORMAT, choices: undefined }, [])).toEqual([]); }); }); - -describe("templateLabel", () => { - it("upper-cases the extension and drops its dot", () => { - expect(templateLabel({ name: "beamer", ext: ".pdf" })).toBe("beamer — PDF"); - }); -});