Skip to content
Merged
9 changes: 9 additions & 0 deletions MAINTAINING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions docs/PANDOC_EXPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,15 @@ 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, 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: <name>` 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.
Expand Down
13 changes: 13 additions & 0 deletions docs/PAPERBELL_INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,19 @@ 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
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
Expand Down
7 changes: 5 additions & 2 deletions docs/PAPERBELL_SUITE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 38 additions & 2 deletions src/compile/steps/abstract-compile-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,50 @@ 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)".
*/
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, 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, string>
): string {
return text.replace(/\{(\w+)\}/g, (whole, name) => vars[name] ?? whole);
}

/**
Expand Down
67 changes: 48 additions & 19 deletions src/compile/steps/pandoc-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import type { CompileContext, CompileManuscriptInput } from "..";
import {
CompileStepKind,
CompileStepOptionType,
fillOptionText,
makeBuiltinStep,
type CompileStepOption,
} from "./abstract-compile-step";
import {
binSearchDirs,
Expand Down Expand Up @@ -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}` : "");
Expand Down Expand Up @@ -101,7 +104,7 @@ function missingPresetHelp(
template: string,
templateSource: string
): string {
const installed = listPandocTemplates(app);
const installed = listPandocTemplateNames(app);
const where = `The preset "${template}" comes from ${templateSource}.`;
if (installed.length === 0) {
return (
Expand Down Expand Up @@ -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: {
Expand All @@ -167,16 +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: "",
},
FORMAT_OPTION,
{
id: "filename",
name: "File name",
Expand Down Expand Up @@ -246,13 +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.`
);
}
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.
Expand Down Expand Up @@ -308,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: <name>` 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
Expand Down
67 changes: 67 additions & 0 deletions src/model/pandoc-templates-utils.ts
Original file line number Diff line number Diff line change
@@ -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})` : "";
}
33 changes: 28 additions & 5 deletions src/model/pandoc-templates.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -9,6 +9,10 @@ import {
DEFAULT_ASSETS_DIR,
resolveUserPath,
} from "src/compile/steps/pandoc-export-utils";
import {
pandocTemplateChoices,
type PandocTemplateChoice,
} from "./pandoc-templates-utils";

/**
* Preset basenames that aren't user-selectable manuscript templates: `crossref`
Expand All @@ -17,11 +21,16 @@ import {
const EXCLUDED = new Set(["crossref", "undefined"]);

/**
* List the downloaded Pandoc presets — the basenames (without `.yaml`) of the
* files in `<assets>/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 `<assets>/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.
*
* 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): string[] {
export function listPandocTemplates(app: App): PandocTemplateChoice[] {
const adapter = app.vault.adapter;
if (!(adapter instanceof FileSystemAdapter)) return [];

Expand All @@ -33,6 +42,20 @@ export function listPandocTemplates(app: App): string[] {
"defaults"
);

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 {
return fs
.readdirSync(defaultsDir)
Expand Down
2 changes: 1 addition & 1 deletion src/model/scaffold/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading