From 205ba52f23ea9e1e3984e56e3fa5e574eeebd133 Mon Sep 17 00:00:00 2001 From: SongshGeo Date: Mon, 17 Aug 2026 22:06:38 +0200 Subject: [PATCH] feat: pick the paper's PaperBell project when scaffolding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PAPERBELL_SUITE.md has always described outputs linking back to their project via `project: `, but nothing wrote it: a paper scaffolded into 50 - Outputs carried no field naming the project it belongs to, so Project Manager could not recognize it as a deliverable. The new-paper modal now asks. The answer is written as a top-level `project:` key in the frontmatter of every draft index note it creates — main, supplementary, response, and cover letter alike. metadata.json is left alone; it stays pure publication metadata. Leaving the field empty omits the key entirely, since an empty `project:` reads as a null association to a sibling querying frontmatter. Note that `project` (the project's acronym) and `_longform.acronym` (the paper's own, used for PDF filenames) are different values with different jobs; the UI labels them apart. The field's contents come from the host when it can serve them, and from the user otherwise. `fetchProjects()` returns null for a missing host, a host too old to implement the call, a denied consent prompt, a host-side error, and an empty list — all of which leave the plain text box the field was built as. The fetch is fire-and-forget, so creating a paper never waits on, or fails because of, PaperBell. That host call is a proposal, not something any shipped host implements: a `projects` scope with requestProjects(), a paperbell:projects-changed event, and the PPBProject shape. It is vendored into shared-config.ts and flagged there as ours, with the client methods declared optional and every caller gating on capability + typeof — so it stays inert against the 0.4.4 host in the test vault. PPB_SCHEMA_VERSION deliberately stays at 1 so the "host schema is newer than vendored" warning keeps working for a real upstream v2. docs/PROPOSAL_PROJECTS_SCOPE.md writes the whole thing up for the host team, including the two questions that matter more than the API: which frontmatter shape Project Manager actually queries, and the fact that one paper is up to four notes needing dedupe by longform.title. Co-Authored-By: Claude Opus 5 --- MAINTAINING.md | 12 ++ docs/PAPERBELL_INTEGRATION.md | 47 +++++ docs/PAPERBELL_SUITE.md | 25 ++- docs/PAPER_PROJECT.md | 11 ++ docs/PROPOSAL_PROJECTS_SCOPE.md | 184 ++++++++++++++++++ src/commands/scaffold.ts | 2 +- src/i18n/en.ts | 7 + src/i18n/zh.ts | 7 + src/main.ts | 2 +- src/model/scaffold/paperbell-scaffold.ts | 8 + src/model/scaffold/parts.ts | 69 ++++++- src/paperbell/client.ts | 51 +++++ src/paperbell/shared-config.ts | 73 ++++++- .../new-paper-modal/index.ts | 128 +++++++++++- .../new-paper-modal/project-options.ts | 35 ++++ test/model/paperbell-scaffold.test.ts | 112 +++++++++++ test/paperbell/client.test.ts | 93 ++++++++- test/paperbell/fixtures.ts | 41 +++- test/paperbell/host-conformance.test.ts | 29 +++ test/view/project-options.test.ts | 78 ++++++++ 20 files changed, 986 insertions(+), 28 deletions(-) create mode 100644 docs/PROPOSAL_PROJECTS_SCOPE.md create mode 100644 src/view/project-lifecycle/new-paper-modal/project-options.ts create mode 100644 test/view/project-options.test.ts diff --git a/MAINTAINING.md b/MAINTAINING.md index 6f748b8..d20905b 100644 --- a/MAINTAINING.md +++ b/MAINTAINING.md @@ -45,6 +45,18 @@ config, account, AI via `requestCompletion`) only when PaperBell is present. `paperbell-shared-config.ts` (zero-dependency by design). - It is pinned to `PPB_SCHEMA_VERSION`. When PaperBell bumps its schema, **re-vendor** the file and update the compatibility check. +- ⚠️ **Re-vendoring overwrites our proposal block.** A straight copy from upstream — for *any* + reason, not just a projects-related one — deletes the `projects` additions below and breaks + `src/paperbell/client.ts` and the new-paper modal. After every re-vendor, either re-apply that + block or, if upstream has adopted it, reconcile the two and drop the proposal marker. `npm run + lint` catches the breakage, but only if you run it. +- One block of that file is **not** vendored from upstream: the proposed `projects` scope, flagged + as such in the file header and written up in + [docs/PROPOSAL_PROJECTS_SCOPE.md](./docs/PROPOSAL_PROJECTS_SCOPE.md). Its client methods are + declared **optional** and every caller checks `capabilities` *and* `typeof method === "function"`, + so it stays inert against every host that exists today. `PPB_SCHEMA_VERSION` stays at `1` while it + is a proposal — bumping it unilaterally would silence the newer-schema warning for a real upstream + v2. When the host ships it, re-vendor as usual and delete the proposal marker. ### Contract conformance (verified against PaperBell 0.4.4) diff --git a/docs/PAPERBELL_INTEGRATION.md b/docs/PAPERBELL_INTEGRATION.md index 77e8778..5d69ce9 100644 --- a/docs/PAPERBELL_INTEGRATION.md +++ b/docs/PAPERBELL_INTEGRATION.md @@ -40,6 +40,7 @@ the user the first time it touches a scope; approval is remembered, denial retur | `llm-credentials` | full LLM credentials **including the API key** (for streaming) | Wired, not yet used by a feature | | `activation` | license / activation status | Wired, not yet used by a feature | | `download-ticket` | a ticket for a protected download | Wired, not yet used by a feature | +| `projects` | the host's project list, for linking an output to its project | **Proposed** — consumed by the new-paper modal, but no shipped host implements it | We deliberately request **no** scopes at startup — that would trigger a consent prompt on every launch. Only `plugin-info` (which needs no consent) is read eagerly to learn the @@ -74,6 +75,46 @@ an "AI available" hint gated on `capabilities.includes("llm-invoke")`. > any feature — they are the seams for the roadmap in > [PAPERBELL_SUITE.md](./PAPERBELL_SUITE.md). +### Link a new paper to its project + +The **New PaperBell paper project…** modal asks which PaperBell project the paper is a +deliverable of, and writes the answer as a top-level `project:` key in the frontmatter of +**every** draft index note it creates: + +```yaml +--- +longform: + format: scenes + title: Sea Level Memory + draftTitle: Main Manuscript + ... +project: ColMemo +--- +``` + +That key is the hook Project Manager uses to count a project's outputs. `metadata.json` is +deliberately left alone — it stays pure publication metadata. Note that `project` (the +*project's* acronym) and `_longform.acronym` (this *paper's*, used for PDF filenames) are +different values; the modal labels them distinctly for the same reason. + +Where the dropdown's contents come from, in order: + +1. **Host list** — `fetchProjects()` calls the proposed `requestProjects` (scope: + `projects`) and the field becomes a dropdown of real projects. Gated on + `capabilities.includes("projects")` **and** `typeof client.requestProjects === "function"`: + capabilities can be stale, and an older host's handle simply has no such method. +2. **Free text** — every other case. Host absent, host too old, consent denied, host-side + error, or an empty list all return `null` from `fetchProjects`, and the field stays the + plain text box it was built as. The fetch is fire-and-forget, so creating a paper never + waits on — or fails because of — PaperBell. + +Leaving the field empty omits the key entirely rather than writing an empty `project:`, +which a sibling querying frontmatter would read as a null association. + +The contract for `projects` is a **proposal**, not something any host ships today; it is +vendored (and marked as such) in `src/paperbell/shared-config.ts` and written up for the +host team in [PROPOSAL_PROJECTS_SCOPE.md](./PROPOSAL_PROJECTS_SCOPE.md). + ## Failing safe (standalone mode) - No host → client stays disconnected; `connected` is `false`, `config` is `null`, @@ -90,3 +131,9 @@ PaperBell's contract, pinned to `PPB_SCHEMA_VERSION`. If the host advertises a * schema version than we vendored, the client logs a warning (it does not break). When the host bumps its schema, re-vendor this file and reconcile the check — the procedure and a decoupled conformance test are described in [MAINTAINING.md](../MAINTAINING.md). + +One block of that file is **ours, not upstream's**: the proposed `projects` scope, flagged +in the file header. `PPB_SCHEMA_VERSION` stays at `1` while it is a proposal — raising it +unilaterally would silence the "host schema is newer than vendored" warning for a real +upstream v2. Feature detection never reads the schema version anyway; it reads +capabilities and checks the method exists. diff --git a/docs/PAPERBELL_SUITE.md b/docs/PAPERBELL_SUITE.md index c9afc23..23c3550 100644 --- a/docs/PAPERBELL_SUITE.md +++ b/docs/PAPERBELL_SUITE.md @@ -32,10 +32,14 @@ The host plugin (`paperbell`) sits across all of them, dispatching LLM calls cen - A paper project is a folder of drafts sharing one `metadata.json` ([PAPER_PROJECT.md](./PAPER_PROJECT.md)). Any sibling that reads the vault can discover a paper's parts from the project index frontmatter and `metadata.json`. -- Outputs are meant to link back to their project (`project: `) and to select - `concepts:` — the hooks by which Project Manager counts deliverables and Cards Wrangler - reverse-queries "outputs around this concept". PaperOut writes manuscripts; these - conventions live in the notes. +- Outputs link back to their project via a top-level `project: ` key, **written + by the new-paper scaffold** onto every draft index note (the modal asks for it; the + dropdown is populated from the host when it supports the proposed `projects` scope, and + is a plain text box otherwise). This is the hook by which Project Manager counts + deliverables — see [PROPOSAL_PROJECTS_SCOPE.md](./PROPOSAL_PROJECTS_SCOPE.md) for the + contract and the open questions we have put to the host team. +- `concepts:` — by which Cards Wrangler reverse-queries "outputs around this concept" — + is still a convention that lives in the notes; PaperOut does not write it. - Compile writes stable JSON sidecars (`manuscript-lines.json`, `figure-numbers.json`, …) and a PDF at a predictable path ([MANUSCRIPT_REFS.md](./MANUSCRIPT_REFS.md)). - The Pandoc toolchain is pulled on demand from **paperout-assets-market** @@ -45,6 +49,8 @@ The host plugin (`paperbell`) sits across all of them, dispatching LLM calls cen - PaperOut registers with the host and **follows its UI language**; it reads account status and host capabilities ([PAPERBELL_INTEGRATION.md](./PAPERBELL_INTEGRATION.md)). +- The new-paper modal offers the host's **project list** to link the paper to, via the + proposed `projects` scope — degrading to manual entry on any host that lacks it. **The gap:** PaperOut currently sits somewhat isolated at the Output end. It does **not** yet consume the concept network, the scholar/publication ledger, or a shared citation source; and @@ -66,9 +72,12 @@ Closing that gap is the roadmap below. 4. **Citations via Zotero / Cards.** Resolve `[@citekey]` and produce `references.bib` from Zotero (Better BibTeX) or from Cards Wrangler's citekey footnotes, instead of a hand-maintained `.bib`. -5. **Deliverables to Project Manager.** PaperOut publishes a read API / events so Project - Manager can count a paper's drafts and compile status as a project deliverable, and a - review-tracking sibling can read the harvested sidecars. +5. **Deliverables to Project Manager.** *Partly done:* outputs now carry `project:` in + their frontmatter, so Project Manager can attribute a paper by scanning the vault. + Still open: PaperOut publishes a read API / events so Project Manager can count a + paper's drafts and compile status without scanning, and a review-tracking sibling can + read the harvested sidecars. The contract is one-way today — see + [PROPOSAL_PROJECTS_SCOPE.md §6](./PROPOSAL_PROJECTS_SCOPE.md). 6. **Compile-finished hooks.** A "compile finished" event triggers downstream packaging / submission, or a `results.json` refresh from an analysis plugin. @@ -129,6 +138,8 @@ ready to pick up. ## See also - [PAPERBELL_INTEGRATION.md](./PAPERBELL_INTEGRATION.md) — the host handshake and scopes. +- [PROPOSAL_PROJECTS_SCOPE.md](./PROPOSAL_PROJECTS_SCOPE.md) — the `projects` scope we have + proposed to the host, and the frontmatter questions that go with it. - [PAPER_PROJECT.md](./PAPER_PROJECT.md) — the paper project scaffold and layout. - [MANUSCRIPT_REFS.md](./MANUSCRIPT_REFS.md) — sidecars and response-letter sync. - [METADATA_AND_PLACEHOLDERS.md](./METADATA_AND_PLACEHOLDERS.md) — `metadata.json` and `{{ }}`. diff --git a/docs/PAPER_PROJECT.md b/docs/PAPER_PROJECT.md index 2147926..f45720d 100644 --- a/docs/PAPER_PROJECT.md +++ b/docs/PAPER_PROJECT.md @@ -8,6 +8,17 @@ Create one via the folder right-click menu **New PaperBell paper project…**, o of the same name (`newPaperProject`). Enter a title; the acronym is auto-derived from the initials (editable). +## Linking the paper to a project + +The modal also asks which **PaperBell project** the paper is a deliverable of. The answer +is written as a top-level `project:` key in every draft index note's frontmatter — the hook +sibling plugins use to count a project's outputs. Leave it empty and the key is omitted. + +This is the *project's* acronym (`ColMemo`), not the paper's own acronym above (`SLM`) — +they are separate values with separate jobs. When the PaperBell host is installed and new +enough, the field is a dropdown of your real projects; otherwise it is a text box you fill +in yourself. See [PAPERBELL_INTEGRATION.md](./PAPERBELL_INTEGRATION.md#link-a-new-paper-to-its-project). + ## Choosing the parts The modal asks which parts the paper needs. **Only the Main Manuscript is created by diff --git a/docs/PROPOSAL_PROJECTS_SCOPE.md b/docs/PROPOSAL_PROJECTS_SCOPE.md new file mode 100644 index 0000000..952a13d --- /dev/null +++ b/docs/PROPOSAL_PROJECTS_SCOPE.md @@ -0,0 +1,184 @@ +# Proposal to the PaperBell host: a `projects` scope + +**Status:** proposal. Nothing here is implemented by any shipped host. PaperOut has +vendored the types (`src/paperbell/shared-config.ts`, marked as a proposal) and gates +every call on capability detection, so this document can be reviewed, changed, or +rejected without breaking anything on our side. + +**Why:** PaperOut writes an academic paper into `50 - Outputs`, but nothing in the note +said which project the paper belongs to, so Project Manager could not recognize it as a +project deliverable. As of this change, the **New PaperBell paper project…** modal asks +for the project and writes it into every draft's frontmatter. What it offers in that +dropdown is what this proposal is about. + +--- + +## 1. The frontmatter convention matters more than the API + +This is the request we most want an answer to, and it does not depend on any code you +ship. + +Today PaperOut writes, at the top level of each draft index note: + +```yaml +--- +longform: + format: scenes + title: Sea Level Memory + draftTitle: Main Manuscript + workflow: PaperBell Manuscript + sceneFolder: manuscript + scenes: + - introduction + - methods + - results + ignoredFiles: [] +project: ColMemo +--- +``` + +`project: `, a plain string — following the convention already written down in +[PAPERBELL_SUITE.md](./PAPERBELL_SUITE.md). **If Project Manager actually queries +something else, tell us before this reaches users.** + +The main alternative is a wikilink (`project: "[[40 - Projects/ColMemo]]"`), which buys +native backlinks and survives renaming the project note — genuinely better properties. +Changing our writer is one line; migrating notes already on users' disks is not. So the +cost of getting this wrong grows with every release. + +**A way to not have to decide centrally:** return a `frontmatterValue` field on each +project and PaperOut will write it verbatim. Then the authority over the interop format +lives in Project Manager, where it belongs, and we never have to re-agree on it. + +## 2. One paper is up to four notes — dedupe by `longform.title` + +A PaperBell paper project can contain a Main Manuscript, a Supplementary, a Response +Letter, and a Cover Letter. Each is its own note with its own frontmatter, and **each +carries the same `project:` value**. We chose that deliberately: a supplementary or a +response letter is genuinely part of the project's output, and opening any one of them +should show what it belongs to. + +The consequence for you: counting notes counts one paper up to four times. Two ready-made +dedupe keys: + +- `longform.title` — identical across all drafts of one paper (that is precisely what + groups them into a project in our model); +- `_longform.acronym` in the paper folder's `metadata.json` — the paper's own acronym. + +Note that `_longform.acronym` (e.g. `SLM`, this *paper's* code, used for PDF filenames) +and `project` (e.g. `ColMemo`, the *project's* code) are different things. We keep them +visibly separate in our UI; worth doing the same in yours. + +## 3. The `projects` scope + +Vendored verbatim in `src/paperbell/shared-config.ts`. + +```ts +export const PPB_PROJECTS_CHANGED_EVENT = "paperbell:projects-changed"; + +export interface PPBProject { + id: string; // 稳定 id,重命名 / 移动后不变 + name: string; // 展示名 + acronym?: string; // 写入 `project:` 的值;同 vault 内须唯一 + notePath?: string; // 项目笔记路径(可选) + status?: "active" | "planned" | "paused" | "done" | "archived"; + folder?: string; // 项目根文件夹(可选) + concepts?: string[]; // 关联的 featured concepts(可选) +} + +export interface PPBProjectsQuery { + status?: NonNullable[]; // 缺省 ["active", "planned"] + query?: string; +} + +export interface PPBProjectsResult { + ok: boolean; + projects: PPBProject[]; + error?: string; +} + +// on PPBClient: +requestProjects?(params?: PPBProjectsQuery): Promise; +onProjectsChange?(cb: () => void): () => void; +``` + +Three requests about the data: + +1. **`id` must be stable across renames and moves.** A vault path does not satisfy this. + We display `name`, write `acronym`, and would use `id` for the reverse reporting in + §6. If the only stable handle you have is the path, say so and we will not build + anything on `id`. +2. **`acronym` must be unique within a vault.** Two projects sharing one acronym write + the same `project:` value, and deliverable attribution silently becomes wrong. If you + cannot guarantee uniqueness, drop `acronym` from the contract and give us + `frontmatterValue` (§1) instead — one authoritative string per project. +3. **Please make consent cheap for this scope.** Nothing here is sensitive: project names + and acronyms are visible to the user in their own vault. As a consent-gated scope, the + user gets a permission dialog the first time they create a paper — friction with no + security benefit. Either mark it low-friction, or fold it into the existing `config` + scope. + +**One concrete consequence of the consent gate.** We call `requestProjects()` when the +new-paper modal opens, and the contract gives us no way to cancel a pending request. If the +user closes the modal while your permission dialog is up, that dialog **outlives the modal** +— it appears orphaned, asking about a field that is no longer on screen. Two ways out, either +is fine: + +- a consent-free probe (e.g. `hasProjects(): boolean`, or simply advertising a count in + `getPluginInfo()`) so we only trigger the real prompt when there is something to show; or +- an `AbortSignal` parameter on `requestProjects`, so a closing modal can withdraw the ask. + +Making the scope low-friction (above) also dissolves this, since there would be no dialog to +strand. + +## 4. `paperbell:projects-changed` + +Same semantics as the existing `paperbell:config-changed`. Without it we re-fetch every +time the modal opens; with it we can hold a list and refresh on change. Lower priority +than §3 — the feature works without it. + +## 5. Version discipline + +`MAINTAINING.md` already records that two different host builds both reported `0.4.4`. +When you add this API surface, please also bump `PaperBellPluginInfo.version` **and** add +`"projects"` to `capabilities`. + +Our detection deliberately ignores the version string and checks two things: + +```ts +capabilities.includes("projects") && typeof client.requestProjects === "function" +``` + +So **`capabilities` has to be honest** — advertising a scope you do not implement is the +one failure mode that reaches a user (we handle it without throwing, but the dropdown +silently stays a text field). Bumping `schemaVersion` is optional for this change since +it is backward-compatible; if you do bump it, we will re-vendor and realign +`PPB_SCHEMA_VERSION` on our side. + +## 6. The reverse direction (next round — not blocking this one) + +The contract is currently one-way: sub-plugins consume, and there is no publish path. +`PPBRequestSource` has no field through which a sub-plugin can expose its own API, so +today there is no way for Project Manager to ask us "what deliverables does this project +have, and how far along is each one?" other than reaching into +`app.plugins.plugins["longform-paperbell"].api` directly. + +Two ways to close it — **we are happy with either, please pick one**: + +- **Registry**: let `registerPPBplugin` accept an `api` field, and let the host hand a + sibling's API to another sub-plugin on request. Generic, and solves this for every + pair of plugins at once. +- **Bus**: give the host an `emit(event, payload)` and let us broadcast + `paperout:deliverable-changed` when a project is scaffolded and when a compile + finishes. Simpler, push-based, no query surface to design. + +Until one exists, Project Manager can only scan frontmatter — which is exactly why §1 +and §2 are the parts of this document that need an answer first. + +## See also + +- [PAPERBELL_INTEGRATION.md](./PAPERBELL_INTEGRATION.md) — the handshake and scopes as + they exist today. +- [PAPERBELL_SUITE.md](./PAPERBELL_SUITE.md) — where PaperOut sits in CIMPO and the + wider collaboration roadmap. +- [PAPER_PROJECT.md](./PAPER_PROJECT.md) — what a paper project is made of. diff --git a/src/commands/scaffold.ts b/src/commands/scaffold.ts index 150999a..db86177 100644 --- a/src/commands/scaffold.ts +++ b/src/commands/scaffold.ts @@ -15,6 +15,6 @@ export const newPaperProject: CommandBuilder = (plugin) => ({ active?.parent instanceof TFolder ? active.parent : plugin.app.vault.getRoot(); - new NewPaperModal(plugin.app, parent).open(); + new NewPaperModal(plugin, parent).open(); }, }); diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 4d473b0..6e81229 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -87,6 +87,13 @@ export const en = { "scaffold.acronymLabel": "Acronym", "scaffold.acronymDesc": "Short code used for the PDF name and labels. Defaults to the title’s initials; editable later in metadata.json.", + "scaffold.projectLabel": "PaperBell project", + "scaffold.projectDesc": + "The research project this paper is a deliverable of — not the paper’s own acronym above. Written as a project: key in each draft’s frontmatter, which is how Project Manager counts a project’s outputs. Leave empty for none.", + "scaffold.projectPlaceholder": "e.g. ColMemo", + "scaffold.projectNone": "— No project —", + "scaffold.projectManual": "Enter manually…", + "scaffold.projectBackToList": "Choose from the project list instead", "scaffold.create": "Create project", "scaffold.invalidName": "Enter a project title without : \\ or / characters.", "scaffold.created": "Created PaperBell project “{title}”.", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 9c88a47..921e996 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -78,6 +78,13 @@ export const zh: Messages = { "scaffold.acronymLabel": "缩写", "scaffold.acronymDesc": "用于 PDF 文件名和标签的短代码。默认取标题首字母,之后可在 metadata.json 中修改。", + "scaffold.projectLabel": "所属 PaperBell 项目", + "scaffold.projectDesc": + "这篇论文作为交付物所属的研究项目 —— 不是上面那个论文自身的缩写。它会写进每个 draft 笔记的 project: 字段,供 Project Manager 统计项目产出。留空表示不关联。", + "scaffold.projectPlaceholder": "例如 ColMemo", + "scaffold.projectNone": "— 不关联项目 —", + "scaffold.projectManual": "手动输入…", + "scaffold.projectBackToList": "改为从项目列表中选择", "scaffold.create": "创建项目", "scaffold.invalidName": "请输入不含 : \\ 或 / 的项目标题。", "scaffold.created": "已创建 PaperBell 项目“{title}”。", diff --git a/src/main.ts b/src/main.ts index e0df505..b14cfda 100644 --- a/src/main.ts +++ b/src/main.ts @@ -112,7 +112,7 @@ export default class LongformPlugin extends Plugin { .setTitle(translate("menu.newPaperProject")) .setIcon(ICON_NAME) .onClick(() => { - new NewPaperModal(this.app, file).open(); + new NewPaperModal(this, file).open(); }); }); // Only offered where there is something to add to, so this doesn't diff --git a/src/model/scaffold/paperbell-scaffold.ts b/src/model/scaffold/paperbell-scaffold.ts index 7af1ac4..cbf4361 100644 --- a/src/model/scaffold/paperbell-scaffold.ts +++ b/src/model/scaffold/paperbell-scaffold.ts @@ -30,6 +30,13 @@ export interface ScaffoldOptions { title: string; /** Short acronym for the PDF name / labels. Defaults to initials of `title`. */ acronym?: string; + /** + * The PaperBell project this paper is a deliverable of — the *project's* acronym + * (e.g. "ColMemo"), not this paper's `acronym` above. Written as a top-level + * `project:` frontmatter key on every index note, which is the hook sibling + * plugins use to count a project's outputs. Omitted entirely when unset. + */ + project?: string; /** * Which parts to create. Required, with no default: the whole point of the * option is that the file set follows the selection, and a default would leave @@ -236,6 +243,7 @@ export function scaffoldContext(opts: ScaffoldOptions): PartContext { title: opts.title.trim(), acronym: (opts.acronym || acronymFromTitle(opts.title.trim())).trim(), author: PLACEHOLDER_AUTHOR, + 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 c12dfac..e918eb8 100644 --- a/src/model/scaffold/parts.ts +++ b/src/model/scaffold/parts.ts @@ -31,6 +31,16 @@ export interface PartContext { title: string; acronym: string; author: 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 + * (e.g. "ColMemo") — not `acronym` above, which is this paper's own. + * + * Undefined when the user did not pick one, in which case the key is omitted + * entirely: an empty `project:` would read as a null association to a sibling + * plugin querying frontmatter, which is worse than no key at all. + */ + project?: string; /** * Whether `figs/example_*` are available in the project — either written by * this same operation, or already on disk. Body text that references them is @@ -77,6 +87,42 @@ export function json(value: unknown): string { return JSON.stringify(value, null, 2) + "\n"; } +/** 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 ._-]*$/; + +/** + * Bare words YAML resolves to something other than a string. Left unquoted, an + * acronym like `2024` would come back as a number and `no` as a boolean, so a + * sibling reading `project:` would not get the string it was written. + */ +const YAML_NON_STRING = /^(-?\d+(\.\d+)?|true|false|yes|no|on|off|null|~)$/i; + +/** + * Render a string as a YAML scalar, quoting it whenever writing it bare would be + * ambiguous, invalid, or read back as a non-string. + * + * Project acronyms are usually plain (`ColMemo`), but this value can be typed by + * hand — a colon, a leading `[`, or CJK punctuation would otherwise produce + * frontmatter Obsidian cannot parse. JSON's string syntax is a subset of YAML's + * double-quoted style, so `JSON.stringify` is a correct escaper here. + */ +export function yamlScalar(value: string): string { + const bare = + PLAIN_YAML_SCALAR.test(value) && !YAML_NON_STRING.test(value); + return bare ? value : JSON.stringify(value); +} + +/** + * The top-level `project:` line for an index note, or "" when unset. + * + * Includes its own trailing newline so callers can interpolate it directly before + * the frontmatter's closing `---` without leaving a blank line behind. + */ +function projectLine(ctx: PartContext): string { + const project = ctx.project?.trim(); + return project ? `project: ${yamlScalar(project)}\n` : ""; +} + // ── Body text ─────────────────────────────────────────────────────────────── const INTRODUCTION_MD = `# Introduction @@ -209,7 +255,8 @@ ${figureSection}`; // ── Index notes (legacy form) ─────────────────────────────────────────────── -function mainIndex(title: string): string { +function mainIndex(ctx: PartContext): string { + const title = ctx.title; return `--- longform: format: scenes @@ -222,13 +269,14 @@ longform: - methods - results ignoredFiles: [] ---- +${projectLine(ctx)}--- Main manuscript of **${title}**. Shared publication metadata lives in \`metadata.json\` in this folder; compile it with the **PaperBell Manuscript** workflow. `; } -function responseIndex(title: string): string { +function responseIndex(ctx: PartContext): string { + const title = ctx.title; return `--- longform: format: scenes @@ -239,13 +287,14 @@ longform: scenes: - response ignoredFiles: [] ---- +${projectLine(ctx)}--- Response-letter draft of **${title}**. Compile the **Main Manuscript** first (it harvests \`manuscript-lines.json\` / \`figure-numbers.json\`), then compile this with **PaperBell Response Letter**: the \`\`\`manuscript\`\`\` fences pull the manuscript's current text into a Page/Line box, and figure labels resolve to the manuscript's figure numbers. `; } -function supplementaryIndex(title: string): string { +function supplementaryIndex(ctx: PartContext): string { + const title = ctx.title; return `--- longform: format: scenes @@ -256,7 +305,7 @@ longform: scenes: - supplementary results ignoredFiles: [] ---- +${projectLine(ctx)}--- Supplementary draft of **${title}**. Its own \`metadata.json\` in this folder (found before the shared one at the project root) adds \`supplementary: true\`, so figures and tables are numbered S1, S2, … `; @@ -284,7 +333,7 @@ function coverLetter(ctx: PartContext, form: ProjectForm): string { ${longform}title: Cover letter manuscript: ${ctx.title} acronym: ${ctx.acronym} -date: +${projectLine(ctx)}date: to: Dear Editor, corresponding: ${ctx.author} (you@example.com) --- @@ -357,7 +406,7 @@ export const PAPER_PARTS: readonly PaperPart[] = [ if (form === "legacy") { files.push({ path: "Main Manuscript (Index).md", - text: mainIndex(ctx.title), + text: mainIndex(ctx), }); return { files }; } @@ -396,7 +445,7 @@ export const PAPER_PARTS: readonly PaperPart[] = [ if (form === "legacy") { files.push({ path: "supplementary/Supplementary (Index).md", - text: supplementaryIndex(ctx.title), + text: supplementaryIndex(ctx), }); return { files }; } @@ -454,7 +503,7 @@ export const PAPER_PARTS: readonly PaperPart[] = [ if (form === "legacy") { files.push({ path: "Response Letter (Index).md", - text: responseIndex(ctx.title), + text: responseIndex(ctx), }); return { files }; } diff --git a/src/paperbell/client.ts b/src/paperbell/client.ts index c525bf0..64b2e92 100644 --- a/src/paperbell/client.ts +++ b/src/paperbell/client.ts @@ -12,8 +12,11 @@ import { type PPBActivationInfo, type PPBDownloadTicket, type PPBDownloadTicketParams, + type PPBProject, + type PPBProjectsQuery, type PaperBellAccountInfo, type PaperBellSharedConfigPublic, + type PPBScope, } from "./shared-config"; import { paperbell, DISCONNECTED } from "./store"; @@ -40,6 +43,8 @@ export class PaperBellClient { private plugin: LongformPlugin; private client: PPBClientHandle | null = null; private unsubscribeConfig: (() => void) | null = null; + /** Host-advertised scopes, mirrored into the store for the UI to read. */ + private capabilities: PPBScope[] = []; constructor(plugin: LongformPlugin) { this.plugin = plugin; @@ -107,6 +112,7 @@ export class PaperBellClient { console.warn("[PaperOut] Could not read PaperBell plugin info:", e); } + this.capabilities = capabilities; paperbell.set({ connected: true, config: null, capabilities }); console.log("[PaperOut] Connected to PaperBell host."); @@ -178,6 +184,50 @@ export class PaperBellClient { : null; } + /** + * The host's project-list method, or null when it cannot serve one. + * + * Both halves matter: `capabilities` comes from `getPluginInfo()` and says what the + * host *advertises* (and therefore what it will prompt for consent on), while the + * `typeof` check is what stops us calling a method an older host's handle simply + * does not have. Neither alone is trustworthy. + */ + private get projectsRequester(): PPBClientHandle["requestProjects"] | null { + if (!this.client) return null; + if (!this.capabilities.includes("projects")) return null; + const request = this.client.requestProjects; + return typeof request === "function" ? request.bind(this.client) : null; + } + + /** + * Request the host's project list (scope: `projects`). First call prompts for consent. + * + * Returns `null` for every "no list available" case — host absent, host too old to + * implement it, consent denied, host-side error, or a thrown exception. Callers are + * meant to treat them identically and fall back to manual entry, so a missing project + * list can never block creating a paper. + */ + async fetchProjects(query?: PPBProjectsQuery): Promise { + const requestProjects = this.projectsRequester; + if (!requestProjects) return null; + try { + const result = await requestProjects(query); + if (!result) return null; // consent denied + if (!result.ok) { + console.warn( + "[PaperOut] PaperBell could not list projects:", + result.error ?? "(no error given)" + ); + return null; + } + return result.projects ?? []; + } catch (e) { + // The host is a plugin we do not control; a throw here must not reach the modal. + console.warn("[PaperOut] Error requesting PaperBell projects:", e); + return null; + } + } + /** Tear down: unsubscribe, unregister from the host, reset the store. */ destroy(): void { if (this.unsubscribeConfig) { @@ -192,6 +242,7 @@ export class PaperBellClient { } this.client = null; } + this.capabilities = []; paperbell.set({ ...DISCONNECTED }); } diff --git a/src/paperbell/shared-config.ts b/src/paperbell/shared-config.ts index fe234bd..458cf72 100644 --- a/src/paperbell/shared-config.ts +++ b/src/paperbell/shared-config.ts @@ -14,6 +14,14 @@ * scopes and their `request*` methods, the `paperbell:plugins-changed` event, and the * `providerId` / `providerName` / `hasApiKey` fields on the public LLM config. * + * ⚠️ PROPOSAL — NOT YET UPSTREAM: the `projects` scope and everything it drags in + * (`PPB_PROJECTS_CHANGED_EVENT`, `PPBProject`, `PPBProjectsQuery`, `PPBProjectsResult`, + * `PPBClient.requestProjects` / `onProjectsChange`) are *our* proposal to the host, written + * up in docs/PROPOSAL_PROJECTS_SCOPE.md. No shipped host implements them yet, which is why + * the client methods are optional and every consumer gates on capability + `typeof` checks + * rather than on `PPB_SCHEMA_VERSION` — which stays at 1 until the host really bumps it, + * so the "host schema is newer than vendored" warning keeps working. + * * ── Original header ────────────────────────────────────────────────────────── * PaperBell 对外共享契约(消费方 / IPC 表面)。 * 安全约定: @@ -43,6 +51,12 @@ export const PPB_CONFIG_CHANGED_EVENT = "paperbell:config-changed"; */ export const PPB_PLUGINS_CHANGED_EVENT = "paperbell:plugins-changed"; +/** + * **提案(尚未上游实现)**:项目清单发生变化时宿主在 `app.workspace` 上 trigger 的事件名。 + * 语义对齐 {@link PPB_CONFIG_CHANGED_EVENT} —— 有它子插件就不必每次开界面都重新拉取。 + */ +export const PPB_PROJECTS_CHANGED_EVENT = "paperbell:projects-changed"; + /** Cards Wrangler 期望从 PaperBell 主插件读到的共享配置(消费方契约)。 */ export interface PaperBellSharedConfig { schemaVersion: number; // 便于未来兼容判断 @@ -118,7 +132,9 @@ export type PPBScope = | "llm-invoke" | "llm-credentials" | "activation" - | "download-ticket"; + | "download-ticket" + /** **提案(尚未上游实现)**:宿主维护的项目清单。见 docs/PROPOSAL_PROJECTS_SCOPE.md。 */ + | "projects"; /** * `llm-invoke`:请求宿主用其 AI 配置代发一次**非流式**补全。 @@ -176,6 +192,47 @@ export interface PPBDownloadTicket { [key: string]: unknown; } +/** + * `projects`(**提案,尚未上游实现**):宿主(Project Manager)维护的一条项目记录。 + * + * 子插件用它把自己的产出挂到某个项目下 —— 展示用 `name`,写入交付物 frontmatter 用 + * `acronym`,将来做反向上报用 `id`。 + */ +export interface PPBProject { + /** 稳定 id。项目笔记重命名 / 移动后必须保持不变(因此不宜直接用 vault 路径)。 */ + id: string; + /** 项目全称,下拉菜单的展示名。 */ + name: string; + /** + * 项目缩写 / 代号 —— 交付物 frontmatter `project:` 实际写入的值。 + * 宿主须保证同一 vault 内唯一;缺省时消费方回退到 `name`。 + */ + acronym?: string; + /** 项目笔记的 vault 路径(可选),供跳转 / 生成链接。 */ + notePath?: string; + /** 生命周期状态。消费方默认只列 active / planned。 */ + status?: "active" | "planned" | "paused" | "done" | "archived"; + /** 项目根文件夹(可选),供子插件建议交付物落盘位置。 */ + folder?: string; + /** 关联的 featured concepts(可选),供子插件预填 `concepts:`。 */ + concepts?: string[]; +} + +/** 拉取项目清单的过滤条件。全部可选,由宿主端做匹配。 */ +export interface PPBProjectsQuery { + /** 只返回这些状态的项目;缺省 `["active", "planned"]`。 */ + status?: NonNullable[]; + /** 名称 / 缩写关键词。 */ + query?: string; +} + +export interface PPBProjectsResult { + ok: boolean; + projects: PPBProject[]; + /** ok=false 时的错误描述。 */ + error?: string; +} + /** 调用方(子插件)身份。用于同意弹框展示、授权名单存储与设置入口卡片。 */ export interface PPBRequestSource { /** 稳定的插件 id(建议与其 manifest id 一致)。 */ @@ -237,6 +294,20 @@ export interface PPBClient { onConfigChange( cb: (config: PaperBellSharedConfigPublic) => void, ): () => void; + /** + * 请求宿主的项目清单(scope: projects)。拒绝授权 / 宿主缺失返回 null。 + * + * **提案,尚未上游实现** —— 因此是可选成员:已发布的宿主返回的 handle 上没有这个 + * 方法,声明成必选会让类型撒谎。调用前必须做 `typeof` 检查。 + */ + requestProjects?( + params?: PPBProjectsQuery, + ): Promise; + /** + * 订阅项目清单变更;返回取消订阅函数。底层即 workspace 事件 + * {@link PPB_PROJECTS_CHANGED_EVENT}。**提案,尚未上游实现**(同上,可选)。 + */ + onProjectsChange?(cb: () => void): () => void; /** 注销客户端并清理订阅(不撤销授权)。 */ unregister(): void; } diff --git a/src/view/project-lifecycle/new-paper-modal/index.ts b/src/view/project-lifecycle/new-paper-modal/index.ts index 3a79f08..719eac0 100644 --- a/src/view/project-lifecycle/new-paper-modal/index.ts +++ b/src/view/project-lifecycle/new-paper-modal/index.ts @@ -1,5 +1,4 @@ import { - App, ButtonComponent, Modal, Notice, @@ -9,6 +8,7 @@ import { } from "obsidian"; import { translate } from "src/i18n"; +import type LongformPlugin from "src/main"; import { selectedDraftVaultPath } from "src/model/stores"; import { selectedTab } from "src/view/stores"; import { @@ -17,28 +17,50 @@ import { writePaperbellScaffold, type PaperPartId, } from "src/model/scaffold"; +import { projectOptions, type ProjectOption } from "./project-options"; const ILLEGAL = /[:\\/]/; /** - * Prompts for a project title, an optional acronym, and which parts the paper - * needs, then scaffolds the project under `parent`. + * Dropdown value meaning "let me type it myself". + * + * Cannot collide with a real project: `projectOptions` trims every value and drops + * the empty ones, so no option it produces can start with a space. + */ +const MANUAL_ENTRY = " manual entry"; + +/** + * Prompts for a project title, an optional acronym, the PaperBell project the + * paper is a deliverable of, and which parts it needs, then scaffolds the project + * under `parent`. * * Only the Main Manuscript is created by default: a short paper often needs no * supplement and never needs a response letter before review. Anything left out * can be added later with "Add paper components…". */ export default class NewPaperModal extends Modal { + private plugin: LongformPlugin; private parent: TFolder; private titleValue = ""; private acronymValue = ""; private acronymEdited = false; + /** The PaperBell project's acronym, or "" for no association. */ + private projectValue = ""; + /** True once the user has typed into the project field by hand. */ + private projectEdited = false; + private projectSetting: Setting | null = null; + /** + * The host's projects, once fetched. Kept so switching to manual entry is not a + * one-way door — the text field offers a button back to the list. + */ + private hostProjects: ProjectOption[] = []; /** Main is mandatory — see the note on the toggle below. */ private parts = new Set(["main"]); private examples = true; - constructor(app: App, parent: TFolder) { - super(app); + constructor(plugin: LongformPlugin, parent: TFolder) { + super(plugin.app); + this.plugin = plugin; this.parent = parent; } @@ -88,6 +110,20 @@ export default class NewPaperModal extends Modal { }); }); + // Starts as a plain text field — the control that always works. If the host + // turns out to have a project list, it is swapped for a dropdown below. + // + // Known limitation: the `projects` scope is consent-gated, and the contract has + // no way to cancel a pending request. Close the modal while the host's + // permission dialog is up and that dialog outlives it. Asking the host for a + // consent-free "do you have projects?" probe is filed in + // docs/PROPOSAL_PROJECTS_SCOPE.md; until then the render is guarded instead. + this.projectSetting = new Setting(contentEl) + .setName(translate("scaffold.projectLabel")) + .setDesc(translate("scaffold.projectDesc")); + this.renderProjectTextInput(); + void this.loadHostProjects(); + contentEl.createEl("h4", { text: translate("scaffold.partsHeading") }); for (const part of PAPER_PARTS) { @@ -129,6 +165,86 @@ export default class NewPaperModal extends Modal { validate(); } + /** + * Ask the host for its project list and, if it has one, upgrade the field to a + * dropdown. Deliberately fire-and-forget: `fetchProjects` returns null for a + * missing host, an older host, a denied consent prompt, or a host-side error, + * and every one of those just leaves the text field in place. Creating a paper + * never waits on — or fails because of — PaperBell. + */ + private async loadHostProjects(): Promise { + const projects = await this.plugin.paperBell?.fetchProjects(); + if (!projects || projects.length === 0) return; + // The modal may already be gone — `onClose` nulls the Setting, which is what + // makes this safe. We cannot cancel the host's consent prompt itself; see the + // note on the call site. + if (!this.projectSetting) return; + this.hostProjects = projectOptions(projects); + // Don't yank the field out from under someone who gave up waiting on the + // consent prompt and typed the acronym themselves. + if (this.projectEdited) return; + this.renderProjectDropdown(); + } + + /** Swap the project field's control, keeping `projectValue` as the source of truth. */ + private replaceProjectControl(render: (setting: Setting) => void): void { + const setting = this.projectSetting; + if (!setting) return; + // `clear()` (not `controlEl.empty()`) so the discarded component is also + // dropped from the Setting's `components` array. + setting.clear(); + render(setting); + } + + private renderProjectTextInput(focus = false): void { + this.replaceProjectControl((setting) => { + // Only offered once a host list exists, so manual entry is not a one-way door. + if (this.hostProjects.length > 0) { + setting.addExtraButton((button) => { + button + .setIcon("list") + .setTooltip(translate("scaffold.projectBackToList")) + .onClick(() => this.renderProjectDropdown()); + }); + } + setting.addText((text) => { + text + .setPlaceholder(translate("scaffold.projectPlaceholder")) + .setValue(this.projectValue) + .onChange((value) => { + this.projectEdited = true; + this.projectValue = value; + }); + if (focus) text.inputEl.focus(); + }); + }); + } + + private renderProjectDropdown(): void { + this.replaceProjectControl((setting) => { + setting.addDropdown((dropdown) => { + dropdown.addOption("", translate("scaffold.projectNone")); + for (const option of this.hostProjects) { + dropdown.addOption(option.value, option.label); + } + dropdown.addOption(MANUAL_ENTRY, translate("scaffold.projectManual")); + // A hand-typed value need not be in the list; fall back to "no project" + // rather than letting the select silently show the wrong row. + const known = this.hostProjects.some((o) => o.value === this.projectValue); + dropdown.setValue(known ? this.projectValue : ""); + dropdown.onChange((value) => { + if (value === MANUAL_ENTRY) { + // Keep whatever was selected as the starting text — switching input + // method should not throw away the answer. + this.renderProjectTextInput(true); + return; + } + this.projectValue = value; + }); + }); + }); + } + private async create(): Promise { const title = this.titleValue.trim(); if (!title || ILLEGAL.test(title)) { @@ -139,6 +255,7 @@ export default class NewPaperModal extends Modal { const primaryPath = await writePaperbellScaffold(this.app, this.parent.path, { title, acronym: this.acronymValue.trim() || undefined, + project: this.projectValue.trim() || undefined, parts: [...this.parts], examples: this.examples, }); @@ -155,6 +272,7 @@ export default class NewPaperModal extends Modal { } onClose(): void { + this.projectSetting = null; this.contentEl.empty(); } } diff --git a/src/view/project-lifecycle/new-paper-modal/project-options.ts b/src/view/project-lifecycle/new-paper-modal/project-options.ts new file mode 100644 index 0000000..156dfa4 --- /dev/null +++ b/src/view/project-lifecycle/new-paper-modal/project-options.ts @@ -0,0 +1,35 @@ +import type { PPBProject } from "src/paperbell/shared-config"; + +/** One entry of the project dropdown. */ +export interface ProjectOption { + /** What lands in the note's `project:` frontmatter. */ + value: string; + /** What the dropdown shows. */ + label: string; +} + +/** + * Turn the host's project list into dropdown options. + * + * Kept pure and DOM-free so the interesting parts — which field becomes the + * frontmatter value, and what happens to a project the host returned without an + * acronym — are unit-testable without an Obsidian environment. + * + * `acronym` is the interop key (see docs/PROPOSAL_PROJECTS_SCOPE.md), but it is + * optional in the contract, so we fall back to the name rather than silently + * dropping the project. Entries with nothing usable at all are dropped: an option + * that would write an empty `project:` is worse than an absent one. + */ +export function projectOptions(projects: PPBProject[]): ProjectOption[] { + return projects + .map((project) => { + const value = (project.acronym || project.name || "").trim(); + const name = (project.name || "").trim(); + return { + value, + label: name && value !== name ? `${name} (${value})` : value, + }; + }) + .filter((option) => option.value.length > 0) + .sort((a, b) => a.label.localeCompare(b.label)); +} diff --git a/test/model/paperbell-scaffold.test.ts b/test/model/paperbell-scaffold.test.ts index c20e0f8..b581871 100644 --- a/test/model/paperbell-scaffold.test.ts +++ b/test/model/paperbell-scaffold.test.ts @@ -9,6 +9,7 @@ import { import { ALL_PAPER_PARTS, PAPER_PARTS, + yamlScalar, type PaperPartId, type ScaffoldFile, } from "src/model/scaffold/parts"; @@ -259,6 +260,117 @@ describe("buildPaperbellScaffold — selections", () => { }); }); +describe("buildPaperbellScaffold — the PaperBell project link", () => { + /** The index notes that carry a draft's frontmatter, for every part. */ + const INDEX_NOTES = [ + "Main Manuscript (Index).md", + "supplementary/Supplementary (Index).md", + "Response Letter (Index).md", + "Cover Letter.md", + ]; + + const withProject = (project?: string) => + buildPaperbellScaffold({ + title: "My Paper", + project, + parts: ALL, + examples: false, + }); + + it("writes project: on every index note as a top-level key", () => { + const files = withProject("ColMemo"); + for (const path of INDEX_NOTES) { + const text = textOf(files, path); + // Top-level means column 0 — indented, it would land inside `longform:` + // and be read as part of the draft definition instead of the note's own + // frontmatter, which is what sibling plugins query. + expect(text, path).toMatch(/^project: ColMemo$/m); + // …and inside the frontmatter block, not the body. + const frontmatter = text.split("---")[1]; + expect(frontmatter, path).toContain("project: ColMemo"); + } + }); + + it("omits the key entirely when no project is chosen", () => { + // An empty `project:` reads as a null association to a plugin querying + // frontmatter — worse than no key at all. + for (const files of [withProject(undefined), withProject(" ")]) { + for (const path of INDEX_NOTES) { + expect(textOf(files, path), path).not.toMatch(/^project:/m); + } + } + }); + + it("does not touch metadata.json — it stays pure publication metadata", () => { + const metadata = JSON.parse(textOf(withProject("ColMemo"), "metadata.json")); + expect(metadata._longform).not.toHaveProperty("project"); + expect(metadata).not.toHaveProperty("project"); + }); + + it("keeps the paper's own acronym distinct from the project's", () => { + const text = textOf( + buildPaperbellScaffold({ + title: "Sea Level Memory", + acronym: "SLM", + project: "ColMemo", + parts: ["main", "cover"], + examples: false, + }), + "Cover Letter.md" + ); + expect(text).toMatch(/^acronym: SLM$/m); + expect(text).toMatch(/^project: ColMemo$/m); + }); + + it("quotes a hand-typed value that would break the YAML", () => { + const text = textOf(withProject("Ocean: Memory"), "Main Manuscript (Index).md"); + expect(text).toMatch(/^project: "Ocean: Memory"$/m); + }); + + it("leaves the frontmatter well-formed for every part", () => { + // Interpolating an optional line is exactly the kind of edit that leaves a + // stray blank line behind — which ends the YAML block early in some parsers. + for (const files of [withProject("ColMemo"), withProject(undefined)]) { + for (const path of INDEX_NOTES) { + const text = textOf(files, path); + expect(text.startsWith("---\n"), path).toBe(true); + const frontmatter = text.split("\n---\n")[0].slice("---\n".length); + expect(frontmatter.split("\n"), path).not.toContain(""); + } + } + }); +}); + +describe("yamlScalar", () => { + it("writes plain identifiers bare", () => { + for (const value of ["ColMemo", "PROJ-1", "my project", "v1.0", "A_B"]) { + expect(yamlScalar(value)).toBe(value); + } + }); + + it("quotes anything that could confuse a YAML parser", () => { + expect(yamlScalar("Ocean: Memory")).toBe('"Ocean: Memory"'); + expect(yamlScalar("[bracket]")).toBe('"[bracket]"'); + expect(yamlScalar("#hash")).toBe('"#hash"'); + expect(yamlScalar("集体记忆")).toBe('"集体记忆"'); + expect(yamlScalar('say "hi"')).toBe('"say \\"hi\\""'); + expect(yamlScalar("- leading dash")).toBe('"- leading dash"'); + }); + + it("quotes bare words YAML would read back as a non-string", () => { + // `project: 2024` would come back as a number and `project: no` as a boolean, + // so a sibling reading the key would not get the string that was written. + for (const value of ["2024", "1.5", "-3", "no", "yes", "true", "off", "null", "~"]) { + expect(yamlScalar(value), value).toBe(`"${value}"`); + } + // Case-insensitively, since YAML resolves NO/False/On too. + expect(yamlScalar("NO")).toBe('"NO"'); + // …but a word that merely starts with one is a perfectly good acronym. + expect(yamlScalar("Nova")).toBe("Nova"); + expect(yamlScalar("2024Project")).toBe("2024Project"); + }); +}); + describe("buildPaperbellScaffold — the Main Manuscript is mandatory", () => { it("refuses a selection without it", () => { // Not merely a disabled toggle: the project root is the lowest common diff --git a/test/paperbell/client.test.ts b/test/paperbell/client.test.ts index 23069fd..5362f51 100644 --- a/test/paperbell/client.test.ts +++ b/test/paperbell/client.test.ts @@ -4,8 +4,16 @@ 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 type { PPBCompletionResult } from "src/paperbell/shared-config"; -import { MockPlugin, MockPaperBellHost, makePublicConfig } from "./fixtures"; +import type { + PPBCompletionResult, + PPBProject, +} from "src/paperbell/shared-config"; +import { + MockPlugin, + MockPaperBellHost, + makePublicConfig, + type MockHostOptions, +} from "./fixtures"; function newClient(): { client: PaperBellClient; plugin: MockPlugin } { const plugin = new MockPlugin(); @@ -45,6 +53,87 @@ describe("PaperBellClient — standalone (no host)", () => { expect(await client.fetchSharedConfig()).toBeNull(); expect(await client.fetchAccountInfo()).toBeNull(); expect(await client.requestCompletion({ messages: [] })).toBeNull(); + expect(await client.fetchProjects()).toBeNull(); + }); +}); + +describe("PaperBellClient — project list (proposed `projects` scope)", () => { + const PROJECTS: PPBProject[] = [ + { id: "p1", name: "Collective Memory", acronym: "ColMemo" }, + ]; + + /** Connect to a host, opting it into the proposal via capability + options. */ + function connect(opts: MockHostOptions = {}): { + client: PaperBellClient; + host: MockPaperBellHost; + } { + const { client, plugin } = newClient(); + const host = new MockPaperBellHost({ + capabilities: ["plugin-info", "projects"], + ...opts, + }); + plugin.app.installHost(host); + client.init(); + return { client, host }; + } + + it("returns the host's projects and passes the query through", async () => { + const { client, host } = connect({ + projects: { ok: true, projects: PROJECTS }, + }); + + const query = { status: ["active" as const] }; + expect(await client.fetchProjects(query)).toEqual(PROJECTS); + expect(host.lastProjectsQuery).toEqual(query); + }); + + it("returns null when the host does not advertise the capability", async () => { + // Capability list is the pre-proposal one, but the handle can serve projects: + // we must still not call it, since consent is keyed on an unadvertised scope. + const { client } = connect({ + capabilities: ["plugin-info", "config"], + projects: { ok: true, projects: PROJECTS }, + }); + + expect(await client.fetchProjects()).toBeNull(); + }); + + it("returns null when the host advertises it but has no such method", async () => { + // The realistic mixed case: a host that lies (or whose capabilities are stale) + // while its client handle predates the proposal. Must not throw. + const { client } = connect(); // capability set, `projects` option omitted + + expect(await client.fetchProjects()).toBeNull(); + }); + + it("returns null when consent is denied", async () => { + const { client } = connect({ projects: null }); + + expect(await client.fetchProjects()).toBeNull(); + }); + + it("returns null and warns when the host reports failure", async () => { + const warnSpy = vi + .spyOn(console, "warn") + .mockImplementation((): void => undefined); + const { client } = connect({ + projects: { ok: false, projects: [], error: "no project index" }, + }); + + expect(await client.fetchProjects()).toBeNull(); + expect(String(warnSpy.mock.calls[0]?.[1])).toContain("no project index"); + warnSpy.mockRestore(); + }); + + it("returns null and warns when the host throws", async () => { + const warnSpy = vi + .spyOn(console, "warn") + .mockImplementation((): void => undefined); + const { client } = connect({ projectsThrow: true }); + + await expect(client.fetchProjects()).resolves.toBeNull(); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); }); }); diff --git a/test/paperbell/fixtures.ts b/test/paperbell/fixtures.ts index 38d15ba..d8de27e 100644 --- a/test/paperbell/fixtures.ts +++ b/test/paperbell/fixtures.ts @@ -22,11 +22,19 @@ import type { PPBGrant, PPBHostApi, PPBLLMCredentials, + PPBProjectsQuery, + PPBProjectsResult, PPBRequestSource, PPBScope, } from "src/paperbell/shared-config"; -/** The full set of scopes the real host advertises. */ +/** + * The full set of scopes the real host advertises today. + * + * `projects` is deliberately NOT here: it is a proposal no shipped host implements + * (see docs/PROPOSAL_PROJECTS_SCOPE.md), so the default mock reproduces the "host + * too old" case that our degradation path has to survive. + */ const ALL_SCOPES: PPBScope[] = [ "account", "config", @@ -106,6 +114,14 @@ export interface MockHostOptions { downloadTicket?: PPBDownloadTicket | null; /** If true, `registerPPBplugin` throws (host rejects the handshake). */ rejectRegistration?: boolean; + /** + * Value returned by the proposed `requestProjects()`. Providing it also makes + * the mock *implement* the method — omit it to mimic a host predating the + * proposal, whose client handle has no such method at all. + */ + projects?: PPBProjectsResult | null; + /** If true, `requestProjects()` rejects (host blew up mid-call). */ + projectsThrow?: boolean; } /** @@ -125,6 +141,9 @@ export class MockPaperBellHost implements PPBHostApi { activation: PPBActivationInfo | null; downloadTicket: PPBDownloadTicket | null; lastDownloadTicketParams: PPBDownloadTicketParams | undefined; + lastProjectsQuery: PPBProjectsQuery | undefined; + private projects: PPBProjectsResult | null | undefined; + private projectsThrow: boolean; private rejectRegistration: boolean; private configSubscribers: Array<(c: PaperBellSharedConfigPublic) => void> = []; @@ -144,6 +163,13 @@ export class MockPaperBellHost implements PPBHostApi { this.activation = opts.activation ?? null; this.downloadTicket = opts.downloadTicket ?? null; this.rejectRegistration = opts.rejectRegistration ?? false; + this.projects = opts.projects; + this.projectsThrow = opts.projectsThrow ?? false; + } + + /** Whether this mock pretends to be new enough to serve a project list. */ + private get implementsProjects(): boolean { + return this.projects !== undefined || this.projectsThrow; } registerPPBplugin(source: PPBRequestSource): PPBClient { @@ -151,7 +177,20 @@ export class MockPaperBellHost implements PPBHostApi { throw new Error("host rejected registration"); } this.registeredSources.push(source); + // Spread the optional method in only when this mock claims to support it, so + // an "old host" handle genuinely lacks the property rather than having an + // undefined one — that is exactly what the client's typeof check looks at. + const projectsApi = this.implementsProjects + ? { + requestProjects: async (params?: PPBProjectsQuery) => { + this.lastProjectsQuery = params; + if (this.projectsThrow) throw new Error("host exploded"); + return this.projects ?? null; + }, + } + : {}; return { + ...projectsApi, requestAccountInfo: async () => this.account, requestSharedConfig: async () => this.sharedConfig, requestPluginInfo: async () => this.pluginInfo, diff --git a/test/paperbell/host-conformance.test.ts b/test/paperbell/host-conformance.test.ts index 010a640..344edc1 100644 --- a/test/paperbell/host-conformance.test.ts +++ b/test/paperbell/host-conformance.test.ts @@ -6,6 +6,7 @@ import { PPB_READY_EVENT, PPB_CONFIG_CHANGED_EVENT, PPB_PLUGINS_CHANGED_EVENT, + PPB_PROJECTS_CHANGED_EVENT, } from "src/paperbell/shared-config"; /** @@ -70,3 +71,31 @@ describe.skipIf(!present)( }); } ); + +/** + * The proposed `projects` scope (docs/PROPOSAL_PROJECTS_SCOPE.md). + * + * No shipped host implements it yet, so this whole block stays skipped — asserting + * it unconditionally would fail against every host that exists today. It arms + * itself the moment a bundle carrying `requestProjects` is installed, which is + * exactly when we want to check the shape actually matches what we vendored. + */ +describe.skipIf(!present || !src.includes("requestProjects"))( + "PaperBell host bundle — projects scope (proposal)", + () => { + // The skip condition already proves `requestProjects` is there, so asserting it + // again would be vacuous. These check the parts a host could plausibly ship + // without — and that our client would then silently ignore. + it("advertises the `projects` scope alongside the method", () => { + // Our client refuses to call `requestProjects` unless the scope is in + // `capabilities`, so a host shipping one without the other is a no-op for us. + expect(src, "capabilities should include the projects scope").toMatch( + /["']projects["']/ + ); + }); + + it("fires the projects-changed event we vendored", () => { + expect(src).toContain(PPB_PROJECTS_CHANGED_EVENT); + }); + } +); diff --git a/test/view/project-options.test.ts b/test/view/project-options.test.ts new file mode 100644 index 0000000..9a5b7b0 --- /dev/null +++ b/test/view/project-options.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; + +import { projectOptions } from "src/view/project-lifecycle/new-paper-modal/project-options"; +import type { PPBProject } from "src/paperbell/shared-config"; + +const project = (p: Partial): PPBProject => ({ + id: "id", + name: "Name", + ...p, +}); + +describe("projectOptions", () => { + it("uses the acronym as the value and shows the name alongside it", () => { + // The value is what lands in `project:` frontmatter — the acronym is the + // interop key, not the display name. + expect( + projectOptions([ + project({ id: "p1", name: "Collective Memory", acronym: "ColMemo" }), + ]) + ).toEqual([{ value: "ColMemo", label: "Collective Memory (ColMemo)" }]); + }); + + it("falls back to the name when the host omits the acronym", () => { + // `acronym` is optional in the contract; dropping such a project would hide + // it from the user with no explanation. + expect(projectOptions([project({ name: "Sea Level" })])).toEqual([ + { value: "Sea Level", label: "Sea Level" }, + ]); + }); + + it("drops entries with nothing usable", () => { + // An option that writes an empty `project:` is worse than an absent one. + expect( + projectOptions([ + project({ name: "", acronym: "" }), + project({ name: " ", acronym: " " }), + project({ name: "Real", acronym: "R" }), + ]) + ).toEqual([{ value: "R", label: "Real (R)" }]); + }); + + it("trims whitespace around the value", () => { + expect(projectOptions([project({ name: "X", acronym: " ColMemo " })])).toEqual( + [{ value: "ColMemo", label: "X (ColMemo)" }] + ); + }); + + it("sorts by label so the dropdown order does not follow host internals", () => { + const labels = projectOptions([ + project({ id: "3", name: "Zebra", acronym: "Z" }), + project({ id: "1", name: "Alpha", acronym: "A" }), + project({ id: "2", name: "Mango", acronym: "M" }), + ]).map((o) => o.label); + + expect(labels).toEqual(["Alpha (A)", "Mango (M)", "Zebra (Z)"]); + }); + + it("returns an empty list for an empty input", () => { + expect(projectOptions([])).toEqual([]); + }); + + it("never produces a value that could collide with the manual-entry sentinel", () => { + // The modal's MANUAL_ENTRY sentinel is a space-prefixed string, and its safety + // rests on this guarantee holding here. Relaxing the trim without noticing + // would let a host-supplied project hijack the "type it myself" option. + const values = projectOptions([ + project({ name: " leading", acronym: " lead " }), + project({ name: " spaced name only" }), + project({ name: "Normal", acronym: "N" }), + ]).map((o) => o.value); + + expect(values.length).toBeGreaterThan(0); + for (const value of values) { + expect(value, JSON.stringify(value)).toBe(value.trim()); + expect(value.startsWith(" "), JSON.stringify(value)).toBe(false); + } + }); +});