From 30cb9d293c364e6e19d95f4b567d73d6497f03db Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sat, 27 Jun 2026 06:35:25 +0300 Subject: [PATCH 1/7] feat(footnotes): inline authoring + deterministic server-side canonicalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make footnotes author-inline: the agent/tool inserts a footnote at its point of use (anchor + text) and the numbering plus the bottom list are DERIVED deterministically server-side. The agent has no access to footnotesList and cannot desync — out-of-order lists, orphan definitions, and raw trailing [^id] blocks become structurally impossible. editor-ext: - canonicalizeFootnotes(docJSON) -> docJSON: a pure, EditorView-free port of footnoteSyncPlugin's end-state. Distinct reference ids in document order are the source of truth; exactly one trailing footnotesList holds one definition per referenced id in reference order (reusing the existing node or synthesizing an empty one); orphans dropped; duplicate definitions resolved deterministically (first wins, never lost); idempotent. - Unit tests + a golden parity suite: on every editor-reachable steady state the live footnoteSyncPlugin's JSON is a canonicalize no-op (byte-for-byte parity), and the canonicalizer additionally repairs the out-of-order list a non-editor write produces. mcp: - footnote-canonicalize.ts: behavioural mirror of the editor-ext canonicalizer (the MCP package is intentionally decoupled from the editor barrel, like footnote-lex/docmost-schema), plus footnoteContentKey for content dedup. - Auto-canonicalize on EVERY write path: markdownToProseMirror (fixes import ordering), update_page_json, and after every docmost_transform. Idempotent, so it is a no-op when footnotes are already canonical. - insert_footnote tool + insertInlineFootnote: anchor + markdown text -> a mark-safe footnoteReference and a content-dedup'd definition; the list and numbering are derived. Same-content footnotes reuse one number/definition. - canonicalizeFootnotes + insertInlineFootnote exposed as docmost_transform sandbox helpers. Tests: editor-ext 157 green; MCP 325 green; server + client tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../footnote/footnote-canonicalize.test.ts | 327 ++++++++++++++++++ .../src/lib/footnote/footnote-canonicalize.ts | 190 ++++++++++ packages/editor-ext/src/lib/footnote/index.ts | 1 + packages/mcp/build/client.js | 67 +++- packages/mcp/build/index.js | 35 +- packages/mcp/build/lib/collaboration.js | 8 +- .../mcp/build/lib/footnote-canonicalize.js | 226 ++++++++++++ packages/mcp/build/lib/transforms.js | 120 +++++++ packages/mcp/src/client.ts | 78 ++++- packages/mcp/src/index.ts | 43 ++- packages/mcp/src/lib/collaboration.ts | 8 +- packages/mcp/src/lib/footnote-canonicalize.ts | 243 +++++++++++++ packages/mcp/src/lib/transforms.ts | 156 +++++++++ .../test/unit/footnote-canonicalize.test.mjs | 200 +++++++++++ 14 files changed, 1685 insertions(+), 17 deletions(-) create mode 100644 packages/editor-ext/src/lib/footnote/footnote-canonicalize.test.ts create mode 100644 packages/editor-ext/src/lib/footnote/footnote-canonicalize.ts create mode 100644 packages/mcp/build/lib/footnote-canonicalize.js create mode 100644 packages/mcp/src/lib/footnote-canonicalize.ts create mode 100644 packages/mcp/test/unit/footnote-canonicalize.test.mjs diff --git a/packages/editor-ext/src/lib/footnote/footnote-canonicalize.test.ts b/packages/editor-ext/src/lib/footnote/footnote-canonicalize.test.ts new file mode 100644 index 00000000..543c2028 --- /dev/null +++ b/packages/editor-ext/src/lib/footnote/footnote-canonicalize.test.ts @@ -0,0 +1,327 @@ +import { describe, it, expect } from 'vitest'; +import { Editor, getSchema } from '@tiptap/core'; +import { Document } from '@tiptap/extension-document'; +import { Paragraph } from '@tiptap/extension-paragraph'; +import { Text } from '@tiptap/extension-text'; +import { FootnoteReference } from './footnote-reference'; +import { FootnotesList } from './footnotes-list'; +import { FootnoteDefinition } from './footnote-definition'; +import { canonicalizeFootnotes } from './footnote-canonicalize'; +import { + collectReferenceIds, + computeFootnoteNumbers, + FOOTNOTE_REFERENCE_NAME, + FOOTNOTES_LIST_NAME, + FOOTNOTE_DEFINITION_NAME, +} from './footnote-util'; +import { Node as PMNode } from '@tiptap/pm/model'; + +const extensions = [ + Document, + Paragraph, + Text, + FootnoteReference, + FootnotesList, + FootnoteDefinition, +]; + +const ref = (id: string) => ({ type: FOOTNOTE_REFERENCE_NAME, attrs: { id } }); +const def = (id: string, text?: string) => ({ + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id }, + content: [ + text + ? { type: 'paragraph', content: [{ type: 'text', text }] } + : { type: 'paragraph' }, + ], +}); +const list = (...defs: any[]) => ({ type: FOOTNOTES_LIST_NAME, content: defs }); +const para = (...inline: any[]) => ({ type: 'paragraph', content: inline }); + +/** Find every node of `type`, document order. */ +function findAll(node: any, type: string, acc: any[] = []): any[] { + if (!node || typeof node !== 'object') return acc; + if (node.type === type) acc.push(node); + if (Array.isArray(node.content)) { + for (const c of node.content) findAll(c, type, acc); + } + return acc; +} + +/** Physical id order of the definitions in the (single) footnotesList. */ +function defOrder(doc: any): string[] { + return findAll(doc, FOOTNOTE_DEFINITION_NAME).map((d) => d.attrs.id); +} + +const schema = getSchema(extensions); +/** Reference order (distinct, document order) computed via the shared util. */ +function refOrder(doc: any): string[] { + return collectReferenceIds(PMNode.fromJSON(schema, doc)); +} + +describe('canonicalizeFootnotes (pure JSON)', () => { + it('orders definitions by FIRST reference (out-of-order list -> 1..N)', () => { + // References appear b, a, d, c; the bottom list is in a different (import) + // order. The canonical list must follow reference order so reading it top to + // bottom yields numbers 1..N. + const doc = { + type: 'doc', + content: [ + para( + { type: 'text', text: 'x' }, + ref('b'), + ref('a'), + ref('d'), + ref('c'), + ), + list(def('a', 'A'), def('c', 'C'), def('b', 'B'), def('d', 'D')), + ], + }; + + const out = canonicalizeFootnotes(doc); + expect(defOrder(out)).toEqual(['b', 'a', 'd', 'c']); + // The physical definition order now matches reference order, so the derived + // numbers (1..N) run sequentially down the list. + expect(refOrder(out)).toEqual(['b', 'a', 'd', 'c']); + const numbers = computeFootnoteNumbers(PMNode.fromJSON(schema, out)); + expect(numbers.get('b')).toBe(1); + expect(numbers.get('a')).toBe(2); + expect(numbers.get('d')).toBe(3); + expect(numbers.get('c')).toBe(4); + }); + + it('numbers run 1..N down the canonical list', () => { + const doc = { + type: 'doc', + content: [ + para({ type: 'text', text: 'x' }, ref('b'), ref('a'), ref('c')), + list(def('a', 'A'), def('c', 'C'), def('b', 'B')), + ], + }; + const out = canonicalizeFootnotes(doc); + // Definition order == reference order == 1,2,3 reading down. + expect(defOrder(out)).toEqual(['b', 'a', 'c']); + }); + + it('drops an orphan definition (no matching reference)', () => { + const doc = { + type: 'doc', + content: [ + para({ type: 'text', text: 'x' }, ref('a')), + list(def('a', 'A'), def('orphan', 'O')), + ], + }; + const out = canonicalizeFootnotes(doc); + expect(defOrder(out)).toEqual(['a']); + expect(findAll(out, FOOTNOTE_DEFINITION_NAME)).toHaveLength(1); + }); + + it('with NO references, removes the footnotesList entirely', () => { + const doc = { + type: 'doc', + content: [ + para({ type: 'text', text: 'plain' }), + list(def('orphan', 'O')), + ], + }; + const out = canonicalizeFootnotes(doc); + expect(findAll(out, FOOTNOTES_LIST_NAME)).toHaveLength(0); + expect(findAll(out, FOOTNOTE_DEFINITION_NAME)).toHaveLength(0); + }); + + it('reuse: repeated references collapse to ONE definition/number', () => { + const doc = { + type: 'doc', + content: [ + para(ref('d'), { type: 'text', text: ' a ' }, ref('d'), ref('d')), + list(def('d', 'shared')), + ], + }; + const out = canonicalizeFootnotes(doc); + // One definition; the three references keep id "d". + expect(defOrder(out)).toEqual(['d']); + expect( + findAll(out, FOOTNOTE_REFERENCE_NAME).map((r) => r.attrs.id), + ).toEqual(['d', 'd', 'd']); + }); + + it('duplicate definitions: first wins, the rest are dropped (never resurface as orphans)', () => { + const doc = { + type: 'doc', + content: [ + para({ type: 'text', text: 'x' }, ref('d')), + list(def('d', 'first'), def('d', 'second'), def('d', 'third')), + ], + }; + const out = canonicalizeFootnotes(doc); + const defs = findAll(out, FOOTNOTE_DEFINITION_NAME); + expect(defs.map((d) => d.attrs.id)).toEqual(['d']); + expect(defs[0].content[0].content[0].text).toBe('first'); + }); + + it('synthesizes an empty definition for a reference that has none', () => { + const doc = { + type: 'doc', + content: [para({ type: 'text', text: 'x' }, ref('missing'))], + }; + const out = canonicalizeFootnotes(doc); + expect(defOrder(out)).toEqual(['missing']); + const list0 = findAll(out, FOOTNOTES_LIST_NAME); + expect(list0).toHaveLength(1); + }); + + it('merges multiple footnotesList nodes into one', () => { + const doc = { + type: 'doc', + content: [ + para({ type: 'text', text: 'a' }, ref('x'), ref('y')), + list(def('x', 'X')), + para({ type: 'text', text: 'tail' }), + list(def('y', 'Y')), + ], + }; + const out = canonicalizeFootnotes(doc); + expect(findAll(out, FOOTNOTES_LIST_NAME)).toHaveLength(1); + expect(defOrder(out)).toEqual(['x', 'y']); + }); + + it('places the single list before trailing empty paragraphs', () => { + const doc = { + type: 'doc', + content: [ + para({ type: 'text', text: 'x' }, ref('a')), + list(def('a', 'A')), + { type: 'paragraph' }, + ], + }; + const out = canonicalizeFootnotes(doc); + const last = out.content[out.content.length - 1]; + expect(last.type).toBe('paragraph'); + expect(out.content[out.content.length - 2].type).toBe(FOOTNOTES_LIST_NAME); + }); + + it('is idempotent: canonicalize(canonicalize(x)) === canonicalize(x)', () => { + const doc = { + type: 'doc', + content: [ + para({ type: 'text', text: 'x' }, ref('b'), ref('a')), + list(def('a', 'A'), def('b', 'B'), def('orphan', 'O')), + ], + }; + const once = canonicalizeFootnotes(doc); + const twice = canonicalizeFootnotes(once); + expect(twice).toEqual(once); + }); + + it('does not mutate its input', () => { + const doc = { + type: 'doc', + content: [ + para({ type: 'text', text: 'x' }, ref('a')), + list(def('orphan', 'O')), + ], + }; + const snapshot = JSON.parse(JSON.stringify(doc)); + canonicalizeFootnotes(doc); + expect(doc).toEqual(snapshot); + }); +}); + +/** + * GOLDEN PARITY against the live `footnoteSyncPlugin`. The server canonicalizer + * must produce EXACTLY what the editor keeps. For every editor-reachable steady + * state (the list is already reference-ordered there), driving a real editor to + * convergence and then running `canonicalizeFootnotes` on its JSON must be a + * byte-for-byte no-op — proving the server output is identical to the editor's. + */ +describe('canonicalizeFootnotes golden parity with footnoteSyncPlugin', () => { + function makeEditor(content: any) { + return new Editor({ extensions, content }); + } + + /** Load `content`, fire one local edit so the sync plugin converges, return JSON. */ + function pluginSteadyState(content: any): any { + const editor = makeEditor(content); + // A local doc change triggers footnoteSyncPlugin.appendTransaction. + editor.commands.insertContentAt(1, ' '); + const json = editor.state.doc.toJSON(); + editor.destroy(); + return json; + } + + const corpus: Array<{ name: string; content: any }> = [ + { + name: 'plain ref + def', + content: { + type: 'doc', + content: [para({ type: 'text', text: 'a' }, ref('x')), list(def('x', 'X'))], + }, + }, + { + name: 'two refs, two defs in reference order', + content: { + type: 'doc', + content: [ + para({ type: 'text', text: 'a' }, ref('x'), { type: 'text', text: 'b' }, ref('y')), + list(def('x', 'X'), def('y', 'Y')), + ], + }, + }, + { + name: 'orphan definition gets removed', + content: { + type: 'doc', + content: [para({ type: 'text', text: 'a' }, ref('x')), list(def('x', 'X'), def('orphan', 'O'))], + }, + }, + { + name: 'reference missing its definition (synth empty)', + content: { + type: 'doc', + content: [para({ type: 'text', text: 'a' }, ref('x'))], + }, + }, + { + name: 'reuse: repeated references, one definition', + content: { + type: 'doc', + content: [ + para(ref('d'), { type: 'text', text: ' a ' }, ref('d'), ref('d')), + list(def('d', 'shared')), + ], + }, + }, + { + name: 'no footnotes at all', + content: { + type: 'doc', + content: [para({ type: 'text', text: 'just text' })], + }, + }, + ]; + + for (const { name, content } of corpus) { + it(`steady state is a canonicalize no-op: ${name}`, () => { + const steady = pluginSteadyState(content); + expect(canonicalizeFootnotes(steady)).toEqual(steady); + }); + } + + it('the canonicalizer and the editor agree on reference order and definition set', () => { + const content = { + type: 'doc', + content: [ + para({ type: 'text', text: 'a' }, ref('x'), { type: 'text', text: 'b' }, ref('y')), + list(def('y', 'Y'), def('x', 'X')), // physically reversed + ], + }; + const steady = pluginSteadyState(content); + const canon = canonicalizeFootnotes(content); + // Same reference order and same DEFINITION SET (ids) in both, even though the + // physical list order may differ (the plugin preserves node identity, the + // canonicalizer reorders). Numbering — derived from reference order — matches. + expect(refOrder(steady)).toEqual(['x', 'y']); + expect(defOrder(canon)).toEqual(['x', 'y']); + expect(new Set(defOrder(steady))).toEqual(new Set(defOrder(canon))); + }); +}); diff --git a/packages/editor-ext/src/lib/footnote/footnote-canonicalize.ts b/packages/editor-ext/src/lib/footnote/footnote-canonicalize.ts new file mode 100644 index 00000000..5017dc05 --- /dev/null +++ b/packages/editor-ext/src/lib/footnote/footnote-canonicalize.ts @@ -0,0 +1,190 @@ +import { + FOOTNOTE_REFERENCE_NAME, + FOOTNOTES_LIST_NAME, + FOOTNOTE_DEFINITION_NAME, + deriveFootnoteId, +} from './footnote-util'; + +/** + * Server-side, EditorView-free port of the footnote integrity invariant that + * `footnoteSyncPlugin` maintains in the live editor. Where the plugin is an + * `appendTransaction` that only runs inside a ProseMirror `EditorView`, this is + * a PURE function over ProseMirror JSON: `canonicalizeFootnotes(doc) -> doc`. + * + * It exists because every NON-editor write path (the MCP `markdownToProseMirror` + * importer, `update_page_json`, `docmost_transform`, the future git-sync writer) + * builds ProseMirror JSON directly via `TiptapTransformer`/`updateYFragment`, + * which NEVER runs the editor's plugins — so the canonical footnote topology was + * never enforced on those writes. That is the root cause of the symptom in the + * issue: footnotes rendered out of order (`1, 4, 2, 3, …`), a raw trailing + * `[^id]: …` block, and orphan definitions, all of which are simply the result + * of content written PAST the canonicalizer. + * + * The desired end-state (identical to the plugin's) is: + * + * 1. Reference ids in DOCUMENT ORDER are the single source of truth for which + * definitions exist and in what order (numbering is derived from this, see + * `computeFootnoteNumbers`). Repeated references that share an id are REUSE + * (one footnote, one number, one definition) — never re-id'd. + * 2. Exactly ONE `footnotesList`, holding one definition per referenced id in + * REFERENCE order, reusing the existing definition node (content preserved) + * or synthesizing an empty one when missing. The list sits after the last + * meaningful block (only trailing empty paragraphs may follow it). + * 3. Orphan definitions (no matching reference) are dropped. + * 4. Duplicate DEFINITIONS (two nodes sharing an id) are resolved + * deterministically: the first keeps the id; each later duplicate is re-id'd + * via `deriveFootnoteId` (never random) so it is never silently lost — and, + * lacking a matching reference, it then falls under the orphan policy and is + * dropped. This matches the editor's never-lose-by-collision rule and the + * importer's first-wins rule (both converge to "one definition per id"). + * 5. Idempotent: a document that already satisfies the invariant is returned + * structurally unchanged (the existing definition/list nodes are reused + * verbatim), so re-running the canonicalizer — or running it on a write that + * the editor already canonicalized — is a no-op. This is what makes it safe + * to wire into EVERY write path without spurious mutations / git-sync churn. + * + * Divergence from the live plugin (intentional): the plugin preserves the + * PHYSICAL order of existing definition nodes to keep their Yjs/CRDT subtree + * identity stable across collaborators (numbering is decoration-derived, so the + * displayed numbers are correct regardless of physical order). This function has + * no live CRDT to protect, so it physically REORDERS the list into reference + * order — which is exactly the repair the out-of-order import needs. On every + * editor-reachable steady state (where the list is already reference-ordered) the + * two agree byte-for-byte; see the golden test. + * + * Pure: deep-clones its input, never mutates the caller's object, and is + * deterministic (no `Math.random`/`Date.now`). + */ +export function canonicalizeFootnotes(doc: T): T { + if ( + doc == null || + typeof doc !== 'object' || + !Array.isArray((doc as any).content) + ) { + return doc; + } + const out = cloneJson(doc) as any; + + // 1) Distinct reference ids in document order (deep — references can live in + // callouts, tables, list items, ...). This is the ordering/numbering truth. + const referenceIds: string[] = []; + const seenRefIds = new Set(); + collectReferenceIds(out, referenceIds, seenRefIds); + + // 2) Every definition node in document order (deep — defs normally live inside + // one or more `footnotesList` blocks, but we tolerate stray placements). + const defNodes: any[] = []; + collectDefinitions(out, defNodes); + + // 3) Resolve the id topology deterministically. The first definition for an id + // keeps it; a later duplicate is re-id'd to a fresh derived id (never lost), + // which — having no matching reference — is dropped as an orphan in step 4. + const taken = new Set(referenceIds); + for (const d of defNodes) { + const id = d?.attrs?.id; + if (id) taken.add(id); + } + const occurrenceOf = new Map(); + const seenDefIds = new Set(); + // finalId -> definition node (the node reference inside `out`). + const defByFinalId = new Map(); + for (const d of defNodes) { + const origId = d?.attrs?.id; + if (!origId) continue; + if (!seenDefIds.has(origId)) { + seenDefIds.add(origId); + defByFinalId.set(origId, d); + } else { + const next = (occurrenceOf.get(origId) ?? 1) + 1; + occurrenceOf.set(origId, next); + const newId = deriveFootnoteId(origId, next, taken); + taken.add(newId); + defByFinalId.set(newId, d); + } + } + + // 4) Build the ordered definition list: one per referenced id, in REFERENCE + // order, reusing the existing node (content preserved, id normalized) or + // synthesizing an empty definition. Definitions whose final id is NOT + // referenced are orphans and are simply never added. + const orderedDefs: any[] = []; + for (const id of referenceIds) { + const existing = defByFinalId.get(id); + if (existing) { + const node = cloneJson(existing); + node.attrs = { ...(node.attrs ?? {}), id }; + orderedDefs.push(node); + } else { + orderedDefs.push(emptyDefinition(id)); + } + } + + // 5) Strip every existing top-level footnotesList; we rebuild a single one. + const top: any[] = out.content.filter( + (n: any) => !(n && n.type === FOOTNOTES_LIST_NAME), + ); + + // 6) No references -> there must be NO list at all. + if (referenceIds.length === 0) { + out.content = top; + return out; + } + + // 7) Insert exactly one footnotesList after the last meaningful (non-empty + // paragraph) block, so it coexists with a trailing-node empty paragraph. + let insertAt = top.length; + while (insertAt > 0 && isEmptyParagraph(top[insertAt - 1])) insertAt--; + top.splice(insertAt, 0, { type: FOOTNOTES_LIST_NAME, content: orderedDefs }); + out.content = top; + return out; +} + +/** A fresh empty definition node for a referenced id with no definition. */ +function emptyDefinition(id: string): any { + return { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id }, + content: [{ type: 'paragraph' }], + }; +} + +function isEmptyParagraph(node: any): boolean { + return ( + !!node && + node.type === 'paragraph' && + (!Array.isArray(node.content) || node.content.length === 0) + ); +} + +/** Collect DISTINCT footnoteReference ids in document order (first appearance). */ +function collectReferenceIds( + node: any, + out: string[], + seen: Set, +): void { + if (!node || typeof node !== 'object') return; + if (node.type === FOOTNOTE_REFERENCE_NAME) { + const id = node?.attrs?.id; + if (id && !seen.has(id)) { + seen.add(id); + out.push(id); + } + } + if (Array.isArray(node.content)) { + for (const child of node.content) collectReferenceIds(child, out, seen); + } +} + +/** Collect every footnoteDefinition node in document order. */ +function collectDefinitions(node: any, out: any[]): void { + if (!node || typeof node !== 'object') return; + if (node.type === FOOTNOTE_DEFINITION_NAME) out.push(node); + if (Array.isArray(node.content)) { + for (const child of node.content) collectDefinitions(child, out); + } +} + +function cloneJson(v: T): T { + if (typeof structuredClone === 'function') return structuredClone(v); + return JSON.parse(JSON.stringify(v)) as T; +} diff --git a/packages/editor-ext/src/lib/footnote/index.ts b/packages/editor-ext/src/lib/footnote/index.ts index 02defff1..f3cafac3 100644 --- a/packages/editor-ext/src/lib/footnote/index.ts +++ b/packages/editor-ext/src/lib/footnote/index.ts @@ -4,3 +4,4 @@ export * from "./footnotes-list"; export * from "./footnote-definition"; export * from "./footnote-numbering"; export * from "./footnote-sync"; +export * from "./footnote-canonicalize"; diff --git a/packages/mcp/build/client.js b/packages/mcp/build/client.js index a5219c5c..f9cf5a75 100644 --- a/packages/mcp/build/client.js +++ b/packages/mcp/build/client.js @@ -17,7 +17,7 @@ import { applyTextEdits, } from "./lib/json-edit.js"; import { getCollabToken, performLogin } from "./lib/auth-utils.js"; import { diffDocs, summarizeChange } from "./lib/diff.js"; import { applyAnchorInDoc, canAnchorInDoc } from "./lib/comment-anchor.js"; -import { blockText, walk, getList, insertMarkerAfter, setCalloutRange, noteItem, mdToInlineNodes, commentsToFootnotes, } from "./lib/transforms.js"; +import { blockText, walk, getList, insertMarkerAfter, setCalloutRange, noteItem, mdToInlineNodes, commentsToFootnotes, canonicalizeFootnotes, insertInlineFootnote, } from "./lib/transforms.js"; import vm from "node:vm"; // Supported image types, kept as two lookup tables so both a local file // extension and a remote Content-Type can be mapped to the same canonical set. @@ -1063,6 +1063,11 @@ export class DocmostClient { // the markdown link path (which TipTap sanitizes), raw JSON could otherwise // inject javascript:/data: link hrefs or media srcs straight into the doc. this.validateDocUrls(doc); + // Canonicalize footnotes (idempotent): an agent-authored JSON doc cannot + // leave footnotes out of order, orphaned, or in multiple lists — the bottom + // list + numbering are always derived from reference order. No-op when the + // footnotes are already canonical. + doc = canonicalizeFootnotes(doc); // Write the BODY first, then the title (#159 split-brain): a failed body // write (e.g. persist timeout) must not leave a new title over the old body. const collabToken = await this.getCollabTokenWithReauth(); @@ -1079,6 +1084,49 @@ export class DocmostClient { verify: mutation.verify, }; } + /** + * AUTHOR-INLINE footnote insertion. The agent supplies only WHERE + * (`anchorText`, a snippet of body text to attach the marker after) and WHAT + * (`text`, the footnote content as markdown). Numbering and the bottom + * `footnotesList` are derived deterministically server-side + * (`insertInlineFootnote` -> `canonicalizeFootnotes`): the agent never sees, + * assigns, or edits a footnote number or the list, so it CANNOT desync. + * + * Content DEDUP: when an existing definition has the same content, its id is + * reused (one number, one definition, several references). The write is atomic + * via `mutatePageContent` (single-writer, page-locked); if the anchor text is + * not found the transform aborts with a clear error and no write happens. + */ + async insertFootnote(pageId, anchorText, text) { + await this.ensureAuthenticated(); + if (!anchorText || !anchorText.trim()) { + throw new Error("insert_footnote: anchorText is required"); + } + if (text == null || `${text}`.trim() === "") { + throw new Error("insert_footnote: text is required"); + } + const collabToken = await this.getCollabTokenWithReauth(); + let result = null; + const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => { + const r = insertInlineFootnote(liveDoc, { anchorText, text }); + if (!r.inserted) { + throw new Error(`insert_footnote: anchor text not found: ${JSON.stringify(anchorText.slice(0, 80))}`); + } + result = { footnoteId: r.footnoteId, reused: r.reused }; + return r.doc; + }); + return { + success: true, + modified: true, + pageId, + footnoteId: result ? result.footnoteId : undefined, + reused: result ? result.reused : undefined, + message: result && result.reused + ? "Footnote inserted (reused an existing same-content definition)." + : "Footnote inserted.", + verify: mutation.verify, + }; + } /** * Export a page to a single self-contained Docmost-flavoured markdown file: * meta block + body (with inline comment anchors + diagrams) + comment @@ -2422,6 +2470,8 @@ export class DocmostClient { noteItem, mdToInlineNodes, commentsToFootnotes, + canonicalizeFootnotes, + insertInlineFootnote, }, }; // Captured oldDoc / newDoc for the diff (set inside runTransform). @@ -2455,13 +2505,18 @@ export class DocmostClient { if (typeof fn !== "function") { throw new Error("transform must evaluate to a function (doc, ctx) => doc"); } - const result = vm.runInNewContext("f(d, c)", { f: fn, d: sandbox.doc, c: ctx }, { timeout: 5000 }); - if (!result || - typeof result !== "object" || - result.type !== "doc" || - !Array.isArray(result.content)) { + const raw = vm.runInNewContext("f(d, c)", { f: fn, d: sandbox.doc, c: ctx }, { timeout: 5000 }); + if (!raw || + typeof raw !== "object" || + raw.type !== "doc" || + !Array.isArray(raw.content)) { throw new Error('transform must return a ProseMirror doc node ({ type:"doc", content:[...] })'); } + // Auto-canonicalize footnotes after the transform (idempotent): no write + // path can leave footnotes out of order / orphaned / in a raw `[^id]` + // block. In a dryRun preview this may surface footnote edits the script + // author did not write (the canonicalizer tidied them) — that is expected. + const result = canonicalizeFootnotes(raw); // Validate the returned doc before it can be written. this.validateDocStructure(result); this.validateDocUrls(result); diff --git a/packages/mcp/build/index.js b/packages/mcp/build/index.js index 7f258a19..06bc19ea 100644 --- a/packages/mcp/build/index.js +++ b/packages/mcp/build/index.js @@ -637,8 +637,12 @@ export function createDocmostMcpServer(config) { "mark-safe), setCalloutRange(doc, n) (sync a [1]…[K] callout range to " + "[1]…[n]), noteItem(inlineNodes) (wrap inline nodes in a listItem with a " + "fresh id), mdToInlineNodes(markdown) (comment markdown -> inline nodes), " + - "and commentsToFootnotes(doc, comments, {notesHeading}) (turn inline " + - "comments into numbered footnotes). Footnote convention: markers are " + + "commentsToFootnotes(doc, comments, {notesHeading}) (turn inline " + + "comments into numbered footnotes), canonicalizeFootnotes(doc) (derive " + + "footnote numbering + the single bottom list from reference order, drop " + + "orphans/duplicates — runs automatically after every transform too), and " + + "insertInlineFootnote(doc, {anchorText, text}) (author-inline footnote: " + + "marker + dedup'd definition, list derived). Footnote convention: markers are " + "plain '[N]' text in the body; the notes are an orderedList under a " + "heading whose text is 'Примечания переводчика'. The transform runs " + "sandboxed (no require/process/fs/network, 5s timeout) and must return a " + @@ -672,6 +676,33 @@ export function createDocmostMcpServer(config) { }); return jsonContent(result); }); + // Tool: insert_footnote + server.registerTool("insert_footnote", { + description: "Insert an AUTHOR-INLINE footnote: you specify only WHERE (anchorText) " + + "and WHAT (text). The footnote marker is placed right after anchorText in " + + "the body, and the bottom footnotes list + the numbering are derived " + + "deterministically server-side. You do NOT assign a number, and you " + + "never see or edit the footnotes list — so footnotes cannot end up out " + + "of order, orphaned, or as a raw '[^id]' block. If a footnote with the " + + "SAME text already exists, its number is REUSED (one definition, several " + + "references). The write is atomic and won't clobber concurrent edits; if " + + "anchorText is not found, nothing is written and an error is returned.", + inputSchema: { + pageId: z.string().min(1), + anchorText: z + .string() + .min(1) + .describe("A snippet of existing body text; the footnote marker is inserted " + + "immediately after its first occurrence (mark-safe)."), + text: z + .string() + .min(1) + .describe("The footnote content as markdown (becomes the definition)."), + }, + }, async ({ pageId, anchorText, text }) => { + const result = await docmostClient.insertFootnote(pageId, anchorText, text); + return jsonContent(result); + }); // Tool: diff_page_versions registerShared(SHARED_TOOL_SPECS.diffPageVersions, async ({ pageId, from, to }) => { const result = await docmostClient.diffPageVersions(pageId, from, to); diff --git a/packages/mcp/build/lib/collaboration.js b/packages/mcp/build/lib/collaboration.js index 87f0ef8a..1b6b1a10 100644 --- a/packages/mcp/build/lib/collaboration.js +++ b/packages/mcp/build/lib/collaboration.js @@ -11,6 +11,7 @@ import { docmostExtensions, docmostSchema } from "./docmost-schema.js"; import { withPageLock } from "./page-lock.js"; import { sanitizeForYjs, findUnstorableAttr } from "./node-ops.js"; import { lexFootnoteLines } from "./footnote-lex.js"; +import { canonicalizeFootnotes } from "./footnote-canonicalize.js"; import { summarizeChange } from "./diff.js"; /** * Build the descriptive error for an opaque Yjs encode failure ("Unexpected @@ -349,7 +350,12 @@ export async function markdownToProseMirror(markdownContent) { const { body, section } = extractFootnotes(withCallouts); const html = (await marked.parse(body)) + section; const bridged = bridgeTaskLists(html); - return generateJSON(bridged, docmostExtensions); + const json = generateJSON(bridged, docmostExtensions); + // Canonicalize footnotes on EVERY import: the section above is built in + // definition order, but numbering is derived from REFERENCE order — so without + // this the bottom list renders out of order (`1, 4, 2, 3, …`). Idempotent, so + // it is a no-op when the footnotes are already canonical. + return canonicalizeFootnotes(json); } /** * Build the collaboration WebSocket URL from an API base URL: diff --git a/packages/mcp/build/lib/footnote-canonicalize.js b/packages/mcp/build/lib/footnote-canonicalize.js new file mode 100644 index 00000000..056a2d31 --- /dev/null +++ b/packages/mcp/build/lib/footnote-canonicalize.js @@ -0,0 +1,226 @@ +/** + * Server-side footnote canonicalizer + inline authoring helper (MCP mirror). + * + * `canonicalizeFootnotes(doc)` is a pure ProseMirror-JSON port of the editor's + * `footnoteSyncPlugin` end-state, identical in behaviour to + * `@docmost/editor-ext`'s `canonicalizeFootnotes`. It is mirrored here — rather + * than imported from editor-ext — for the SAME reason `footnote-lex.ts` and the + * `docmost-schema.ts` nodes are mirrored: the MCP package is deliberately + * decoupled from the browser/React-heavy editor barrel and operates on plain + * JSON. The editor-ext copy owns the golden test against the live plugin; this + * copy must stay behaviourally identical. + * + * Why it exists: every NON-editor write path (markdown import, update_page_json, + * docmost_transform, insert_footnote) builds ProseMirror JSON directly, so the + * editor's footnote plugins never run and the canonical topology (sequential + * numbering by first reference, one trailing list, no orphans, no raw `[^id]`) + * was never enforced. Running this at the end of every write path closes that + * gap; because it is idempotent, it is a no-op when the footnotes are already + * canonical (no spurious mutations / git-sync churn). + */ +const FOOTNOTE_REFERENCE_NAME = "footnoteReference"; +const FOOTNOTES_LIST_NAME = "footnotesList"; +const FOOTNOTE_DEFINITION_NAME = "footnoteDefinition"; +function cloneJson(v) { + if (typeof structuredClone === "function") + return structuredClone(v); + return JSON.parse(JSON.stringify(v)); +} +/** + * Deterministic unique id for the k-th (k >= 2) duplicate of an id during + * collision resolution. Pure function of (originalId, occurrence, taken) — no + * Math.random/Date.now — mirroring editor-ext's `deriveFootnoteId`. Kept local + * (the importer's first-wins de-dup means duplicates are rare here, but the + * canonicalizer must still resolve them deterministically). + */ +export function deriveFootnoteId(originalId, occurrence, taken) { + let candidate = `${originalId}__${occurrence}`; + let n = 0; + while (taken.has(candidate)) { + n += 1; + candidate = `${originalId}__${occurrence}${suffix(n)}`; + } + return candidate; +} +function suffix(n) { + let out = ""; + let x = n; + while (x > 0) { + const rem = (x - 1) % 25; + out = String.fromCharCode(98 + rem) + out; // 98 = 'b' + x = Math.floor((x - 1) / 25); + } + return out; +} +function isEmptyParagraph(node) { + return (!!node && + node.type === "paragraph" && + (!Array.isArray(node.content) || node.content.length === 0)); +} +function collectReferenceIds(node, out, seen) { + if (!node || typeof node !== "object") + return; + if (node.type === FOOTNOTE_REFERENCE_NAME) { + const id = node?.attrs?.id; + if (id && !seen.has(id)) { + seen.add(id); + out.push(id); + } + } + if (Array.isArray(node.content)) { + for (const child of node.content) + collectReferenceIds(child, out, seen); + } +} +function collectDefinitions(node, out) { + if (!node || typeof node !== "object") + return; + if (node.type === FOOTNOTE_DEFINITION_NAME) + out.push(node); + if (Array.isArray(node.content)) { + for (const child of node.content) + collectDefinitions(child, out); + } +} +function emptyDefinition(id) { + return { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id }, + content: [{ type: "paragraph" }], + }; +} +/** + * Canonicalize footnotes in a ProseMirror-JSON document. See the file header and + * the editor-ext twin for the full contract. Pure (deep-clones input, + * deterministic, idempotent). + */ +export function canonicalizeFootnotes(doc) { + if (doc == null || + typeof doc !== "object" || + !Array.isArray(doc.content)) { + return doc; + } + const out = cloneJson(doc); + const referenceIds = []; + collectReferenceIds(out, referenceIds, new Set()); + const defNodes = []; + collectDefinitions(out, defNodes); + const taken = new Set(referenceIds); + for (const d of defNodes) { + const id = d?.attrs?.id; + if (id) + taken.add(id); + } + const occurrenceOf = new Map(); + const seenDefIds = new Set(); + const defByFinalId = new Map(); + for (const d of defNodes) { + const origId = d?.attrs?.id; + if (!origId) + continue; + if (!seenDefIds.has(origId)) { + seenDefIds.add(origId); + defByFinalId.set(origId, d); + } + else { + const next = (occurrenceOf.get(origId) ?? 1) + 1; + occurrenceOf.set(origId, next); + const newId = deriveFootnoteId(origId, next, taken); + taken.add(newId); + defByFinalId.set(newId, d); + } + } + const orderedDefs = []; + for (const id of referenceIds) { + const existing = defByFinalId.get(id); + if (existing) { + const node = cloneJson(existing); + node.attrs = { ...(node.attrs ?? {}), id }; + orderedDefs.push(node); + } + else { + orderedDefs.push(emptyDefinition(id)); + } + } + const top = out.content.filter((n) => !(n && n.type === FOOTNOTES_LIST_NAME)); + if (referenceIds.length === 0) { + out.content = top; + return out; + } + let insertAt = top.length; + while (insertAt > 0 && isEmptyParagraph(top[insertAt - 1])) + insertAt--; + top.splice(insertAt, 0, { type: FOOTNOTES_LIST_NAME, content: orderedDefs }); + out.content = top; + return out; +} +/** + * Normalized content key for de-duplicating footnote DEFINITIONS by their text. + * + * Two definitions with the same key are the SAME footnote — so the inline + * authoring tool reuses one id (one number, one definition, several references) + * instead of minting a second definition. Key = plaintext (whitespace-collapsed, + * trimmed) PLUS a signature of the inline mark types in order, so two notes that + * read the same but differ in formatting (one bold, one plain) are NOT merged. + * Conservative: only an exact match merges. + */ +export function footnoteContentKey(defNode) { + const parts = []; + const visit = (n) => { + if (!n || typeof n !== "object") + return; + if (n.type === "text" && typeof n.text === "string") { + const marks = Array.isArray(n.marks) + ? n.marks.map((m) => m?.type).filter(Boolean).sort().join(",") + : ""; + parts.push(`${n.text}${marks}`); + } + if (Array.isArray(n.content)) + for (const c of n.content) + visit(c); + }; + visit(defNode); + // Collapse the assembled text's whitespace and trim, keeping the mark + // signature attached so formatting differences still distinguish notes. + return parts + .join("") + .replace(/[ \t\r\n]+/g, " ") + .trim(); +} +/** + * Build a footnoteDefinition node from inline ProseMirror nodes, keyed by id. + */ +export function makeFootnoteDefinition(id, inlineNodes) { + const content = Array.isArray(inlineNodes) ? cloneJson(inlineNodes) : []; + return { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id }, + content: [{ type: "paragraph", content }], + }; +} +/** + * Generate a uuidv7-style id (time-ordered), matching editor-ext's + * `generateFootnoteId`. Used for a genuinely-new inline footnote id. + */ +export function generateFootnoteId() { + const now = Date.now(); + const timeHex = now.toString(16).padStart(12, "0"); + const rand = (length) => { + let s = ""; + for (let i = 0; i < length; i++) + s += Math.floor(Math.random() * 16).toString(16); + return s; + }; + const versioned = "7" + rand(3); + const variantNibble = (8 + Math.floor(Math.random() * 4)).toString(16); + const variant = variantNibble + rand(3); + return (timeHex.slice(0, 8) + + "-" + + timeHex.slice(8, 12) + + "-" + + versioned + + "-" + + variant + + "-" + + rand(12)); +} diff --git a/packages/mcp/build/lib/transforms.js b/packages/mcp/build/lib/transforms.js index 2fc5d37b..76147f02 100644 --- a/packages/mcp/build/lib/transforms.js +++ b/packages/mcp/build/lib/transforms.js @@ -14,6 +14,8 @@ * - `marks` arrays are preserved verbatim when fragments are split/reordered. */ import { blockPlainText } from "./node-ops.js"; +import { canonicalizeFootnotes, footnoteContentKey, makeFootnoteDefinition, generateFootnoteId, } from "./footnote-canonicalize.js"; +export { canonicalizeFootnotes } from "./footnote-canonicalize.js"; /** Deep-clone a JSON-serializable value without mutating the original. */ function clone(value) { if (typeof structuredClone === "function") { @@ -471,3 +473,121 @@ export function commentsToFootnotes(doc, comments, opts = {}) { const synced = setCalloutRange(working, definitions.length); return { doc: synced.doc, consumed }; } +/** A NUL-delimited sentinel that cannot occur in real prose. */ +const INLINE_FOOTNOTE_SENTINEL = "\u0000IFN\u0000"; +/** + * AUTHOR-INLINE footnote insertion. The caller supplies WHERE (anchorText) and + * WHAT (markdown text); numbering and the bottom list are derived server-side by + * `canonicalizeFootnotes`. The caller never sees or edits `footnotesList`, never + * assigns a number, and cannot desync — orphans / out-of-order lists / raw + * `[^id]` markdown are structurally impossible. + * + * Content DEDUP (#3 in the issue): if an existing definition has the SAME + * normalized content key, its id is REUSED (the new reference points at it: one + * number, one definition, several references). Otherwise a fresh uuid id is + * minted and a new definition added. Conservative — only an exact content match + * merges. + * + * Mechanics: the marker is inserted with the same mark-safe `insertMarkerAfter` + * split used elsewhere, via a sentinel that is then replaced by a real + * `footnoteReference` node (dropping the inserted leading space so the marker + * attaches to the preceding word). The whole document is then canonicalized. + * + * Operates on a clone of `doc`. When the anchor is not found, returns the input + * unchanged with `inserted:false`. + */ +export function insertInlineFootnote(doc, opts) { + const inline = mdToInlineNodes(opts.text ?? ""); + const key = footnoteContentKey(makeFootnoteDefinition("", inline)); + // Content dedup: reuse an existing definition's id when its key matches. + let footnoteId = null; + let reused = false; + if (key !== "") { + walk(doc, (n) => { + if (footnoteId == null && + isObject(n) && + n.type === "footnoteDefinition" && + n.attrs && + typeof n.attrs.id === "string" && + n.attrs.id !== "" && + footnoteContentKey(n) === key) { + footnoteId = n.attrs.id; + reused = true; + } + }); + } + if (footnoteId == null) + footnoteId = generateFootnoteId(); + // Insert a sentinel marker after the anchor (mark-safe split). + const r = insertMarkerAfter(doc, (opts.anchorText ?? "").trimEnd(), INLINE_FOOTNOTE_SENTINEL); + if (!r.inserted) { + return { doc: clone(doc), inserted: false, footnoteId, reused }; + } + let working = r.doc; + // Replace the sentinel run with a real footnoteReference node. + replaceSentinelWithReference(working, footnoteId); + // Add a NEW definition (canonicalize will order/place it); a reused id needs + // no new definition (the existing one is shared). + if (!reused) { + appendDefinition(working, makeFootnoteDefinition(footnoteId, inline)); + } + // Derive numbering + the single bottom list deterministically. + working = canonicalizeFootnotes(working); + return { doc: working, inserted: true, footnoteId, reused }; +} +/** + * Replace the lone sentinel text run (created by insertMarkerAfter as + * `" " + sentinel`) with a footnoteReference node, dropping the leading space so + * the marker attaches to the preceding word. Mutates `doc` in place. + */ +function replaceSentinelWithReference(doc, footnoteId) { + let done = false; + const visit = (container) => { + if (done || !isObject(container) || !Array.isArray(container.content)) + return; + const arr = container.content; + for (let i = 0; i < arr.length; i++) { + const n = arr[i]; + if (isObject(n) && + n.type === "text" && + typeof n.text === "string" && + n.text.includes(INLINE_FOOTNOTE_SENTINEL)) { + const idx = n.text.indexOf(INLINE_FOOTNOTE_SENTINEL); + // Text before the sentinel, with a single trailing space (the one + // insertMarkerAfter prepended) stripped so the ref hugs the word. + const before = n.text.slice(0, idx).replace(/ $/, ""); + const after = n.text.slice(idx + INLINE_FOOTNOTE_SENTINEL.length); + const marks = Array.isArray(n.marks) ? n.marks : []; + const parts = []; + if (before.length > 0) + parts.push({ ...n, text: before, marks: [...marks] }); + parts.push({ type: "footnoteReference", attrs: { id: footnoteId } }); + if (after.length > 0) + parts.push({ ...n, text: after, marks: [...marks] }); + arr.splice(i, 1, ...parts); + done = true; + return; + } + } + for (const child of arr) { + visit(child); + if (done) + return; + } + }; + visit(doc); +} +/** + * Append a definition node so the canonicalizer can order/place it: into the + * first existing footnotesList, or a new trailing list when none exists. + */ +function appendDefinition(doc, defNode) { + const existingList = getList(doc, (n) => isObject(n) && n.type === "footnotesList"); + if (existingList && Array.isArray(existingList.content)) { + existingList.content.push(defNode); + return; + } + if (Array.isArray(doc.content)) { + doc.content.push({ type: "footnotesList", content: [defNode] }); + } +} diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index 39ff3146..9169237d 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -60,6 +60,8 @@ import { noteItem, mdToInlineNodes, commentsToFootnotes, + canonicalizeFootnotes, + insertInlineFootnote, } from "./lib/transforms.js"; import vm from "node:vm"; @@ -1344,6 +1346,12 @@ export class DocmostClient { // inject javascript:/data: link hrefs or media srcs straight into the doc. this.validateDocUrls(doc); + // Canonicalize footnotes (idempotent): an agent-authored JSON doc cannot + // leave footnotes out of order, orphaned, or in multiple lists — the bottom + // list + numbering are always derived from reference order. No-op when the + // footnotes are already canonical. + doc = canonicalizeFootnotes(doc); + // Write the BODY first, then the title (#159 split-brain): a failed body // write (e.g. persist timeout) must not leave a new title over the old body. const collabToken = await this.getCollabTokenWithReauth(); @@ -1368,6 +1376,59 @@ export class DocmostClient { }; } + /** + * AUTHOR-INLINE footnote insertion. The agent supplies only WHERE + * (`anchorText`, a snippet of body text to attach the marker after) and WHAT + * (`text`, the footnote content as markdown). Numbering and the bottom + * `footnotesList` are derived deterministically server-side + * (`insertInlineFootnote` -> `canonicalizeFootnotes`): the agent never sees, + * assigns, or edits a footnote number or the list, so it CANNOT desync. + * + * Content DEDUP: when an existing definition has the same content, its id is + * reused (one number, one definition, several references). The write is atomic + * via `mutatePageContent` (single-writer, page-locked); if the anchor text is + * not found the transform aborts with a clear error and no write happens. + */ + async insertFootnote(pageId: string, anchorText: string, text: string) { + await this.ensureAuthenticated(); + if (!anchorText || !anchorText.trim()) { + throw new Error("insert_footnote: anchorText is required"); + } + if (text == null || `${text}`.trim() === "") { + throw new Error("insert_footnote: text is required"); + } + const collabToken = await this.getCollabTokenWithReauth(); + let result: { footnoteId: string; reused: boolean } | null = null; + const mutation = await mutatePageContent( + pageId, + collabToken, + this.apiUrl, + (liveDoc: any) => { + const r = insertInlineFootnote(liveDoc, { anchorText, text }); + if (!r.inserted) { + throw new Error( + `insert_footnote: anchor text not found: ${JSON.stringify( + anchorText.slice(0, 80), + )}`, + ); + } + result = { footnoteId: r.footnoteId, reused: r.reused }; + return r.doc; + }, + ); + return { + success: true, + modified: true, + pageId, + footnoteId: result ? (result as any).footnoteId : undefined, + reused: result ? (result as any).reused : undefined, + message: result && (result as any).reused + ? "Footnote inserted (reused an existing same-content definition)." + : "Footnote inserted.", + verify: mutation.verify, + }; + } + /** * Export a page to a single self-contained Docmost-flavoured markdown file: * meta block + body (with inline comment anchors + diagrams) + comment @@ -2986,6 +3047,8 @@ export class DocmostClient { noteItem, mdToInlineNodes, commentsToFootnotes, + canonicalizeFootnotes, + insertInlineFootnote, }, }; @@ -3022,21 +3085,26 @@ export class DocmostClient { "transform must evaluate to a function (doc, ctx) => doc", ); } - const result = vm.runInNewContext( + const raw = vm.runInNewContext( "f(d, c)", { f: fn, d: sandbox.doc, c: ctx }, { timeout: 5000 }, ); if ( - !result || - typeof result !== "object" || - result.type !== "doc" || - !Array.isArray(result.content) + !raw || + typeof raw !== "object" || + raw.type !== "doc" || + !Array.isArray(raw.content) ) { throw new Error( 'transform must return a ProseMirror doc node ({ type:"doc", content:[...] })', ); } + // Auto-canonicalize footnotes after the transform (idempotent): no write + // path can leave footnotes out of order / orphaned / in a raw `[^id]` + // block. In a dryRun preview this may surface footnote edits the script + // author did not write (the canonicalizer tidied them) — that is expected. + const result = canonicalizeFootnotes(raw); // Validate the returned doc before it can be written. this.validateDocStructure(result); this.validateDocUrls(result); diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 51d1489b..b980c8cc 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -892,8 +892,12 @@ server.registerTool( "mark-safe), setCalloutRange(doc, n) (sync a [1]…[K] callout range to " + "[1]…[n]), noteItem(inlineNodes) (wrap inline nodes in a listItem with a " + "fresh id), mdToInlineNodes(markdown) (comment markdown -> inline nodes), " + - "and commentsToFootnotes(doc, comments, {notesHeading}) (turn inline " + - "comments into numbered footnotes). Footnote convention: markers are " + + "commentsToFootnotes(doc, comments, {notesHeading}) (turn inline " + + "comments into numbered footnotes), canonicalizeFootnotes(doc) (derive " + + "footnote numbering + the single bottom list from reference order, drop " + + "orphans/duplicates — runs automatically after every transform too), and " + + "insertInlineFootnote(doc, {anchorText, text}) (author-inline footnote: " + + "marker + dedup'd definition, list derived). Footnote convention: markers are " + "plain '[N]' text in the body; the notes are an orderedList under a " + "heading whose text is 'Примечания переводчика'. The transform runs " + "sandboxed (no require/process/fs/network, 5s timeout) and must return a " + @@ -934,6 +938,41 @@ server.registerTool( }, ); +// Tool: insert_footnote +server.registerTool( + "insert_footnote", + { + description: + "Insert an AUTHOR-INLINE footnote: you specify only WHERE (anchorText) " + + "and WHAT (text). The footnote marker is placed right after anchorText in " + + "the body, and the bottom footnotes list + the numbering are derived " + + "deterministically server-side. You do NOT assign a number, and you " + + "never see or edit the footnotes list — so footnotes cannot end up out " + + "of order, orphaned, or as a raw '[^id]' block. If a footnote with the " + + "SAME text already exists, its number is REUSED (one definition, several " + + "references). The write is atomic and won't clobber concurrent edits; if " + + "anchorText is not found, nothing is written and an error is returned.", + inputSchema: { + pageId: z.string().min(1), + anchorText: z + .string() + .min(1) + .describe( + "A snippet of existing body text; the footnote marker is inserted " + + "immediately after its first occurrence (mark-safe).", + ), + text: z + .string() + .min(1) + .describe("The footnote content as markdown (becomes the definition)."), + }, + }, + async ({ pageId, anchorText, text }) => { + const result = await docmostClient.insertFootnote(pageId, anchorText, text); + return jsonContent(result); + }, +); + // Tool: diff_page_versions registerShared( SHARED_TOOL_SPECS.diffPageVersions, diff --git a/packages/mcp/src/lib/collaboration.ts b/packages/mcp/src/lib/collaboration.ts index aec82aa1..55159ef9 100644 --- a/packages/mcp/src/lib/collaboration.ts +++ b/packages/mcp/src/lib/collaboration.ts @@ -11,6 +11,7 @@ import { docmostExtensions, docmostSchema } from "./docmost-schema.js"; import { withPageLock } from "./page-lock.js"; import { sanitizeForYjs, findUnstorableAttr } from "./node-ops.js"; import { lexFootnoteLines } from "./footnote-lex.js"; +import { canonicalizeFootnotes } from "./footnote-canonicalize.js"; import { summarizeChange, VerifyReport } from "./diff.js"; /** @@ -400,7 +401,12 @@ export async function markdownToProseMirror( const { body, section } = extractFootnotes(withCallouts); const html = (await marked.parse(body)) + section; const bridged = bridgeTaskLists(html); - return generateJSON(bridged, docmostExtensions); + const json = generateJSON(bridged, docmostExtensions); + // Canonicalize footnotes on EVERY import: the section above is built in + // definition order, but numbering is derived from REFERENCE order — so without + // this the bottom list renders out of order (`1, 4, 2, 3, …`). Idempotent, so + // it is a no-op when the footnotes are already canonical. + return canonicalizeFootnotes(json); } /** diff --git a/packages/mcp/src/lib/footnote-canonicalize.ts b/packages/mcp/src/lib/footnote-canonicalize.ts new file mode 100644 index 00000000..c05af3da --- /dev/null +++ b/packages/mcp/src/lib/footnote-canonicalize.ts @@ -0,0 +1,243 @@ +/** + * Server-side footnote canonicalizer + inline authoring helper (MCP mirror). + * + * `canonicalizeFootnotes(doc)` is a pure ProseMirror-JSON port of the editor's + * `footnoteSyncPlugin` end-state, identical in behaviour to + * `@docmost/editor-ext`'s `canonicalizeFootnotes`. It is mirrored here — rather + * than imported from editor-ext — for the SAME reason `footnote-lex.ts` and the + * `docmost-schema.ts` nodes are mirrored: the MCP package is deliberately + * decoupled from the browser/React-heavy editor barrel and operates on plain + * JSON. The editor-ext copy owns the golden test against the live plugin; this + * copy must stay behaviourally identical. + * + * Why it exists: every NON-editor write path (markdown import, update_page_json, + * docmost_transform, insert_footnote) builds ProseMirror JSON directly, so the + * editor's footnote plugins never run and the canonical topology (sequential + * numbering by first reference, one trailing list, no orphans, no raw `[^id]`) + * was never enforced. Running this at the end of every write path closes that + * gap; because it is idempotent, it is a no-op when the footnotes are already + * canonical (no spurious mutations / git-sync churn). + */ + +const FOOTNOTE_REFERENCE_NAME = "footnoteReference"; +const FOOTNOTES_LIST_NAME = "footnotesList"; +const FOOTNOTE_DEFINITION_NAME = "footnoteDefinition"; + +function cloneJson(v: T): T { + if (typeof structuredClone === "function") return structuredClone(v); + return JSON.parse(JSON.stringify(v)) as T; +} + +/** + * Deterministic unique id for the k-th (k >= 2) duplicate of an id during + * collision resolution. Pure function of (originalId, occurrence, taken) — no + * Math.random/Date.now — mirroring editor-ext's `deriveFootnoteId`. Kept local + * (the importer's first-wins de-dup means duplicates are rare here, but the + * canonicalizer must still resolve them deterministically). + */ +export function deriveFootnoteId( + originalId: string, + occurrence: number, + taken: Set | ReadonlySet, +): string { + let candidate = `${originalId}__${occurrence}`; + let n = 0; + while (taken.has(candidate)) { + n += 1; + candidate = `${originalId}__${occurrence}${suffix(n)}`; + } + return candidate; +} + +function suffix(n: number): string { + let out = ""; + let x = n; + while (x > 0) { + const rem = (x - 1) % 25; + out = String.fromCharCode(98 + rem) + out; // 98 = 'b' + x = Math.floor((x - 1) / 25); + } + return out; +} + +function isEmptyParagraph(node: any): boolean { + return ( + !!node && + node.type === "paragraph" && + (!Array.isArray(node.content) || node.content.length === 0) + ); +} + +function collectReferenceIds(node: any, out: string[], seen: Set): void { + if (!node || typeof node !== "object") return; + if (node.type === FOOTNOTE_REFERENCE_NAME) { + const id = node?.attrs?.id; + if (id && !seen.has(id)) { + seen.add(id); + out.push(id); + } + } + if (Array.isArray(node.content)) { + for (const child of node.content) collectReferenceIds(child, out, seen); + } +} + +function collectDefinitions(node: any, out: any[]): void { + if (!node || typeof node !== "object") return; + if (node.type === FOOTNOTE_DEFINITION_NAME) out.push(node); + if (Array.isArray(node.content)) { + for (const child of node.content) collectDefinitions(child, out); + } +} + +function emptyDefinition(id: string): any { + return { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id }, + content: [{ type: "paragraph" }], + }; +} + +/** + * Canonicalize footnotes in a ProseMirror-JSON document. See the file header and + * the editor-ext twin for the full contract. Pure (deep-clones input, + * deterministic, idempotent). + */ +export function canonicalizeFootnotes(doc: T): T { + if ( + doc == null || + typeof doc !== "object" || + !Array.isArray((doc as any).content) + ) { + return doc; + } + const out = cloneJson(doc) as any; + + const referenceIds: string[] = []; + collectReferenceIds(out, referenceIds, new Set()); + + const defNodes: any[] = []; + collectDefinitions(out, defNodes); + + const taken = new Set(referenceIds); + for (const d of defNodes) { + const id = d?.attrs?.id; + if (id) taken.add(id); + } + const occurrenceOf = new Map(); + const seenDefIds = new Set(); + const defByFinalId = new Map(); + for (const d of defNodes) { + const origId = d?.attrs?.id; + if (!origId) continue; + if (!seenDefIds.has(origId)) { + seenDefIds.add(origId); + defByFinalId.set(origId, d); + } else { + const next = (occurrenceOf.get(origId) ?? 1) + 1; + occurrenceOf.set(origId, next); + const newId = deriveFootnoteId(origId, next, taken); + taken.add(newId); + defByFinalId.set(newId, d); + } + } + + const orderedDefs: any[] = []; + for (const id of referenceIds) { + const existing = defByFinalId.get(id); + if (existing) { + const node = cloneJson(existing); + node.attrs = { ...(node.attrs ?? {}), id }; + orderedDefs.push(node); + } else { + orderedDefs.push(emptyDefinition(id)); + } + } + + const top: any[] = out.content.filter( + (n: any) => !(n && n.type === FOOTNOTES_LIST_NAME), + ); + + if (referenceIds.length === 0) { + out.content = top; + return out; + } + + let insertAt = top.length; + while (insertAt > 0 && isEmptyParagraph(top[insertAt - 1])) insertAt--; + top.splice(insertAt, 0, { type: FOOTNOTES_LIST_NAME, content: orderedDefs }); + out.content = top; + return out; +} + +/** + * Normalized content key for de-duplicating footnote DEFINITIONS by their text. + * + * Two definitions with the same key are the SAME footnote — so the inline + * authoring tool reuses one id (one number, one definition, several references) + * instead of minting a second definition. Key = plaintext (whitespace-collapsed, + * trimmed) PLUS a signature of the inline mark types in order, so two notes that + * read the same but differ in formatting (one bold, one plain) are NOT merged. + * Conservative: only an exact match merges. + */ +export function footnoteContentKey(defNode: any): string { + const parts: string[] = []; + const visit = (n: any): void => { + if (!n || typeof n !== "object") return; + if (n.type === "text" && typeof n.text === "string") { + const marks = Array.isArray(n.marks) + ? n.marks.map((m: any) => m?.type).filter(Boolean).sort().join(",") + : ""; + parts.push(`${n.text}${marks}`); + } + if (Array.isArray(n.content)) for (const c of n.content) visit(c); + }; + visit(defNode); + // Collapse the assembled text's whitespace and trim, keeping the mark + // signature attached so formatting differences still distinguish notes. + return parts + .join("") + .replace(/[ \t\r\n]+/g, " ") + .trim(); +} + +/** + * Build a footnoteDefinition node from inline ProseMirror nodes, keyed by id. + */ +export function makeFootnoteDefinition(id: string, inlineNodes: any[]): any { + const content = Array.isArray(inlineNodes) ? cloneJson(inlineNodes) : []; + return { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id }, + content: [{ type: "paragraph", content }], + }; +} + +/** + * Generate a uuidv7-style id (time-ordered), matching editor-ext's + * `generateFootnoteId`. Used for a genuinely-new inline footnote id. + */ +export function generateFootnoteId(): string { + const now = Date.now(); + const timeHex = now.toString(16).padStart(12, "0"); + const rand = (length: number) => { + let s = ""; + for (let i = 0; i < length; i++) + s += Math.floor(Math.random() * 16).toString(16); + return s; + }; + const versioned = "7" + rand(3); + const variantNibble = (8 + Math.floor(Math.random() * 4)).toString(16); + const variant = variantNibble + rand(3); + return ( + timeHex.slice(0, 8) + + "-" + + timeHex.slice(8, 12) + + "-" + + versioned + + "-" + + variant + + "-" + + rand(12) + ); +} diff --git a/packages/mcp/src/lib/transforms.ts b/packages/mcp/src/lib/transforms.ts index 98269aff..5c595f86 100644 --- a/packages/mcp/src/lib/transforms.ts +++ b/packages/mcp/src/lib/transforms.ts @@ -15,6 +15,14 @@ */ import { blockPlainText } from "./node-ops.js"; +import { + canonicalizeFootnotes, + footnoteContentKey, + makeFootnoteDefinition, + generateFootnoteId, +} from "./footnote-canonicalize.js"; + +export { canonicalizeFootnotes } from "./footnote-canonicalize.js"; /** Deep-clone a JSON-serializable value without mutating the original. */ function clone(value: T): T { @@ -559,3 +567,151 @@ export function commentsToFootnotes( return { doc: synced.doc, consumed }; } + +/** Options for insertInlineFootnote. */ +export interface InsertInlineFootnoteOptions { + /** Body text after which the footnote marker is placed (mark-safe). */ + anchorText: string; + /** Footnote content as markdown (converted to inline nodes). */ + text: string; +} + +/** Result of insertInlineFootnote. */ +export interface InsertInlineFootnoteResult { + doc: any; + /** False when the anchor text was not found (no write). */ + inserted: boolean; + /** The footnote id used (new or reused). */ + footnoteId: string; + /** True when an existing same-content definition was reused (content dedup). */ + reused: boolean; +} + +/** A NUL-delimited sentinel that cannot occur in real prose. */ +const INLINE_FOOTNOTE_SENTINEL = "\u0000IFN\u0000"; + +/** + * AUTHOR-INLINE footnote insertion. The caller supplies WHERE (anchorText) and + * WHAT (markdown text); numbering and the bottom list are derived server-side by + * `canonicalizeFootnotes`. The caller never sees or edits `footnotesList`, never + * assigns a number, and cannot desync — orphans / out-of-order lists / raw + * `[^id]` markdown are structurally impossible. + * + * Content DEDUP (#3 in the issue): if an existing definition has the SAME + * normalized content key, its id is REUSED (the new reference points at it: one + * number, one definition, several references). Otherwise a fresh uuid id is + * minted and a new definition added. Conservative — only an exact content match + * merges. + * + * Mechanics: the marker is inserted with the same mark-safe `insertMarkerAfter` + * split used elsewhere, via a sentinel that is then replaced by a real + * `footnoteReference` node (dropping the inserted leading space so the marker + * attaches to the preceding word). The whole document is then canonicalized. + * + * Operates on a clone of `doc`. When the anchor is not found, returns the input + * unchanged with `inserted:false`. + */ +export function insertInlineFootnote( + doc: any, + opts: InsertInlineFootnoteOptions, +): InsertInlineFootnoteResult { + const inline = mdToInlineNodes(opts.text ?? ""); + const key = footnoteContentKey(makeFootnoteDefinition("", inline)); + + // Content dedup: reuse an existing definition's id when its key matches. + let footnoteId: string | null = null; + let reused = false; + if (key !== "") { + walk(doc, (n) => { + if ( + footnoteId == null && + isObject(n) && + n.type === "footnoteDefinition" && + n.attrs && + typeof n.attrs.id === "string" && + n.attrs.id !== "" && + footnoteContentKey(n) === key + ) { + footnoteId = n.attrs.id; + reused = true; + } + }); + } + if (footnoteId == null) footnoteId = generateFootnoteId(); + + // Insert a sentinel marker after the anchor (mark-safe split). + const r = insertMarkerAfter(doc, (opts.anchorText ?? "").trimEnd(), INLINE_FOOTNOTE_SENTINEL); + if (!r.inserted) { + return { doc: clone(doc), inserted: false, footnoteId, reused }; + } + let working = r.doc; + + // Replace the sentinel run with a real footnoteReference node. + replaceSentinelWithReference(working, footnoteId); + + // Add a NEW definition (canonicalize will order/place it); a reused id needs + // no new definition (the existing one is shared). + if (!reused) { + appendDefinition(working, makeFootnoteDefinition(footnoteId, inline)); + } + + // Derive numbering + the single bottom list deterministically. + working = canonicalizeFootnotes(working); + return { doc: working, inserted: true, footnoteId, reused }; +} + +/** + * Replace the lone sentinel text run (created by insertMarkerAfter as + * `" " + sentinel`) with a footnoteReference node, dropping the leading space so + * the marker attaches to the preceding word. Mutates `doc` in place. + */ +function replaceSentinelWithReference(doc: any, footnoteId: string): void { + let done = false; + const visit = (container: any): void => { + if (done || !isObject(container) || !Array.isArray(container.content)) return; + const arr = container.content; + for (let i = 0; i < arr.length; i++) { + const n = arr[i]; + if ( + isObject(n) && + n.type === "text" && + typeof n.text === "string" && + n.text.includes(INLINE_FOOTNOTE_SENTINEL) + ) { + const idx = n.text.indexOf(INLINE_FOOTNOTE_SENTINEL); + // Text before the sentinel, with a single trailing space (the one + // insertMarkerAfter prepended) stripped so the ref hugs the word. + const before = n.text.slice(0, idx).replace(/ $/, ""); + const after = n.text.slice(idx + INLINE_FOOTNOTE_SENTINEL.length); + const marks = Array.isArray(n.marks) ? n.marks : []; + const parts: any[] = []; + if (before.length > 0) parts.push({ ...n, text: before, marks: [...marks] }); + parts.push({ type: "footnoteReference", attrs: { id: footnoteId } }); + if (after.length > 0) parts.push({ ...n, text: after, marks: [...marks] }); + arr.splice(i, 1, ...parts); + done = true; + return; + } + } + for (const child of arr) { + visit(child); + if (done) return; + } + }; + visit(doc); +} + +/** + * Append a definition node so the canonicalizer can order/place it: into the + * first existing footnotesList, or a new trailing list when none exists. + */ +function appendDefinition(doc: any, defNode: any): void { + const existingList = getList(doc, (n) => isObject(n) && n.type === "footnotesList"); + if (existingList && Array.isArray(existingList.content)) { + existingList.content.push(defNode); + return; + } + if (Array.isArray(doc.content)) { + doc.content.push({ type: "footnotesList", content: [defNode] }); + } +} diff --git a/packages/mcp/test/unit/footnote-canonicalize.test.mjs b/packages/mcp/test/unit/footnote-canonicalize.test.mjs new file mode 100644 index 00000000..c2dd3005 --- /dev/null +++ b/packages/mcp/test/unit/footnote-canonicalize.test.mjs @@ -0,0 +1,200 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + canonicalizeFootnotes, + footnoteContentKey, +} from "../../build/lib/footnote-canonicalize.js"; +import { insertInlineFootnote } from "../../build/lib/transforms.js"; +import { markdownToProseMirror } from "../../build/lib/collaboration.js"; + +function findAll(node, type, acc = []) { + if (!node || typeof node !== "object") return acc; + if (node.type === type) acc.push(node); + if (Array.isArray(node.content)) { + for (const c of node.content) findAll(c, type, acc); + } + return acc; +} +const defIds = (doc) => + findAll(doc, "footnoteDefinition").map((d) => d.attrs.id); +const refIds = (doc) => + findAll(doc, "footnoteReference").map((r) => r.attrs.id); + +const ref = (id) => ({ type: "footnoteReference", attrs: { id } }); +const def = (id, text) => ({ + type: "footnoteDefinition", + attrs: { id }, + content: [{ type: "paragraph", content: [{ type: "text", text }] }], +}); +const para = (...inline) => ({ type: "paragraph", content: inline }); +const list = (...defs) => ({ type: "footnotesList", content: defs }); + +test("canonicalize orders definitions by first reference (out-of-order -> 1..N)", () => { + const doc = { + type: "doc", + content: [ + para({ type: "text", text: "x" }, ref("b"), ref("a"), ref("d"), ref("c")), + list(def("a", "A"), def("c", "C"), def("b", "B"), def("d", "D")), + ], + }; + const out = canonicalizeFootnotes(doc); + assert.deepEqual(defIds(out), ["b", "a", "d", "c"]); + assert.equal(findAll(out, "footnotesList").length, 1); +}); + +test("canonicalize drops orphan definitions", () => { + const doc = { + type: "doc", + content: [ + para({ type: "text", text: "x" }, ref("a")), + list(def("a", "A"), def("orphan", "O")), + ], + }; + assert.deepEqual(defIds(canonicalizeFootnotes(doc)), ["a"]); +}); + +test("canonicalize: no references -> no list", () => { + const doc = { + type: "doc", + content: [para({ type: "text", text: "x" }), list(def("o", "O"))], + }; + const out = canonicalizeFootnotes(doc); + assert.equal(findAll(out, "footnotesList").length, 0); +}); + +test("canonicalize: duplicate definitions -> first wins, rest dropped", () => { + const doc = { + type: "doc", + content: [ + para({ type: "text", text: "x" }, ref("d")), + list(def("d", "first"), def("d", "second")), + ], + }; + const out = canonicalizeFootnotes(doc); + assert.deepEqual(defIds(out), ["d"]); + assert.match(JSON.stringify(out), /"first"/); + assert.doesNotMatch(JSON.stringify(out), /"second"/); +}); + +test("canonicalize is idempotent", () => { + const doc = { + type: "doc", + content: [ + para({ type: "text", text: "x" }, ref("b"), ref("a")), + list(def("a", "A"), def("b", "B"), def("orphan", "O")), + ], + }; + const once = canonicalizeFootnotes(doc); + const twice = canonicalizeFootnotes(once); + assert.deepEqual(twice, once); +}); + +test("canonicalize does not mutate its input", () => { + const doc = { + type: "doc", + content: [para({ type: "text", text: "x" }, ref("a")), list(def("o", "O"))], + }; + const snap = JSON.parse(JSON.stringify(doc)); + canonicalizeFootnotes(doc); + assert.deepEqual(doc, snap); +}); + +test("footnoteContentKey: same text -> same key; formatting differs -> different key", () => { + const plain = def("x", "hello world"); + const sameText = def("y", "hello world"); // whitespace-collapsed match + const bold = { + type: "footnoteDefinition", + attrs: { id: "z" }, + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "hello world", marks: [{ type: "bold" }] }, + ], + }, + ], + }; + assert.equal(footnoteContentKey(plain), footnoteContentKey(sameText)); + assert.notEqual(footnoteContentKey(plain), footnoteContentKey(bold)); +}); + +test("insertInlineFootnote: places a reference at the anchor and derives the list", () => { + const doc = { + type: "doc", + content: [para({ type: "text", text: "The sky is blue today." })], + }; + const r = insertInlineFootnote(doc, { + anchorText: "blue", + text: "Rayleigh scattering.", + }); + assert.equal(r.inserted, true); + assert.equal(r.reused, false); + assert.equal(refIds(r.doc).length, 1); + assert.deepEqual(defIds(r.doc), [r.footnoteId]); + // The marker hugs the anchor word (no leading space text run before the ref). + assert.equal(findAll(r.doc, "footnotesList").length, 1); +}); + +test("insertInlineFootnote: content dedup -> same text reuses one definition, two refs", () => { + let doc = { + type: "doc", + content: [para({ type: "text", text: "Alpha and beta and gamma." })], + }; + const r1 = insertInlineFootnote(doc, { + anchorText: "Alpha", + text: "shared note", + }); + const r2 = insertInlineFootnote(r1.doc, { + anchorText: "beta", + text: "shared note", + }); + assert.equal(r2.reused, true); + assert.equal(r2.footnoteId, r1.footnoteId); + // One definition, two references both pointing at it. + assert.deepEqual(defIds(r2.doc), [r1.footnoteId]); + assert.deepEqual(refIds(r2.doc), [r1.footnoteId, r1.footnoteId]); +}); + +test("insertInlineFootnote: distinct text -> two definitions numbered by reference order", () => { + let doc = { + type: "doc", + content: [para({ type: "text", text: "First point, second point." })], + }; + const r1 = insertInlineFootnote(doc, { anchorText: "First", text: "note one" }); + const r2 = insertInlineFootnote(r1.doc, { + anchorText: "second", + text: "note two", + }); + assert.equal(r2.reused, false); + // Reference order in the body is [First-ref, second-ref]; the derived list + // matches that order. + assert.deepEqual(defIds(r2.doc), refIds(r2.doc)); + assert.equal(defIds(r2.doc).length, 2); +}); + +test("insertInlineFootnote: anchor not found -> inserted:false, no write", () => { + const doc = { + type: "doc", + content: [para({ type: "text", text: "nothing to anchor on" })], + }; + const r = insertInlineFootnote(doc, { anchorText: "ZZZ", text: "x" }); + assert.equal(r.inserted, false); + assert.equal(findAll(r.doc, "footnoteReference").length, 0); +}); + +test("markdown import: out-of-order definitions render as a reference-ordered list", async () => { + // References appear b, a, c in the body; definitions are written in a, b, c + // order (the import order). After canonicalization the bottom list follows + // REFERENCE order so the numbers read 1, 2, 3 down the list. + const md = [ + "See[^b] then[^a] then[^c].", + "", + "[^a]: alpha", + "[^b]: bravo", + "[^c]: charlie", + ].join("\n"); + const json = await markdownToProseMirror(md); + assert.deepEqual(defIds(json), ["b", "a", "c"]); + assert.equal(findAll(json, "footnotesList").length, 1); +}); From fa929c9e86bb84fcecbd24367b6c5e6a371a6e62 Mon Sep 17 00:00:00 2001 From: a Date: Sat, 27 Jun 2026 17:10:41 +0300 Subject: [PATCH 2/7] fix(footnotes): canonicalize footnotes on server import + markdown paste (#228) The footnote canonicalizer was wired into the MCP and editor-ext write paths but NOT into the server's user-facing markdown/HTML import paths, so importing or pasting markdown with out-of-order, reused, or orphan footnotes did not canonicalize -- the exact trigger bug #228 fixes was still reproduced on import. markdownToHtml -> htmlToJson builds ProseMirror JSON directly and never runs the editor's footnoteSyncPlugin, and that plugin does not reorder an existing list, so the stored footnotes kept the source's physical definition order, retained orphans, and did not collapse reused references. Wire canonicalizeFootnotes (already exported from @docmost/editor-ext) into every server markdown/HTML -> page-JSON seam, before persisting: - ImportService.importPage (REST single-file .md/.html import) - FileImportTaskService (zip import worker) - PageService.parseProsemirrorContent (API createPage / updatePageContent) Also hook the client markdown paste: handlePaste applies a manual transaction (returns true), bypassing transformPasted/footnoteSyncPlugin, so a pasted out-of-order markdown footnote block would persist out of order. canonicalizePastedFootnotes reorders a self-contained pasted block (one that carries its own footnotesList) to reference order, deduped and orphan-free; it is deliberately scoped to whole-block pastes so a reference-only paste that reuses a footnote already defined in the target doc is left untouched. canonicalizeFootnotes is pure, idempotent and shape-safe (a doc with no footnotes is unchanged), so it is safe on every write path. Residual: when a pasted block merges into a doc that already has footnotes, ordering relative to the pre-existing footnotes is still governed by the live sync plugin (which does not reorder across the boundary). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../markdown-clipboard.canonicalize.test.ts | 142 ++++++++++++++++++ .../editor/extensions/markdown-clipboard.ts | 56 ++++++- .../src/core/page/services/page.service.ts | 10 +- .../services/file-import-task.service.ts | 11 +- ...port.service.footnote-canonicalize.spec.ts | 139 +++++++++++++++++ .../import/services/import.service.ts | 10 +- 6 files changed, 361 insertions(+), 7 deletions(-) create mode 100644 apps/client/src/features/editor/extensions/markdown-clipboard.canonicalize.test.ts create mode 100644 apps/server/src/integrations/import/services/import.service.footnote-canonicalize.spec.ts diff --git a/apps/client/src/features/editor/extensions/markdown-clipboard.canonicalize.test.ts b/apps/client/src/features/editor/extensions/markdown-clipboard.canonicalize.test.ts new file mode 100644 index 00000000..65d10481 --- /dev/null +++ b/apps/client/src/features/editor/extensions/markdown-clipboard.canonicalize.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from "vitest"; +import { Editor } from "@tiptap/core"; +import { Document } from "@tiptap/extension-document"; +import { Paragraph } from "@tiptap/extension-paragraph"; +import { Text } from "@tiptap/extension-text"; +import { Node as PMNode, Fragment, Slice } from "@tiptap/pm/model"; +import { + FootnoteReference, + FootnotesList, + FootnoteDefinition, + FOOTNOTE_REFERENCE_NAME, + FOOTNOTE_DEFINITION_NAME, + FOOTNOTES_LIST_NAME, +} from "@docmost/editor-ext"; +import { canonicalizePastedFootnotes } from "./markdown-clipboard"; + +/** + * A markdown paste builds its ProseMirror fragment via DOM -> parseSlice and is + * applied with a manual transaction (handlePaste returns true), so it bypasses + * the editor's footnoteSyncPlugin — which never reorders an existing list. These + * tests pin canonicalizePastedFootnotes, the focused hook that makes a pasted + * out-of-order markdown footnote block come out canonical (issue #228). + */ + +const extensions = [ + Document, + Paragraph, + Text, + FootnoteReference, + FootnotesList, + FootnoteDefinition, +]; + +function makeSchema() { + const editor = new Editor({ extensions, content: { type: "doc", content: [] } }); + const { schema } = editor; + return { editor, schema }; +} + +/** List footnote def ids of the (single) footnotesList in a slice, in order. */ +function listIds(slice: Slice): string[] { + const out: string[] = []; + slice.content.forEach((node: PMNode) => { + if (node.type.name === FOOTNOTES_LIST_NAME) { + node.content.forEach((def: PMNode) => { + if (def.type.name === FOOTNOTE_DEFINITION_NAME) out.push(def.attrs.id); + }); + } + }); + return out; +} + +function hasList(slice: Slice): boolean { + let found = false; + slice.content.forEach((n: PMNode) => { + if (n.type.name === FOOTNOTES_LIST_NAME) found = true; + }); + return found; +} + +describe("canonicalizePastedFootnotes", () => { + it("reorders a pasted block to reference order, dedups reuse, drops orphans", () => { + const { editor, schema } = makeSchema(); + // Body references c, a, b (and again a => reuse); definitions a, b, c, z + // (z is an orphan) — the exact shape a markdown paste produces. + const slice = new Slice( + Fragment.fromArray([ + schema.nodes.paragraph.create(null, [ + schema.text("body "), + schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "c" }), + schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }), + schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "b" }), + schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }), + ]), + schema.nodes[FOOTNOTES_LIST_NAME].create(null, [ + schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "a" }, [ + schema.nodes.paragraph.create(null, [schema.text("note A")]), + ]), + schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "b" }, [ + schema.nodes.paragraph.create(null, [schema.text("note B")]), + ]), + schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "c" }, [ + schema.nodes.paragraph.create(null, [schema.text("note C")]), + ]), + schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "z" }, [ + schema.nodes.paragraph.create(null, [schema.text("orphan")]), + ]), + ]), + ]), + 0, + 0, + ); + + const out = canonicalizePastedFootnotes(slice, schema); + // Reference order, orphan z dropped, reused a appears once. + expect(listIds(out)).toEqual(["c", "a", "b"]); + editor.destroy(); + }); + + it("leaves a reference-ONLY paste untouched (no synthesized definitions)", () => { + // A paste that reuses an id defined in the TARGET doc must NOT gain a + // synthesized empty definition here — it carries no footnotesList of its own. + const { editor, schema } = makeSchema(); + const slice = new Slice( + Fragment.from( + schema.nodes.paragraph.create(null, [ + schema.text("see "), + schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }), + ]), + ), + 0, + 0, + ); + const out = canonicalizePastedFootnotes(slice, schema); + expect(hasList(out)).toBe(false); + expect(out).toBe(slice); // returned unchanged (same reference) + editor.destroy(); + }); + + it("leaves an open (partial) slice untouched even if it carries a list", () => { + // An open slice (openStart/openEnd > 0) is a partial selection, not a + // standalone block, so it is returned as-is BEFORE any footnote handling. + const { editor, schema } = makeSchema(); + const slice = new Slice( + Fragment.fromArray([ + schema.nodes.paragraph.create(null, [ + schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }), + ]), + schema.nodes[FOOTNOTES_LIST_NAME].create(null, [ + schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "a" }, [ + schema.nodes.paragraph.create(null, [schema.text("A")]), + ]), + ]), + ]), + 1, + 1, + ); + const out = canonicalizePastedFootnotes(slice, schema); + expect(out).toBe(slice); + editor.destroy(); + }); +}); diff --git a/apps/client/src/features/editor/extensions/markdown-clipboard.ts b/apps/client/src/features/editor/extensions/markdown-clipboard.ts index bebb567a..89b7c22e 100644 --- a/apps/client/src/features/editor/extensions/markdown-clipboard.ts +++ b/apps/client/src/features/editor/extensions/markdown-clipboard.ts @@ -3,7 +3,13 @@ import { Extension } from "@tiptap/core"; import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; import { DOMParser, DOMSerializer, Fragment, Slice } from "@tiptap/pm/model"; import { find } from "linkifyjs"; -import { markdownToHtml, htmlToMarkdown } from "@docmost/editor-ext"; +import { + markdownToHtml, + htmlToMarkdown, + canonicalizeFootnotes, + FOOTNOTES_LIST_NAME, +} from "@docmost/editor-ext"; +import type { Schema } from "@tiptap/pm/model"; export const MarkdownClipboard = Extension.create({ name: "markdownClipboard", @@ -83,12 +89,25 @@ export const MarkdownClipboard = Extension.create({ const body = elementFromString(parsed); normalizeTableColumnWidths(body); - const contentNodes = DOMParser.fromSchema( + const parsedSlice = DOMParser.fromSchema( this.editor.schema, ).parseSlice(body, { preserveWhitespace: true, }); + // A markdown paste builds its ProseMirror fragment directly (DOM -> + // parseSlice), bypassing the editor's footnoteSyncPlugin, which never + // reorders an existing list. So a pasted markdown block whose footnote + // definitions are out of order (or contains orphan defs) would be + // stored out of order. Canonicalize the self-contained pasted block so + // its footnotes come out reference-ordered, deduped and orphan-free + // (issue #228). See canonicalizePastedFootnotes for why this is scoped + // to whole-block pastes that carry their own footnotesList. + const contentNodes = canonicalizePastedFootnotes( + parsedSlice, + this.editor.schema, + ); + tr.replaceRange(from, to, contentNodes); const insertEnd = tr.mapping.map(from, 1); tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(from, insertEnd - 2)), -1)); @@ -133,6 +152,39 @@ export const MarkdownClipboard = Extension.create({ }, }); +/** + * Reorder/dedup the footnotes of a SELF-CONTAINED pasted markdown block to the + * canonical invariant (the live footnoteSyncPlugin never reorders an existing + * list, so an out-of-order pasted block would otherwise persist out of order). + * + * Scoped deliberately to whole-block pastes (openStart/openEnd === 0) that carry + * their OWN footnotesList: canonicalizeFootnotes would synthesize empty + * definitions for any reference lacking a definition, which is correct for a + * standalone block but would be wrong for a reference-only paste that REUSES a + * footnote already defined in the target document — so those are left untouched + * for the paste/sync plugins to merge. Residual: when the pasted block is merged + * into a doc that already has footnotes, ordering RELATIVE to the pre-existing + * footnotes is still governed by the sync plugin (which does not reorder). + */ +export function canonicalizePastedFootnotes(slice: Slice, schema: Schema): Slice { + if (slice.openStart !== 0 || slice.openEnd !== 0) return slice; + + let hasFootnotesList = false; + slice.content.forEach((node) => { + if (node.type.name === FOOTNOTES_LIST_NAME) hasFootnotesList = true; + }); + if (!hasFootnotesList) return slice; + + const content = slice.content.toJSON(); + if (!Array.isArray(content)) return slice; + + const canonical = canonicalizeFootnotes({ type: "doc", content }) as { + content?: unknown[]; + }; + const fragment = Fragment.fromJSON(schema, canonical.content ?? []); + return new Slice(fragment, 0, 0); +} + function elementFromString(value) { // add a wrapper to preserve leading and trailing whitespace const wrappedValue = `${value}`; diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index aeb59eff..97133e01 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -52,7 +52,7 @@ import { INTERNAL_LINK_REGEX, extractPageSlugId, } from '../../../integrations/export/utils'; -import { markdownToHtml } from '@docmost/editor-ext'; +import { markdownToHtml, canonicalizeFootnotes } from '@docmost/editor-ext'; import { WatcherService } from '../../watcher/watcher.service'; import { sql } from 'kysely'; import { TransclusionService } from '../transclusion/transclusion.service'; @@ -1301,6 +1301,14 @@ export class PageService { } } + // markdown/html are converted via markdownToHtml -> htmlToJson and json may + // be written programmatically (API createPage/updatePageContent) — none of + // these run the editor's footnoteSyncPlugin, so footnotes keep the source's + // physical order, orphans survive, and reused references aren't collapsed. + // Canonicalize to the editor's invariant before persisting (issue #228). + // Pure + idempotent + shape-safe: a doc with no footnotes is unchanged. + prosemirrorJson = canonicalizeFootnotes(prosemirrorJson); + try { jsonToNode(prosemirrorJson); } catch (err) { diff --git a/apps/server/src/integrations/import/services/file-import-task.service.ts b/apps/server/src/integrations/import/services/file-import-task.service.ts index 218c75ca..7666e9b7 100644 --- a/apps/server/src/integrations/import/services/file-import-task.service.ts +++ b/apps/server/src/integrations/import/services/file-import-task.service.ts @@ -18,7 +18,7 @@ import { generateSlugId } from '../../../common/helpers'; import { v7 } from 'uuid'; import { generateJitteredKeyBetween } from 'fractional-indexing-jittered'; import { FileTask, InsertablePage } from '@docmost/db/types/entity.types'; -import { markdownToHtml } from '@docmost/editor-ext'; +import { markdownToHtml, canonicalizeFootnotes } from '@docmost/editor-ext'; import { getProsemirrorContent } from '../../../common/helpers/prosemirror/utils'; import { formatImportHtml } from '../utils/import-formatter'; import { @@ -496,9 +496,16 @@ export class FileImportTaskService { await this.importService.processHTML(html), ); - const { title, prosemirrorJson } = + const { title, prosemirrorJson: extractedJson } = this.importService.extractTitleAndRemoveHeading(pmState); + // Canonicalize footnote topology on this non-editor write path + // (markdownToHtml/processHTML never runs footnoteSyncPlugin), so a + // zip-imported page's footnotes are reference-ordered, deduped, and + // orphan-free like the editor's invariant (issue #228). Pure + + // idempotent + shape-safe; a footnote-free doc is unchanged. + const prosemirrorJson = canonicalizeFootnotes(extractedJson); + const insertablePage: InsertablePage = { id: page.id, slugId: page.slugId, diff --git a/apps/server/src/integrations/import/services/import.service.footnote-canonicalize.spec.ts b/apps/server/src/integrations/import/services/import.service.footnote-canonicalize.spec.ts new file mode 100644 index 00000000..e53b17a1 --- /dev/null +++ b/apps/server/src/integrations/import/services/import.service.footnote-canonicalize.spec.ts @@ -0,0 +1,139 @@ +// Importing ImportService transitively loads import-formatter.ts, which imports +// the ESM-only @sindresorhus/slugify package (not in jest's transform +// allowlist). slugify is irrelevant to the path under test, so it is mocked out +// to keep the module graph loadable under ts-jest. +jest.mock('@sindresorhus/slugify', () => ({ + __esModule: true, + default: (input: string) => String(input), +})); + +import { ImportService } from './import.service'; +import { canonicalizeFootnotes } from '@docmost/editor-ext'; + +/** + * Integration-ish test for the USER-FACING markdown import path + * (`ImportService.importPage`). It exercises the REAL markdown -> HTML -> JSON + * conversion and asserts that the stored page content has its footnotes + * canonicalized — the gap that issue #228 fixes: the import path builds + * ProseMirror JSON directly (never running the editor's footnoteSyncPlugin), so + * before this wiring the stored footnotes kept the markdown's physical + * definition order (out of order vs. references), retained orphan definitions, + * and did not collapse reused references. + * + * The DB/ydoc side-effects are stubbed: `getNewPagePosition` (DB query) and + * `createYdoc` (Yjs encode) are spied, and `pageRepo.insertPage` captures the + * persisted `content`. Everything between markdown and persistence is REAL. + */ + +// Out-of-order references (c, a, b), a REUSED reference ([^a] twice -> one +// footnote), and an ORPHAN definition ([^z], never referenced). +const MARKDOWN = [ + '# Title', + '', + 'Body refs [^c] and [^a] and [^b] and again [^a].', + '', + '[^a]: note A', + '[^b]: note B', + '[^c]: note C', + '[^z]: orphan note', +].join('\n'); + +function makeFile(filename: string, contents: string) { + return { + filename, + toBuffer: async () => Buffer.from(contents), + } as any; +} + +function makeService() { + let captured: any = null; + const pageRepo = { + insertPage: jest.fn(async (values: any) => { + captured = values; + return { id: 'page-id', slugId: 'slug-id' }; + }), + }; + const service = new ImportService( + pageRepo as any, + {} as any, + {} as any, + {} as any, + ); + jest.spyOn(service as any, 'getNewPagePosition').mockResolvedValue('a0'); + jest + .spyOn(service as any, 'createYdoc') + .mockResolvedValue(Buffer.from([]) as any); + return { service, pageRepo, getCaptured: () => captured }; +} + +/** List the footnote-definition ids of the (single) footnotesList, in order. */ +function footnoteListIds(content: any): string[] { + const list = (content.content ?? []).find( + (n: any) => n.type === 'footnotesList', + ); + if (!list) return []; + return (list.content ?? []) + .filter((n: any) => n.type === 'footnoteDefinition') + .map((n: any) => n.attrs?.id); +} + +function definitionText(content: any, id: string): string | undefined { + const list = (content.content ?? []).find( + (n: any) => n.type === 'footnotesList', + ); + const def = (list?.content ?? []).find( + (n: any) => n.type === 'footnoteDefinition' && n.attrs?.id === id, + ); + return def?.content?.[0]?.content?.[0]?.text; +} + +describe('ImportService.importPage — footnote canonicalization (#228)', () => { + it('orders footnotes by first reference, dedupes reuse, and drops orphans', async () => { + const { service, getCaptured } = makeService(); + + await service.importPage( + Promise.resolve(makeFile('note.md', MARKDOWN)), + 'user-id', + 'space-id', + 'workspace-id', + ); + + const content = getCaptured().content; + expect(content).toBeTruthy(); + + // Reference order is c, a, b (NOT the markdown definition order a, b, c). + expect(footnoteListIds(content)).toEqual(['c', 'a', 'b']); + + // Definitions preserved and attached to the right ids. + expect(definitionText(content, 'c')).toBe('note C'); + expect(definitionText(content, 'a')).toBe('note A'); + expect(definitionText(content, 'b')).toBe('note B'); + + // Orphan definition [^z] is dropped. + expect(footnoteListIds(content)).not.toContain('z'); + + // Reused [^a] yields exactly ONE definition, and exactly one list. + const lists = (content.content ?? []).filter( + (n: any) => n.type === 'footnotesList', + ); + expect(lists).toHaveLength(1); + expect(footnoteListIds(content).filter((id) => id === 'a')).toHaveLength(1); + }); + + it('is idempotent: canonicalizing the stored output again is a no-op', async () => { + const { service, getCaptured } = makeService(); + await service.importPage( + Promise.resolve(makeFile('note.md', MARKDOWN)), + 'user-id', + 'space-id', + 'workspace-id', + ); + const stored = getCaptured().content; + + // The stored content is already canonical; running the canonicalizer a second + // time must not change it (safe to wire into every write path). + const second = canonicalizeFootnotes(stored); + expect(second).toEqual(stored); + expect(footnoteListIds(second)).toEqual(['c', 'a', 'b']); + }); +}); diff --git a/apps/server/src/integrations/import/services/import.service.ts b/apps/server/src/integrations/import/services/import.service.ts index 19bffe8d..c2057a73 100644 --- a/apps/server/src/integrations/import/services/import.service.ts +++ b/apps/server/src/integrations/import/services/import.service.ts @@ -17,7 +17,7 @@ import { import { generateJitteredKeyBetween } from 'fractional-indexing-jittered'; import { TiptapTransformer } from '@hocuspocus/transformer'; import * as Y from 'yjs'; -import { markdownToHtml } from '@docmost/editor-ext'; +import { markdownToHtml, canonicalizeFootnotes } from '@docmost/editor-ext'; import { FileTaskStatus, FileTaskType, @@ -85,7 +85,13 @@ export class ImportService { const extracted = this.extractTitleAndRemoveHeading(prosemirrorState); const title = extracted.title; - const prosemirrorJson = extracted.prosemirrorJson; + // Imported markdown/HTML is built via markdownToHtml -> htmlToJson, which + // never runs the editor's footnoteSyncPlugin, so the footnote topology keeps + // the source's PHYSICAL definition order (out of order vs. references), + // retains orphan definitions, and is not deduped. Canonicalize before + // persisting so the stored page matches the editor's invariant (issue #228). + // Pure + idempotent + shape-safe: a doc with no footnotes is unchanged. + const prosemirrorJson = canonicalizeFootnotes(extracted.prosemirrorJson); const pageTitle = title || fileName; From 07ebd8c63e914bf9857676c92cb2e4f606c73fd9 Mon Sep 17 00:00:00 2001 From: a Date: Sat, 27 Jun 2026 20:23:16 +0300 Subject: [PATCH 3/7] =?UTF-8?q?fix(footnotes):=20address=20PR=20#232=20rev?= =?UTF-8?q?iew=20=E2=80=94=20fragment-safe=20canonicalization,=20plugin=20?= =?UTF-8?q?placement=20parity,=20dead-code=20removal=20(#228)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Must-fix: - Move canonicalizeFootnotes OUT of parseProsemirrorContent. It now runs only on FULL writes (createPage, updatePageContent operation==='replace'), never on an append/prepend fragment (a fragment would lose definition-only footnotes or synthesize a bogus empty list). Add a server binding spec. - Match the live plugin's list PLACEMENT: a single already-canonical footnotesList is left exactly where it sits (the plugin never repositions a sole correct list), so the first write no longer reorders content that follows the list. Applied to BOTH the editor-ext copy and the MCP mirror; pinned by a shared golden corpus case with content after the list. - Fix MCP tool count 38 -> 39 (README x3, AGENTS.md) and the transformJs param help (add canonicalizeFootnotes/insertInlineFootnote). Simplifications: - Remove the dead duplicate re-id mechanism (deriveFootnoteId/suffix/occurrence) from the PURE canonicalizer in both copies — references are never renamed, so the derived ids were never requested; first-wins-drop is the real behaviour. This also makes the editor-ext footnote-util note about "no cross-package copy" true again. - Remove the sentinel round-trip in insertInlineFootnote: a generalized insertNodesAfterAnchor core inserts the footnoteReference node directly. - Drop the redundant per-definition deep clone in step 4 (shallow id-normalizing copy; out is already deep-cloned). Docs / architecture: - Correct the editor-ext copy's "It exists because…" header to its real consumers (server import, page.service create/update, client paste). - Note markdownToProseMirror reuse for create/update comment in collaboration.ts. - A: shared golden JSON corpus exercised by BOTH the editor-ext copy and the MCP mirror (footnote-corpus.ts / .mjs) so "the two copies behave identically" is checkable. - C: split the MCP canonicalizer into a pure mirror + footnote-authoring.ts. - B: import services persist via a different path, so left one-line consolidation comments at the call sites rather than folding (does not fall out cleanly). Tests: insertFootnote wrapper guards + docmost_transform dryRun auto-canonicalize (MCP mock), page.service create/update + append/prepend binding (server jest), shared corpus incl. nested-container reference. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 +- README.md | 6 +- ...page.service.footnote-canonicalize.spec.ts | 153 +++ .../src/core/page/services/page.service.ts | 43 +- .../services/file-import-task.service.ts | 3 + .../import/services/import.service.ts | 4 + .../footnote/footnote-canonicalize.test.ts | 19 + .../src/lib/footnote/footnote-canonicalize.ts | 166 ++- .../src/lib/footnote/footnote-corpus.ts | 1179 +++++++++++++++++ packages/mcp/build/index.js | 3 +- packages/mcp/build/lib/collaboration.js | 11 +- packages/mcp/build/lib/footnote-authoring.js | 88 ++ .../mcp/build/lib/footnote-canonicalize.js | 202 ++- packages/mcp/build/lib/transforms.js | 80 +- packages/mcp/src/index.ts | 3 +- packages/mcp/src/lib/collaboration.ts | 11 +- packages/mcp/src/lib/footnote-authoring.ts | 91 ++ packages/mcp/src/lib/footnote-canonicalize.ts | 213 ++- packages/mcp/src/lib/transforms.ts | 97 +- .../mcp/test/mock/footnote-write.test.mjs | 152 +++ .../test/unit/footnote-canonicalize.test.mjs | 6 +- packages/mcp/test/unit/footnote-corpus.mjs | 1164 ++++++++++++++++ .../mcp/test/unit/footnote-corpus.test.mjs | 19 + 23 files changed, 3262 insertions(+), 453 deletions(-) create mode 100644 apps/server/src/core/page/services/page.service.footnote-canonicalize.spec.ts create mode 100644 packages/editor-ext/src/lib/footnote/footnote-corpus.ts create mode 100644 packages/mcp/build/lib/footnote-authoring.js create mode 100644 packages/mcp/src/lib/footnote-authoring.ts create mode 100644 packages/mcp/test/mock/footnote-write.test.mjs create mode 100644 packages/mcp/test/unit/footnote-corpus.mjs create mode 100644 packages/mcp/test/unit/footnote-corpus.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 50f86b17..e8eed03d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -254,7 +254,7 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes - **Redis** backs caching, the BullMQ queues, the WebSocket Socket.IO adapter, and collaboration sync. ### The two AI subsystems (the main fork additions) -1. **Embedded MCP server** (`integrations/mcp/` + `packages/mcp`). The standalone `@docmost/mcp` server (38 agent-native tools: per-block patch/insert/delete by id, scripted `(doc)=>doc` transforms with dry-run diff, table editing, version diff/restore, comments, images, shares) is bundled and served over HTTP at `/mcp`. It writes through Docmost's real-time-collaboration layer so concurrent human edits aren't clobbered. Each request authenticates **per-user** via the `Authorization` header — either HTTP Basic (`base64(email:password)`, the user's own Docmost login, validated through `AuthService`) or a Bearer access JWT (the user's `authToken`) — and the session acts under that user's permissions. `MCP_DOCMOST_EMAIL` / `MCP_DOCMOST_PASSWORD` are an **optional service-account fallback**, used only when a request carries neither Basic nor Bearer credentials (back-compat for CI/scripts). An admin enables MCP with a workspace toggle (Workspace settings → AI). Optionally protected by a shared `MCP_TOKEN`: when set, every `/mcp` request must carry a matching `X-MCP-Token` header (its own header, separate from `Authorization`, which now carries the per-user Basic/Bearer credentials). Note: this changed from the older `Authorization: Bearer ` scheme — see `.env.example` and the CHANGELOG Breaking Changes entry. +1. **Embedded MCP server** (`integrations/mcp/` + `packages/mcp`). The standalone `@docmost/mcp` server (39 agent-native tools: per-block patch/insert/delete by id, scripted `(doc)=>doc` transforms with dry-run diff, table editing, version diff/restore, comments, images, shares) is bundled and served over HTTP at `/mcp`. It writes through Docmost's real-time-collaboration layer so concurrent human edits aren't clobbered. Each request authenticates **per-user** via the `Authorization` header — either HTTP Basic (`base64(email:password)`, the user's own Docmost login, validated through `AuthService`) or a Bearer access JWT (the user's `authToken`) — and the session acts under that user's permissions. `MCP_DOCMOST_EMAIL` / `MCP_DOCMOST_PASSWORD` are an **optional service-account fallback**, used only when a request carries neither Basic nor Bearer credentials (back-compat for CI/scripts). An admin enables MCP with a workspace toggle (Workspace settings → AI). Optionally protected by a shared `MCP_TOKEN`: when set, every `/mcp` request must carry a matching `X-MCP-Token` header (its own header, separate from `Authorization`, which now carries the per-user Basic/Bearer credentials). Note: this changed from the older `Authorization: Bearer ` scheme — see `.env.example` and the CHANGELOG Breaking Changes entry. 2. **AI agent chat** (`core/ai-chat/` server + `apps/client/src/features/ai-chat/` client). A built-in agent over the wiki using the Vercel **AI SDK** (`ai`, `@ai-sdk/*`) against any OpenAI-compatible provider configured per workspace (`integrations/ai/` — credentials encrypted at rest via `integrations/crypto`, stored in `ai_provider_credentials`). Key pieces: - `core/ai-chat/tools/` — the agent's ~40 read+write tools. Every tool runs under the **calling user's** CASL permissions via a per-user loopback access token (`docmost-client.loader.ts`), so the agent can never exceed what the user could do. Only **reversible** operations are exposed (page history + trash; no permanent delete). Agent edits get an "AI agent" provenance badge in page history (`20260616T130000-agent-provenance` migration). - `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint. diff --git a/README.md b/README.md index cbbbdcab..8fd95cf5 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ The goal of the fork is a **100% open, AGPL-only build with no Enterprise-Editio | --- | --- | | **EE code removed** | Stripped all client and server Enterprise-Edition code; ships as a clean community/AGPL build with no license checks. | | **Comment resolution** | Re-implemented from scratch as a community feature (resolve / re-open with Open/Resolved tabs). No EE code reused, available to anyone who can comment. | -| **Embedded MCP server** | A community MCP server (`@docmost/mcp`, 38 tools) is served over HTTP at `/mcp` — no enterprise license required. Replaces the removed license-gated EE MCP. | +| **Embedded MCP server** | A community MCP server (`@docmost/mcp`, 39 tools) is served over HTTP at `/mcp` — no enterprise license required. Replaces the removed license-gated EE MCP. | | **AI agent chat** | Built-in AI agent chat over your wiki, written from scratch as a community feature — no enterprise license. The agent reads and edits pages on your behalf (scoped to your permissions), with full-text + vector (RAG) search and optional web access via external MCP servers. | | **Rebranding** | App logo / name changed from *Docmost* to *Gitmost*. | | **Compact page tree** | Default page-tree indentation reduced from 16px to 8px per nesting level. | @@ -44,7 +44,7 @@ The goal of the fork is a **100% open, AGPL-only build with no Enterprise-Editio ### Embedded MCP server Gitmost has **our own MCP server** — [docmost-mcp](https://github.com/vvzvlad/docmost-mcp), -which we wrote — **built directly into the app** and served at `/mcp`. It exposes **38 +which we wrote — **built directly into the app** and served at `/mcp`. It exposes **39 agent-native tools**: surgical per-block edits (patch / insert / delete by id), structure-preserving find/replace, scripted `(doc) => doc` transforms with a dry-run diff, structured table editing, version history with diff / restore, comments, images and share @@ -60,7 +60,7 @@ every little fix. And it needs no enterprise license. | | **Gitmost `/mcp` (our docmost-mcp)** | Docmost's built-in MCP | | --- | :---: | :---: | | **Enterprise license** | Not required | Required | -| **Tools** | 38, agent-native | Coarse (read Markdown, page CRUD, replace whole page) | +| **Tools** | 39, agent-native | Coarse (read Markdown, page CRUD, replace whole page) | | **Per-block edits / find-replace / scripted transforms** | ✅ | — | | **Structured table editing, version diff / restore** | ✅ | — | | **Comments, images, share links** | ✅ | — | diff --git a/apps/server/src/core/page/services/page.service.footnote-canonicalize.spec.ts b/apps/server/src/core/page/services/page.service.footnote-canonicalize.spec.ts new file mode 100644 index 00000000..3d2dac75 --- /dev/null +++ b/apps/server/src/core/page/services/page.service.footnote-canonicalize.spec.ts @@ -0,0 +1,153 @@ +// Binding test for issue #228 must-fix #1 / test-coverage #12: footnote +// canonicalization moved OUT of parseProsemirrorContent and is now applied only +// on FULL-document writes (createPage, and updatePageContent with operation +// 'replace'), NEVER on an append/prepend FRAGMENT. +// +// The Yjs encode / plain-text extract are stubbed (partial module mock keeps the +// REAL canonicalizeFootnotes) and parseProsemirrorContent is spied to return the +// raw fixture, so the test isolates the canonicalize BINDING from schema/Yjs. +jest.mock('@docmost/editor-ext', () => { + const actual = jest.requireActual('@docmost/editor-ext'); + return { + ...actual, + createYdocFromJson: jest.fn(() => Buffer.from([])), + jsonToText: jest.fn(() => ''), + }; +}); + +import { PageService } from './page.service'; + +const refNode = (id: string) => ({ type: 'footnoteReference', attrs: { id } }); +const defNode = (id: string, text: string) => ({ + type: 'footnoteDefinition', + attrs: { id }, + content: [{ type: 'paragraph', content: [{ type: 'text', text }] }], +}); +const doc = (...content: any[]) => ({ type: 'doc', content }); + +/** A full doc whose footnote definitions are OUT of reference order (b,a refs; + * a,b defs) — canonicalization must reorder the definitions to [b, a]. */ +const outOfOrderFull = () => + doc( + { type: 'paragraph', content: [{ type: 'text', text: 'x' }, refNode('b'), refNode('a')] }, + { type: 'footnotesList', content: [defNode('a', 'A'), defNode('b', 'B')] }, + ); + +/** A definition-ONLY fragment (no references): canonicalizing it would drop the + * whole footnotesList (referenceIds is empty) — i.e. LOSE the footnote. */ +const defOnlyFragment = () => + doc({ type: 'footnotesList', content: [defNode('a', 'appended note')] }); + +/** A reference-only fragment that REUSES an id defined elsewhere in the live + * doc: canonicalizing it would synthesize a bogus empty footnotesList/def. */ +const refReuseFragment = () => + doc({ type: 'paragraph', content: [{ type: 'text', text: 'more' }, refNode('a')] }); + +function listDefIds(content: any): string[] { + const list = (content.content ?? []).find((n: any) => n.type === 'footnotesList'); + return (list?.content ?? []) + .filter((n: any) => n.type === 'footnoteDefinition') + .map((n: any) => n.attrs?.id); +} +function hasFootnotesList(content: any): boolean { + return (content.content ?? []).some((n: any) => n.type === 'footnotesList'); +} + +describe('PageService footnote canonicalization binding (#228)', () => { + function makeService() { + let insertedContent: any = null; + let yjsPayload: any = null; + + const pageRepo = { + insertPage: jest.fn(async (values: any) => { + insertedContent = values.content; + return { id: 'page-id', slugId: 'slug-id' }; + }), + }; + const generalQueue = { add: jest.fn().mockReturnValue({ catch: jest.fn() }) }; + const collaborationGateway = { + handleYjsEvent: jest.fn(async (_evt: string, _name: string, payload: any) => { + yjsPayload = payload; + }), + }; + + const service = new PageService( + pageRepo as any, + {} as any, // pagePermissionRepo + {} as any, // attachmentRepo + {} as any, // db + {} as any, // storageService + {} as any, // attachmentQueue + {} as any, // aiQueue + generalQueue as any, + {} as any, // eventEmitter + collaborationGateway as any, + {} as any, // watcherService + {} as any, // transclusionService + ); + // Isolate the canonicalize BINDING: return the raw fixture (a deep clone so + // canonicalize never mutates the caller's object) instead of running the + // real markdown/HTML/JSON parse + schema validation. + jest + .spyOn(service as any, 'parseProsemirrorContent') + .mockImplementation(async (content: any) => structuredClone(content)); + jest.spyOn(service as any, 'nextPagePosition').mockResolvedValue('a0'); + + return { service, getInsertedContent: () => insertedContent, getYjsPayload: () => yjsPayload }; + } + + it('createPage (full write) canonicalizes footnotes into reference order', async () => { + const { service, getInsertedContent } = makeService(); + await service.create('user-id', 'workspace-id', { + spaceId: 'space-id', + content: outOfOrderFull(), + format: 'json', + } as any); + // Definitions reordered to reference order [b, a]. + expect(listDefIds(getInsertedContent())).toEqual(['b', 'a']); + }); + + it("updatePageContent operation 'replace' canonicalizes footnotes", async () => { + const { service, getYjsPayload } = makeService(); + await service.updatePageContent( + 'page-id', + outOfOrderFull(), + 'replace' as any, + 'json' as any, + { id: 'user-id' } as any, + ); + expect(getYjsPayload().operation).toBe('replace'); + expect(listDefIds(getYjsPayload().prosemirrorJson)).toEqual(['b', 'a']); + }); + + it("append of a definition-only fragment is NOT canonicalized (footnote preserved, not dropped)", async () => { + const { service, getYjsPayload } = makeService(); + await service.updatePageContent( + 'page-id', + defOnlyFragment(), + 'append' as any, + 'json' as any, + { id: 'user-id' } as any, + ); + // Canonicalizing a reference-less fragment would DROP the whole list; the + // fragment must pass through untouched so the merge keeps the definition. + expect(getYjsPayload().operation).toBe('append'); + expect(hasFootnotesList(getYjsPayload().prosemirrorJson)).toBe(true); + expect(listDefIds(getYjsPayload().prosemirrorJson)).toEqual(['a']); + }); + + it('prepend of a reference-reuse fragment is NOT canonicalized (no synthesized garbage list)', async () => { + const { service, getYjsPayload } = makeService(); + await service.updatePageContent( + 'page-id', + refReuseFragment(), + 'prepend' as any, + 'json' as any, + { id: 'user-id' } as any, + ); + // Canonicalizing would synthesize a bogus empty footnotesList for the reused + // reference; the fragment must pass through with no list at all. + expect(getYjsPayload().operation).toBe('prepend'); + expect(hasFootnotesList(getYjsPayload().prosemirrorJson)).toBe(false); + }); +}); diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index 97133e01..44382d8a 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -160,9 +160,14 @@ export class PageService { let ydoc = undefined; if (createPageDto?.content && createPageDto?.format) { - const prosemirrorJson = await this.parseProsemirrorContent( - createPageDto.content, - createPageDto.format, + // createPage always writes a FULL document, so canonicalize footnotes to + // the editor's invariant before persisting (issue #228). Pure + idempotent + // + shape-safe: a doc with no footnotes is returned unchanged. + const prosemirrorJson = canonicalizeFootnotes( + await this.parseProsemirrorContent( + createPageDto.content, + createPageDto.format, + ), ); content = prosemirrorJson; @@ -343,7 +348,17 @@ export class PageService { format: ContentFormat, user: User, ): Promise { - const prosemirrorJson = await this.parseProsemirrorContent(content, format); + let prosemirrorJson = await this.parseProsemirrorContent(content, format); + + // Canonicalize footnotes ONLY for a full-document write ('replace'). For an + // append/prepend FRAGMENT, canonicalizing is semantically wrong (it would + // drop a definition-only fragment's list, or synthesize a duplicate empty + // definition for a fragment reusing an existing id) — the fragment merges + // into the live doc where the editor's footnoteSyncPlugin keeps the invariant + // (issue #228, must-fix #1). + if (operation === 'replace') { + prosemirrorJson = canonicalizeFootnotes(prosemirrorJson); + } const documentName = `page.${pageId}`; await this.collaborationGateway.handleYjsEvent( @@ -1301,14 +1316,18 @@ export class PageService { } } - // markdown/html are converted via markdownToHtml -> htmlToJson and json may - // be written programmatically (API createPage/updatePageContent) — none of - // these run the editor's footnoteSyncPlugin, so footnotes keep the source's - // physical order, orphans survive, and reused references aren't collapsed. - // Canonicalize to the editor's invariant before persisting (issue #228). - // Pure + idempotent + shape-safe: a doc with no footnotes is unchanged. - prosemirrorJson = canonicalizeFootnotes(prosemirrorJson); - + // NOTE: footnote canonicalization is intentionally NOT done here. This + // method serves BOTH full writes (createPage / updatePageContent with + // operation 'replace') AND fragment writes (append / prepend). Canonicalizing + // a FRAGMENT is semantically wrong — e.g. a definition-only fragment has no + // references, so the canonicalizer would drop its whole footnotesList (lost + // footnotes), and a fragment reusing an existing id would synthesize an empty + // duplicate definition. The canonicalizer therefore runs only at the + // FULL-DOCUMENT callers (createPage, and updatePageContent for 'replace'), + // never on a fragment (issue #228, must-fix #1). + // (Future consolidation, architecture B: the import services persist via a + // different path; folding all of these into one "prepare JSON for persist" + // helper would centralize the canonicalize call — left as follow-up.) try { jsonToNode(prosemirrorJson); } catch (err) { diff --git a/apps/server/src/integrations/import/services/file-import-task.service.ts b/apps/server/src/integrations/import/services/file-import-task.service.ts index 7666e9b7..5ec2fe8d 100644 --- a/apps/server/src/integrations/import/services/file-import-task.service.ts +++ b/apps/server/src/integrations/import/services/file-import-task.service.ts @@ -504,6 +504,9 @@ export class FileImportTaskService { // zip-imported page's footnotes are reference-ordered, deduped, and // orphan-free like the editor's invariant (issue #228). Pure + // idempotent + shape-safe; a footnote-free doc is unchanged. + // (Future consolidation, architecture B: like import.service, this + // path persists directly rather than via PageService — a shared + // "prepare JSON for persist" helper would centralize this call.) const prosemirrorJson = canonicalizeFootnotes(extractedJson); const insertablePage: InsertablePage = { diff --git a/apps/server/src/integrations/import/services/import.service.ts b/apps/server/src/integrations/import/services/import.service.ts index c2057a73..75418e55 100644 --- a/apps/server/src/integrations/import/services/import.service.ts +++ b/apps/server/src/integrations/import/services/import.service.ts @@ -91,6 +91,10 @@ export class ImportService { // retains orphan definitions, and is not deduped. Canonicalize before // persisting so the stored page matches the editor's invariant (issue #228). // Pure + idempotent + shape-safe: a doc with no footnotes is unchanged. + // (Future consolidation, architecture B: this import path persists directly + // via pageRepo.insertPage rather than through PageService.createPage, so the + // canonicalize call lives here; folding both into one "prepare JSON for + // persist" helper is a sensible follow-up.) const prosemirrorJson = canonicalizeFootnotes(extracted.prosemirrorJson); const pageTitle = title || fileName; diff --git a/packages/editor-ext/src/lib/footnote/footnote-canonicalize.test.ts b/packages/editor-ext/src/lib/footnote/footnote-canonicalize.test.ts index 543c2028..80b56874 100644 --- a/packages/editor-ext/src/lib/footnote/footnote-canonicalize.test.ts +++ b/packages/editor-ext/src/lib/footnote/footnote-canonicalize.test.ts @@ -7,6 +7,7 @@ import { FootnoteReference } from './footnote-reference'; import { FootnotesList } from './footnotes-list'; import { FootnoteDefinition } from './footnote-definition'; import { canonicalizeFootnotes } from './footnote-canonicalize'; +import { FOOTNOTE_CORPUS } from './footnote-corpus'; import { collectReferenceIds, computeFootnoteNumbers, @@ -325,3 +326,21 @@ describe('canonicalizeFootnotes golden parity with footnoteSyncPlugin', () => { expect(new Set(defOrder(steady))).toEqual(new Set(defOrder(canon))); }); }); + +/** + * SHARED golden corpus: this editor-ext copy of `canonicalizeFootnotes` and the + * MCP mirror (`packages/mcp/src/lib/footnote-canonicalize.ts`) are BOTH run + * against the identical { input -> expected } corpus. Pinning the same expected + * outputs in both suites makes "the two pure copies behave identically" a + * checkable property without coupling the packages (architecture item A). The + * MCP mirror of these assertions lives in `test/unit/footnote-corpus.test.mjs`. + */ +describe('canonicalizeFootnotes shared golden corpus (editor-ext copy)', () => { + for (const { name, input, expected } of FOOTNOTE_CORPUS) { + it(`matches the corpus expected output: ${name}`, () => { + expect(canonicalizeFootnotes(input)).toEqual(expected); + // Idempotent on the corpus too. + expect(canonicalizeFootnotes(expected)).toEqual(expected); + }); + } +}); diff --git a/packages/editor-ext/src/lib/footnote/footnote-canonicalize.ts b/packages/editor-ext/src/lib/footnote/footnote-canonicalize.ts index 5017dc05..db543519 100644 --- a/packages/editor-ext/src/lib/footnote/footnote-canonicalize.ts +++ b/packages/editor-ext/src/lib/footnote/footnote-canonicalize.ts @@ -2,7 +2,6 @@ import { FOOTNOTE_REFERENCE_NAME, FOOTNOTES_LIST_NAME, FOOTNOTE_DEFINITION_NAME, - deriveFootnoteId, } from './footnote-util'; /** @@ -11,14 +10,20 @@ import { * `appendTransaction` that only runs inside a ProseMirror `EditorView`, this is * a PURE function over ProseMirror JSON: `canonicalizeFootnotes(doc) -> doc`. * - * It exists because every NON-editor write path (the MCP `markdownToProseMirror` - * importer, `update_page_json`, `docmost_transform`, the future git-sync writer) - * builds ProseMirror JSON directly via `TiptapTransformer`/`updateYFragment`, - * which NEVER runs the editor's plugins — so the canonical footnote topology was - * never enforced on those writes. That is the root cause of the symptom in the - * issue: footnotes rendered out of order (`1, 4, 2, 3, …`), a raw trailing - * `[^id]: …` block, and orphan definitions, all of which are simply the result - * of content written PAST the canonicalizer. + * It exists because the NON-editor write paths served by THIS copy build + * ProseMirror JSON directly (never running the editor's plugins), so the + * canonical footnote topology was never enforced on those writes. The consumers + * of this editor-ext copy are: the server markdown/HTML import + * (`markdownToHtml -> htmlToJson` in import.service / file-import-task.service), + * `PageService` create/update (`parseProsemirrorContent` for the JSON/markdown/ + * HTML REST write paths), and the client markdown PASTE path + * (`markdown-clipboard.ts`). (The MCP package mirrors this canonicalizer in + * `packages/mcp/src/lib/footnote-canonicalize.ts` for its own write paths — + * `markdownToProseMirror`, `update_page_json`, `docmost_transform`, + * `insert_footnote` — see that file's header.) All of these are the root cause + * of the symptom in the issue: footnotes rendered out of order (`1, 4, 2, 3, …`), + * a raw trailing `[^id]: …` block, and orphan definitions, all of which are + * simply the result of content written PAST the canonicalizer. * * The desired end-state (identical to the plugin's) is: * @@ -31,12 +36,14 @@ import { * or synthesizing an empty one when missing. The list sits after the last * meaningful block (only trailing empty paragraphs may follow it). * 3. Orphan definitions (no matching reference) are dropped. - * 4. Duplicate DEFINITIONS (two nodes sharing an id) are resolved - * deterministically: the first keeps the id; each later duplicate is re-id'd - * via `deriveFootnoteId` (never random) so it is never silently lost — and, - * lacking a matching reference, it then falls under the orphan policy and is - * dropped. This matches the editor's never-lose-by-collision rule and the - * importer's first-wins rule (both converge to "one definition per id"). + * 4. Duplicate DEFINITIONS (two nodes sharing an id) are resolved first-wins: + * the first definition for an id is kept; later duplicates carry the SAME + * id, so they can never be referenced separately and are simply dropped. + * This matches the importer's first-wins rule ("one definition per id"). + * (The LIVE editor instead re-id's a duplicate definition so a paste/collab + * merge cannot silently lose live user data; the artifacts this copy + * sanitizes are agent/import-authored, so first-wins is the right policy — + * see footnote-sync.ts `resolveCollisions`.) * 5. Idempotent: a document that already satisfies the invariant is returned * structurally unchanged (the existing definition/list nodes are reused * verbatim), so re-running the canonicalizer — or running it on a write that @@ -47,10 +54,18 @@ import { * PHYSICAL order of existing definition nodes to keep their Yjs/CRDT subtree * identity stable across collaborators (numbering is decoration-derived, so the * displayed numbers are correct regardless of physical order). This function has - * no live CRDT to protect, so it physically REORDERS the list into reference - * order — which is exactly the repair the out-of-order import needs. On every - * editor-reachable steady state (where the list is already reference-ordered) the - * two agree byte-for-byte; see the golden test. + * no live CRDT to protect, so when a REPAIR is needed it physically REORDERS the + * list into reference order — which is exactly the fix the out-of-order import + * needs. + * + * Placement PARITY with the plugin: when the document is already in the canonical + * single-list state, this function leaves that list EXACTLY where it sits (it + * does not move it to the end). The plugin behaves the same — it treats one + * footnotesList holding the canonical definition set as canonical regardless of + * whether content follows it (footnote-sync.ts: `primaryList` falls back to the + * last list and `noChangeNeeded` stays true). So on every editor-reachable steady + * state the two agree byte-for-byte, including when non-empty content follows the + * list; see the golden parity test and the shared corpus. * * Pure: deep-clones its input, never mutates the caller's object, and is * deterministic (no `Math.random`/`Date.now`). @@ -76,62 +91,69 @@ export function canonicalizeFootnotes(doc: T): T { const defNodes: any[] = []; collectDefinitions(out, defNodes); - // 3) Resolve the id topology deterministically. The first definition for an id - // keeps it; a later duplicate is re-id'd to a fresh derived id (never lost), - // which — having no matching reference — is dropped as an orphan in step 4. - const taken = new Set(referenceIds); + // 3) First definition per id wins. Later duplicates carry the SAME id, so they + // can never be referenced separately and would be orphans — they are simply + // dropped (first-wins; see the file header, item 4). + const defById = new Map(); for (const d of defNodes) { const id = d?.attrs?.id; - if (id) taken.add(id); - } - const occurrenceOf = new Map(); - const seenDefIds = new Set(); - // finalId -> definition node (the node reference inside `out`). - const defByFinalId = new Map(); - for (const d of defNodes) { - const origId = d?.attrs?.id; - if (!origId) continue; - if (!seenDefIds.has(origId)) { - seenDefIds.add(origId); - defByFinalId.set(origId, d); - } else { - const next = (occurrenceOf.get(origId) ?? 1) + 1; - occurrenceOf.set(origId, next); - const newId = deriveFootnoteId(origId, next, taken); - taken.add(newId); - defByFinalId.set(newId, d); - } + if (id && !defById.has(id)) defById.set(id, d); } // 4) Build the ordered definition list: one per referenced id, in REFERENCE // order, reusing the existing node (content preserved, id normalized) or - // synthesizing an empty definition. Definitions whose final id is NOT - // referenced are orphans and are simply never added. + // synthesizing an empty definition. Definitions whose id is NOT referenced + // are orphans and are simply never added. The reused node is SHALLOW-copied + // (id normalized): `out` is already a deep clone and the old lists are cut, + // so a second per-definition deep clone is needless. const orderedDefs: any[] = []; for (const id of referenceIds) { - const existing = defByFinalId.get(id); + const existing = defById.get(id); if (existing) { - const node = cloneJson(existing); - node.attrs = { ...(node.attrs ?? {}), id }; - orderedDefs.push(node); + orderedDefs.push({ + ...existing, + attrs: { ...(existing.attrs ?? {}), id }, + }); } else { orderedDefs.push(emptyDefinition(id)); } } - // 5) Strip every existing top-level footnotesList; we rebuild a single one. - const top: any[] = out.content.filter( - (n: any) => !(n && n.type === FOOTNOTES_LIST_NAME), - ); - - // 6) No references -> there must be NO list at all. + // 5) No references -> there must be NO list at all. if (referenceIds.length === 0) { - out.content = top; + out.content = out.content.filter( + (n: any) => !(n && n.type === FOOTNOTES_LIST_NAME), + ); return out; } - // 7) Insert exactly one footnotesList after the last meaningful (non-empty - // paragraph) block, so it coexists with a trailing-node empty paragraph. + // 6) Placement parity with the live plugin: when the document is ALREADY in the + // canonical single-list state, leave that list exactly where it sits instead + // of cutting and re-inserting it at the end. The plugin never repositions a + // sole correct list (footnote-sync.ts), so moving it here would silently + // reorder any user content that follows the list on the first write. The doc + // is in that state when there is exactly one top-level footnotesList, every + // definition in the doc is referenced (no orphans / duplicates: the def count + // equals the canonical count), and the list already holds exactly the + // canonical definitions in reference order. + const topLevelLists = out.content.filter( + (n: any) => n && n.type === FOOTNOTES_LIST_NAME, + ); + if ( + topLevelLists.length === 1 && + defNodes.length === orderedDefs.length && + deepEqualJson(topLevelLists[0].content, orderedDefs) + ) { + return out; + } + + // 7) Otherwise rebuild: strip every footnotesList and re-insert exactly one + // after the last meaningful (non-empty paragraph) block, so it coexists with + // a trailing-node empty paragraph. This both repairs a non-canonical doc and + // (in the import case) physically reorders the list into reference order. + const top: any[] = out.content.filter( + (n: any) => !(n && n.type === FOOTNOTES_LIST_NAME), + ); let insertAt = top.length; while (insertAt > 0 && isEmptyParagraph(top[insertAt - 1])) insertAt--; top.splice(insertAt, 0, { type: FOOTNOTES_LIST_NAME, content: orderedDefs }); @@ -139,6 +161,36 @@ export function canonicalizeFootnotes(doc: T): T { return out; } +/** + * Order-insensitive deep equality over plain JSON (objects/arrays/primitives). + * Used to detect an already-canonical footnotesList so its physical position is + * preserved (placement parity with the live plugin). + */ +function deepEqualJson(a: any, b: any): boolean { + if (a === b) return true; + if (a == null || b == null || typeof a !== typeof b) return false; + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (!deepEqualJson(a[i], b[i])) return false; + } + return true; + } + if (typeof a === 'object') { + const ka = Object.keys(a); + const kb = Object.keys(b); + if (ka.length !== kb.length) return false; + for (const k of ka) { + if (!Object.prototype.hasOwnProperty.call(b, k)) return false; + if (!deepEqualJson(a[k], b[k])) return false; + } + return true; + } + return false; +} + /** A fresh empty definition node for a referenced id with no definition. */ function emptyDefinition(id: string): any { return { diff --git a/packages/editor-ext/src/lib/footnote/footnote-corpus.ts b/packages/editor-ext/src/lib/footnote/footnote-corpus.ts new file mode 100644 index 00000000..e8521b74 --- /dev/null +++ b/packages/editor-ext/src/lib/footnote/footnote-corpus.ts @@ -0,0 +1,1179 @@ +/** + * SHARED golden corpus for the footnote canonicalizer (issue #228). + * + * Each case is { name, input, expected } where `expected` is exactly what + * `canonicalizeFootnotes(input)` must return. This is the CANONICAL copy; it is + * mirrored verbatim (data only) in `packages/mcp/test/unit/footnote-corpus.mjs`. + * Both the editor-ext copy and the MCP mirror of `canonicalizeFootnotes` are run + * against this corpus by their respective test suites, which turns "the two + * pure copies behave identically" into a checkable property without coupling the + * packages at build time. When you change one corpus, change the other. + * + * Coverage includes (besides ordering/orphan/reuse/dedup/synth/merge): a single + * canonical list with NON-EMPTY content after it (must NOT be repositioned — + * plugin placement parity, must-fix #2) and a reference nested inside a callout + * (the recursive collection, test-coverage #14). + */ +export interface FootnoteCorpusCase { + name: string; + input: any; + expected: any; +} + +export const FOOTNOTE_CORPUS: FootnoteCorpusCase[] = [ + { + "name": "out-of-order defs ordered by first reference", + "input": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "x" + }, + { + "type": "footnoteReference", + "attrs": { + "id": "b" + } + }, + { + "type": "footnoteReference", + "attrs": { + "id": "a" + } + }, + { + "type": "footnoteReference", + "attrs": { + "id": "d" + } + }, + { + "type": "footnoteReference", + "attrs": { + "id": "c" + } + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "a" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "A" + } + ] + } + ] + }, + { + "type": "footnoteDefinition", + "attrs": { + "id": "c" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "C" + } + ] + } + ] + }, + { + "type": "footnoteDefinition", + "attrs": { + "id": "b" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "B" + } + ] + } + ] + }, + { + "type": "footnoteDefinition", + "attrs": { + "id": "d" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "D" + } + ] + } + ] + } + ] + } + ] + }, + "expected": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "x" + }, + { + "type": "footnoteReference", + "attrs": { + "id": "b" + } + }, + { + "type": "footnoteReference", + "attrs": { + "id": "a" + } + }, + { + "type": "footnoteReference", + "attrs": { + "id": "d" + } + }, + { + "type": "footnoteReference", + "attrs": { + "id": "c" + } + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "b" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "B" + } + ] + } + ] + }, + { + "type": "footnoteDefinition", + "attrs": { + "id": "a" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "A" + } + ] + } + ] + }, + { + "type": "footnoteDefinition", + "attrs": { + "id": "d" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "D" + } + ] + } + ] + }, + { + "type": "footnoteDefinition", + "attrs": { + "id": "c" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "C" + } + ] + } + ] + } + ] + } + ] + } + }, + { + "name": "orphan definition dropped", + "input": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "x" + }, + { + "type": "footnoteReference", + "attrs": { + "id": "a" + } + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "a" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "A" + } + ] + } + ] + }, + { + "type": "footnoteDefinition", + "attrs": { + "id": "orphan" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "O" + } + ] + } + ] + } + ] + } + ] + }, + "expected": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "x" + }, + { + "type": "footnoteReference", + "attrs": { + "id": "a" + } + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "a" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "A" + } + ] + } + ] + } + ] + } + ] + } + }, + { + "name": "no references removes the list", + "input": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "plain" + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "orphan" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "O" + } + ] + } + ] + } + ] + } + ] + }, + "expected": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "plain" + } + ] + } + ] + } + }, + { + "name": "reuse: repeated references collapse to one definition", + "input": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "footnoteReference", + "attrs": { + "id": "d" + } + }, + { + "type": "text", + "text": " a " + }, + { + "type": "footnoteReference", + "attrs": { + "id": "d" + } + }, + { + "type": "footnoteReference", + "attrs": { + "id": "d" + } + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "d" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "shared" + } + ] + } + ] + } + ] + } + ] + }, + "expected": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "footnoteReference", + "attrs": { + "id": "d" + } + }, + { + "type": "text", + "text": " a " + }, + { + "type": "footnoteReference", + "attrs": { + "id": "d" + } + }, + { + "type": "footnoteReference", + "attrs": { + "id": "d" + } + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "d" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "shared" + } + ] + } + ] + } + ] + } + ] + } + }, + { + "name": "duplicate definitions: first wins", + "input": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "x" + }, + { + "type": "footnoteReference", + "attrs": { + "id": "d" + } + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "d" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "first" + } + ] + } + ] + }, + { + "type": "footnoteDefinition", + "attrs": { + "id": "d" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "second" + } + ] + } + ] + }, + { + "type": "footnoteDefinition", + "attrs": { + "id": "d" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "third" + } + ] + } + ] + } + ] + } + ] + }, + "expected": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "x" + }, + { + "type": "footnoteReference", + "attrs": { + "id": "d" + } + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "d" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "first" + } + ] + } + ] + } + ] + } + ] + } + }, + { + "name": "synthesizes an empty definition for a reference with none", + "input": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "x" + }, + { + "type": "footnoteReference", + "attrs": { + "id": "missing" + } + } + ] + } + ] + }, + "expected": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "x" + }, + { + "type": "footnoteReference", + "attrs": { + "id": "missing" + } + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "missing" + }, + "content": [ + { + "type": "paragraph" + } + ] + } + ] + } + ] + } + }, + { + "name": "merges multiple footnotesList nodes into one", + "input": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "a" + }, + { + "type": "footnoteReference", + "attrs": { + "id": "x" + } + }, + { + "type": "footnoteReference", + "attrs": { + "id": "y" + } + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "x" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "X" + } + ] + } + ] + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "tail" + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "y" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Y" + } + ] + } + ] + } + ] + } + ] + }, + "expected": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "a" + }, + { + "type": "footnoteReference", + "attrs": { + "id": "x" + } + }, + { + "type": "footnoteReference", + "attrs": { + "id": "y" + } + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "tail" + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "x" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "X" + } + ] + } + ] + }, + { + "type": "footnoteDefinition", + "attrs": { + "id": "y" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Y" + } + ] + } + ] + } + ] + } + ] + } + }, + { + "name": "single canonical list before a trailing empty paragraph stays put", + "input": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "x" + }, + { + "type": "footnoteReference", + "attrs": { + "id": "a" + } + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "a" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "A" + } + ] + } + ] + } + ] + }, + { + "type": "paragraph" + } + ] + }, + "expected": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "x" + }, + { + "type": "footnoteReference", + "attrs": { + "id": "a" + } + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "a" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "A" + } + ] + } + ] + } + ] + }, + { + "type": "paragraph" + } + ] + } + }, + { + "name": "single canonical list with NON-EMPTY content after it is NOT moved (plugin parity)", + "input": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "x" + }, + { + "type": "footnoteReference", + "attrs": { + "id": "a" + } + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "a" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "A" + } + ] + } + ] + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "epilogue text" + } + ] + } + ] + }, + "expected": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "x" + }, + { + "type": "footnoteReference", + "attrs": { + "id": "a" + } + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "a" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "A" + } + ] + } + ] + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "epilogue text" + } + ] + } + ] + } + }, + { + "name": "reference inside a nested container (callout) is collected", + "input": { + "type": "doc", + "content": [ + { + "type": "callout", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "see " + }, + { + "type": "footnoteReference", + "attrs": { + "id": "n" + } + } + ] + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "n" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "note" + } + ] + } + ] + } + ] + } + ] + }, + "expected": { + "type": "doc", + "content": [ + { + "type": "callout", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "see " + }, + { + "type": "footnoteReference", + "attrs": { + "id": "n" + } + } + ] + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "n" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "note" + } + ] + } + ] + } + ] + } + ] + } + }, + { + "name": "no footnotes at all is unchanged", + "input": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "just text" + } + ] + } + ] + }, + "expected": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "just text" + } + ] + } + ] + } + } +]; diff --git a/packages/mcp/build/index.js b/packages/mcp/build/index.js index 06bc19ea..58197d09 100644 --- a/packages/mcp/build/index.js +++ b/packages/mcp/build/index.js @@ -656,7 +656,8 @@ export function createDocmostMcpServer(config) { "parenthesized function). It receives a clone of the live doc and " + "ctx (comments, log, consume(id), helpers: blockText/walk/getList/" + "insertMarkerAfter/setCalloutRange/noteItem/mdToInlineNodes/" + - "commentsToFootnotes) and must return a {type:'doc'} node."), + "commentsToFootnotes/canonicalizeFootnotes/insertInlineFootnote) " + + "and must return a {type:'doc'} node."), dryRun: z .boolean() .optional() diff --git a/packages/mcp/build/lib/collaboration.js b/packages/mcp/build/lib/collaboration.js index 1b6b1a10..67942c6d 100644 --- a/packages/mcp/build/lib/collaboration.js +++ b/packages/mcp/build/lib/collaboration.js @@ -344,7 +344,16 @@ function extractFootnotes(markdown) { section: `
${inner}
`, }; } -/** Convert markdown to a ProseMirror doc using the full Docmost schema. */ +/** + * Convert markdown to a ProseMirror doc using the full Docmost schema. + * + * NOTE: besides the page-import write paths, this is also reused for comment + * bodies (createComment / updateComment). For an ordinary comment the + * canonicalize call below is a no-op (a comment carries no footnotes), so the + * reuse is safe; the only theoretical effect is if footnote markup were ever + * authored INSIDE a comment — a narrow case where canonicalizing the comment's + * own (self-contained) footnotes is still the correct behaviour. + */ export async function markdownToProseMirror(markdownContent) { const withCallouts = await preprocessCallouts(markdownContent); const { body, section } = extractFootnotes(withCallouts); diff --git a/packages/mcp/build/lib/footnote-authoring.js b/packages/mcp/build/lib/footnote-authoring.js new file mode 100644 index 00000000..ab8d7eb2 --- /dev/null +++ b/packages/mcp/build/lib/footnote-authoring.js @@ -0,0 +1,88 @@ +/** + * Inline-authoring helpers for footnotes (MCP). + * + * These build/identify footnote DEFINITION nodes for the author-inline tool + * (`insertInlineFootnote` in transforms.ts): a content key to de-duplicate notes + * by text, a definition-node factory, and a fresh uuidv7-style id generator. + * + * Split out of `footnote-canonicalize.ts` so that module stays a pure MIRROR of + * the editor-ext canonicalizer (compositionally symmetric to the editor-ext + * copy, which keeps its authoring helpers in `footnote-util.ts`). The pure + * canonicalizer has no dependency on these. + */ +const FOOTNOTE_DEFINITION_NAME = "footnoteDefinition"; +function cloneJson(v) { + if (typeof structuredClone === "function") + return structuredClone(v); + return JSON.parse(JSON.stringify(v)); +} +/** + * Normalized content key for de-duplicating footnote DEFINITIONS by their text. + * + * Two definitions with the same key are the SAME footnote — so the inline + * authoring tool reuses one id (one number, one definition, several references) + * instead of minting a second definition. Key = plaintext (whitespace-collapsed, + * trimmed) PLUS a signature of the inline mark types in order, so two notes that + * read the same but differ in formatting (one bold, one plain) are NOT merged. + * Conservative: only an exact match merges. + */ +export function footnoteContentKey(defNode) { + const parts = []; + const visit = (n) => { + if (!n || typeof n !== "object") + return; + if (n.type === "text" && typeof n.text === "string") { + const marks = Array.isArray(n.marks) + ? n.marks.map((m) => m?.type).filter(Boolean).sort().join(",") + : ""; + parts.push(`${n.text}${marks}`); + } + if (Array.isArray(n.content)) + for (const c of n.content) + visit(c); + }; + visit(defNode); + // Collapse the assembled text's whitespace and trim, keeping the mark + // signature attached so formatting differences still distinguish notes. + return parts + .join("") + .replace(/[ \t\r\n]+/g, " ") + .trim(); +} +/** + * Build a footnoteDefinition node from inline ProseMirror nodes, keyed by id. + */ +export function makeFootnoteDefinition(id, inlineNodes) { + const content = Array.isArray(inlineNodes) ? cloneJson(inlineNodes) : []; + return { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id }, + content: [{ type: "paragraph", content }], + }; +} +/** + * Generate a uuidv7-style id (time-ordered), matching editor-ext's + * `generateFootnoteId`. Used for a genuinely-new inline footnote id. + */ +export function generateFootnoteId() { + const now = Date.now(); + const timeHex = now.toString(16).padStart(12, "0"); + const rand = (length) => { + let s = ""; + for (let i = 0; i < length; i++) + s += Math.floor(Math.random() * 16).toString(16); + return s; + }; + const versioned = "7" + rand(3); + const variantNibble = (8 + Math.floor(Math.random() * 4)).toString(16); + const variant = variantNibble + rand(3); + return (timeHex.slice(0, 8) + + "-" + + timeHex.slice(8, 12) + + "-" + + versioned + + "-" + + variant + + "-" + + rand(12)); +} diff --git a/packages/mcp/build/lib/footnote-canonicalize.js b/packages/mcp/build/lib/footnote-canonicalize.js index 056a2d31..92511ae1 100644 --- a/packages/mcp/build/lib/footnote-canonicalize.js +++ b/packages/mcp/build/lib/footnote-canonicalize.js @@ -1,5 +1,5 @@ /** - * Server-side footnote canonicalizer + inline authoring helper (MCP mirror). + * Server-side footnote canonicalizer (MCP mirror — PURE). * * `canonicalizeFootnotes(doc)` is a pure ProseMirror-JSON port of the editor's * `footnoteSyncPlugin` end-state, identical in behaviour to @@ -8,7 +8,13 @@ * `docmost-schema.ts` nodes are mirrored: the MCP package is deliberately * decoupled from the browser/React-heavy editor barrel and operates on plain * JSON. The editor-ext copy owns the golden test against the live plugin; this - * copy must stay behaviourally identical. + * copy must stay behaviourally identical (a SHARED golden corpus, exercised by + * both test suites, pins that — see `test/unit/footnote-corpus.mjs`). + * + * This module is the pure MIRROR only. The inline-authoring helpers + * (`footnoteContentKey`, `makeFootnoteDefinition`, `generateFootnoteId`) used by + * `insertInlineFootnote` live in the sibling `footnote-authoring.ts`, so this + * file is compositionally symmetric to the editor-ext copy. * * Why it exists: every NON-editor write path (markdown import, update_page_json, * docmost_transform, insert_footnote) builds ProseMirror JSON directly, so the @@ -26,32 +32,6 @@ function cloneJson(v) { return structuredClone(v); return JSON.parse(JSON.stringify(v)); } -/** - * Deterministic unique id for the k-th (k >= 2) duplicate of an id during - * collision resolution. Pure function of (originalId, occurrence, taken) — no - * Math.random/Date.now — mirroring editor-ext's `deriveFootnoteId`. Kept local - * (the importer's first-wins de-dup means duplicates are rare here, but the - * canonicalizer must still resolve them deterministically). - */ -export function deriveFootnoteId(originalId, occurrence, taken) { - let candidate = `${originalId}__${occurrence}`; - let n = 0; - while (taken.has(candidate)) { - n += 1; - candidate = `${originalId}__${occurrence}${suffix(n)}`; - } - return candidate; -} -function suffix(n) { - let out = ""; - let x = n; - while (x > 0) { - const rem = (x - 1) % 25; - out = String.fromCharCode(98 + rem) + out; // 98 = 'b' - x = Math.floor((x - 1) / 25); - } - return out; -} function isEmptyParagraph(node) { return (!!node && node.type === "paragraph" && @@ -89,6 +69,41 @@ function emptyDefinition(id) { content: [{ type: "paragraph" }], }; } +/** + * Order-insensitive deep equality over plain JSON (objects/arrays/primitives). + * Used to detect an already-canonical footnotesList so its physical position is + * preserved (placement parity with the live plugin). + */ +function deepEqualJson(a, b) { + if (a === b) + return true; + if (a == null || b == null || typeof a !== typeof b) + return false; + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (!deepEqualJson(a[i], b[i])) + return false; + } + return true; + } + if (typeof a === "object") { + const ka = Object.keys(a); + const kb = Object.keys(b); + if (ka.length !== kb.length) + return false; + for (const k of ka) { + if (!Object.prototype.hasOwnProperty.call(b, k)) + return false; + if (!deepEqualJson(a[k], b[k])) + return false; + } + return true; + } + return false; +} /** * Canonicalize footnotes in a ProseMirror-JSON document. See the file header and * the editor-ext twin for the full contract. Pure (deep-clones input, @@ -101,52 +116,57 @@ export function canonicalizeFootnotes(doc) { return doc; } const out = cloneJson(doc); + // 1) Distinct reference ids in document order (deep — refs can live in + // callouts, tables, list items, ...). The ordering/numbering truth. const referenceIds = []; collectReferenceIds(out, referenceIds, new Set()); + // 2) Every definition node in document order (deep). const defNodes = []; collectDefinitions(out, defNodes); - const taken = new Set(referenceIds); + // 3) First definition per id wins; later duplicates carry the SAME id, so they + // cannot be referenced separately and would be orphans — they are dropped. + const defById = new Map(); for (const d of defNodes) { const id = d?.attrs?.id; - if (id) - taken.add(id); - } - const occurrenceOf = new Map(); - const seenDefIds = new Set(); - const defByFinalId = new Map(); - for (const d of defNodes) { - const origId = d?.attrs?.id; - if (!origId) - continue; - if (!seenDefIds.has(origId)) { - seenDefIds.add(origId); - defByFinalId.set(origId, d); - } - else { - const next = (occurrenceOf.get(origId) ?? 1) + 1; - occurrenceOf.set(origId, next); - const newId = deriveFootnoteId(origId, next, taken); - taken.add(newId); - defByFinalId.set(newId, d); - } + if (id && !defById.has(id)) + defById.set(id, d); } + // 4) Build the ordered definition list: one per referenced id, in REFERENCE + // order, reusing the existing node (shallow-copied, id normalized — `out` is + // already deep-cloned and the old lists are cut) or synthesizing an empty + // one. Definitions whose id is not referenced are orphans and never added. const orderedDefs = []; for (const id of referenceIds) { - const existing = defByFinalId.get(id); + const existing = defById.get(id); if (existing) { - const node = cloneJson(existing); - node.attrs = { ...(node.attrs ?? {}), id }; - orderedDefs.push(node); + orderedDefs.push({ + ...existing, + attrs: { ...(existing.attrs ?? {}), id }, + }); } else { orderedDefs.push(emptyDefinition(id)); } } - const top = out.content.filter((n) => !(n && n.type === FOOTNOTES_LIST_NAME)); + // 5) No references -> there must be NO list at all. if (referenceIds.length === 0) { - out.content = top; + out.content = out.content.filter((n) => !(n && n.type === FOOTNOTES_LIST_NAME)); return out; } + // 6) Placement parity with the live plugin: when the document is ALREADY in the + // canonical single-list state, leave that list exactly where it sits rather + // than cutting and re-inserting it at the end (the plugin never repositions a + // sole correct list, so moving it would silently reorder any content that + // follows the list on the first write). + const topLevelLists = out.content.filter((n) => n && n.type === FOOTNOTES_LIST_NAME); + if (topLevelLists.length === 1 && + defNodes.length === orderedDefs.length && + deepEqualJson(topLevelLists[0].content, orderedDefs)) { + return out; + } + // 7) Otherwise rebuild: strip every footnotesList and re-insert exactly one + // after the last meaningful (non-empty paragraph) block. + const top = out.content.filter((n) => !(n && n.type === FOOTNOTES_LIST_NAME)); let insertAt = top.length; while (insertAt > 0 && isEmptyParagraph(top[insertAt - 1])) insertAt--; @@ -154,73 +174,3 @@ export function canonicalizeFootnotes(doc) { out.content = top; return out; } -/** - * Normalized content key for de-duplicating footnote DEFINITIONS by their text. - * - * Two definitions with the same key are the SAME footnote — so the inline - * authoring tool reuses one id (one number, one definition, several references) - * instead of minting a second definition. Key = plaintext (whitespace-collapsed, - * trimmed) PLUS a signature of the inline mark types in order, so two notes that - * read the same but differ in formatting (one bold, one plain) are NOT merged. - * Conservative: only an exact match merges. - */ -export function footnoteContentKey(defNode) { - const parts = []; - const visit = (n) => { - if (!n || typeof n !== "object") - return; - if (n.type === "text" && typeof n.text === "string") { - const marks = Array.isArray(n.marks) - ? n.marks.map((m) => m?.type).filter(Boolean).sort().join(",") - : ""; - parts.push(`${n.text}${marks}`); - } - if (Array.isArray(n.content)) - for (const c of n.content) - visit(c); - }; - visit(defNode); - // Collapse the assembled text's whitespace and trim, keeping the mark - // signature attached so formatting differences still distinguish notes. - return parts - .join("") - .replace(/[ \t\r\n]+/g, " ") - .trim(); -} -/** - * Build a footnoteDefinition node from inline ProseMirror nodes, keyed by id. - */ -export function makeFootnoteDefinition(id, inlineNodes) { - const content = Array.isArray(inlineNodes) ? cloneJson(inlineNodes) : []; - return { - type: FOOTNOTE_DEFINITION_NAME, - attrs: { id }, - content: [{ type: "paragraph", content }], - }; -} -/** - * Generate a uuidv7-style id (time-ordered), matching editor-ext's - * `generateFootnoteId`. Used for a genuinely-new inline footnote id. - */ -export function generateFootnoteId() { - const now = Date.now(); - const timeHex = now.toString(16).padStart(12, "0"); - const rand = (length) => { - let s = ""; - for (let i = 0; i < length; i++) - s += Math.floor(Math.random() * 16).toString(16); - return s; - }; - const versioned = "7" + rand(3); - const variantNibble = (8 + Math.floor(Math.random() * 4)).toString(16); - const variant = variantNibble + rand(3); - return (timeHex.slice(0, 8) + - "-" + - timeHex.slice(8, 12) + - "-" + - versioned + - "-" + - variant + - "-" + - rand(12)); -} diff --git a/packages/mcp/build/lib/transforms.js b/packages/mcp/build/lib/transforms.js index 76147f02..ff5862a6 100644 --- a/packages/mcp/build/lib/transforms.js +++ b/packages/mcp/build/lib/transforms.js @@ -14,7 +14,8 @@ * - `marks` arrays are preserved verbatim when fragments are split/reordered. */ import { blockPlainText } from "./node-ops.js"; -import { canonicalizeFootnotes, footnoteContentKey, makeFootnoteDefinition, generateFootnoteId, } from "./footnote-canonicalize.js"; +import { canonicalizeFootnotes } from "./footnote-canonicalize.js"; +import { footnoteContentKey, makeFootnoteDefinition, generateFootnoteId, } from "./footnote-authoring.js"; export { canonicalizeFootnotes } from "./footnote-canonicalize.js"; /** Deep-clone a JSON-serializable value without mutating the original. */ function clone(value) { @@ -85,6 +86,19 @@ export function getList(doc, predicate) { * false when the anchor text was not found in any in-scope block. */ export function insertMarkerAfter(doc, anchor, marker, opts = {}) { + // A plain marker is a leading-space-padded unmarked text run. + return insertNodesAfterAnchor(doc, anchor, () => [{ type: "text", text: " " + marker }], opts); +} +/** + * Mark-safe insertion CORE: split the inline text run that holds the END of + * `anchor` (preserving the surrounding marks) and splice the nodes produced by + * `makeMiddle()` in at the split point. `insertMarkerAfter` (plain text marker) + * and `insertInlineFootnote` (a `footnoteReference` node) are both thin callers — + * the only difference is WHAT is inserted (a space-padded text run vs. a node + * that should hug the preceding word), which is exactly what `makeMiddle` + * decides. Operates on a clone; returns `{ doc, inserted }`. + */ +function insertNodesAfterAnchor(doc, anchor, makeMiddle, opts = {}) { const out = clone(doc); if (!isObject(out) || !Array.isArray(out.content) || !anchor) { return { doc: out, inserted: false }; @@ -138,8 +152,9 @@ export function insertMarkerAfter(doc, anchor, marker, opts = {}) { if (before.length > 0) { parts.push({ ...n, text: before, marks: [...marks] }); } - // Marker is a PLAIN run: no marks copied. Leading space separates it. - parts.push({ type: "text", text: " " + marker }); + // The inserted nodes are caller-decided (a space-padded marker run, + // or a node that hugs the word). They carry no copied marks. + parts.push(...makeMiddle()); if (after.length > 0) { parts.push({ ...n, text: after, marks: [...marks] }); } @@ -473,8 +488,6 @@ export function commentsToFootnotes(doc, comments, opts = {}) { const synced = setCalloutRange(working, definitions.length); return { doc: synced.doc, consumed }; } -/** A NUL-delimited sentinel that cannot occur in real prose. */ -const INLINE_FOOTNOTE_SENTINEL = "\u0000IFN\u0000"; /** * AUTHOR-INLINE footnote insertion. The caller supplies WHERE (anchorText) and * WHAT (markdown text); numbering and the bottom list are derived server-side by @@ -488,10 +501,10 @@ const INLINE_FOOTNOTE_SENTINEL = "\u0000IFN\u0000"; * minted and a new definition added. Conservative — only an exact content match * merges. * - * Mechanics: the marker is inserted with the same mark-safe `insertMarkerAfter` - * split used elsewhere, via a sentinel that is then replaced by a real - * `footnoteReference` node (dropping the inserted leading space so the marker - * attaches to the preceding word). The whole document is then canonicalized. + * Mechanics: the `footnoteReference` node is inserted DIRECTLY at the anchor via + * the same mark-safe split as `insertMarkerAfter` (the shared + * `insertNodesAfterAnchor` core), so it hugs the preceding word with no text + * sentinel round-trip. The whole document is then canonicalized. * * Operates on a clone of `doc`. When the anchor is not found, returns the input * unchanged with `inserted:false`. @@ -518,14 +531,13 @@ export function insertInlineFootnote(doc, opts) { } if (footnoteId == null) footnoteId = generateFootnoteId(); - // Insert a sentinel marker after the anchor (mark-safe split). - const r = insertMarkerAfter(doc, (opts.anchorText ?? "").trimEnd(), INLINE_FOOTNOTE_SENTINEL); + // Insert the footnoteReference node directly after the anchor (mark-safe + // split); it hugs the preceding word with no leading space. + const r = insertNodesAfterAnchor(doc, (opts.anchorText ?? "").trimEnd(), () => [{ type: "footnoteReference", attrs: { id: footnoteId } }]); if (!r.inserted) { return { doc: clone(doc), inserted: false, footnoteId, reused }; } let working = r.doc; - // Replace the sentinel run with a real footnoteReference node. - replaceSentinelWithReference(working, footnoteId); // Add a NEW definition (canonicalize will order/place it); a reused id needs // no new definition (the existing one is shared). if (!reused) { @@ -535,48 +547,6 @@ export function insertInlineFootnote(doc, opts) { working = canonicalizeFootnotes(working); return { doc: working, inserted: true, footnoteId, reused }; } -/** - * Replace the lone sentinel text run (created by insertMarkerAfter as - * `" " + sentinel`) with a footnoteReference node, dropping the leading space so - * the marker attaches to the preceding word. Mutates `doc` in place. - */ -function replaceSentinelWithReference(doc, footnoteId) { - let done = false; - const visit = (container) => { - if (done || !isObject(container) || !Array.isArray(container.content)) - return; - const arr = container.content; - for (let i = 0; i < arr.length; i++) { - const n = arr[i]; - if (isObject(n) && - n.type === "text" && - typeof n.text === "string" && - n.text.includes(INLINE_FOOTNOTE_SENTINEL)) { - const idx = n.text.indexOf(INLINE_FOOTNOTE_SENTINEL); - // Text before the sentinel, with a single trailing space (the one - // insertMarkerAfter prepended) stripped so the ref hugs the word. - const before = n.text.slice(0, idx).replace(/ $/, ""); - const after = n.text.slice(idx + INLINE_FOOTNOTE_SENTINEL.length); - const marks = Array.isArray(n.marks) ? n.marks : []; - const parts = []; - if (before.length > 0) - parts.push({ ...n, text: before, marks: [...marks] }); - parts.push({ type: "footnoteReference", attrs: { id: footnoteId } }); - if (after.length > 0) - parts.push({ ...n, text: after, marks: [...marks] }); - arr.splice(i, 1, ...parts); - done = true; - return; - } - } - for (const child of arr) { - visit(child); - if (done) - return; - } - }; - visit(doc); -} /** * Append a definition node so the canonicalizer can order/place it: into the * first existing footnotesList, or a new trailing list when none exists. diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index b980c8cc..d439229a 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -912,7 +912,8 @@ server.registerTool( "parenthesized function). It receives a clone of the live doc and " + "ctx (comments, log, consume(id), helpers: blockText/walk/getList/" + "insertMarkerAfter/setCalloutRange/noteItem/mdToInlineNodes/" + - "commentsToFootnotes) and must return a {type:'doc'} node.", + "commentsToFootnotes/canonicalizeFootnotes/insertInlineFootnote) " + + "and must return a {type:'doc'} node.", ), dryRun: z .boolean() diff --git a/packages/mcp/src/lib/collaboration.ts b/packages/mcp/src/lib/collaboration.ts index 55159ef9..e6f57aa8 100644 --- a/packages/mcp/src/lib/collaboration.ts +++ b/packages/mcp/src/lib/collaboration.ts @@ -393,7 +393,16 @@ function extractFootnotes(markdown: string): { }; } -/** Convert markdown to a ProseMirror doc using the full Docmost schema. */ +/** + * Convert markdown to a ProseMirror doc using the full Docmost schema. + * + * NOTE: besides the page-import write paths, this is also reused for comment + * bodies (createComment / updateComment). For an ordinary comment the + * canonicalize call below is a no-op (a comment carries no footnotes), so the + * reuse is safe; the only theoretical effect is if footnote markup were ever + * authored INSIDE a comment — a narrow case where canonicalizing the comment's + * own (self-contained) footnotes is still the correct behaviour. + */ export async function markdownToProseMirror( markdownContent: string, ): Promise { diff --git a/packages/mcp/src/lib/footnote-authoring.ts b/packages/mcp/src/lib/footnote-authoring.ts new file mode 100644 index 00000000..9dfcd7fa --- /dev/null +++ b/packages/mcp/src/lib/footnote-authoring.ts @@ -0,0 +1,91 @@ +/** + * Inline-authoring helpers for footnotes (MCP). + * + * These build/identify footnote DEFINITION nodes for the author-inline tool + * (`insertInlineFootnote` in transforms.ts): a content key to de-duplicate notes + * by text, a definition-node factory, and a fresh uuidv7-style id generator. + * + * Split out of `footnote-canonicalize.ts` so that module stays a pure MIRROR of + * the editor-ext canonicalizer (compositionally symmetric to the editor-ext + * copy, which keeps its authoring helpers in `footnote-util.ts`). The pure + * canonicalizer has no dependency on these. + */ + +const FOOTNOTE_DEFINITION_NAME = "footnoteDefinition"; + +function cloneJson(v: T): T { + if (typeof structuredClone === "function") return structuredClone(v); + return JSON.parse(JSON.stringify(v)) as T; +} + +/** + * Normalized content key for de-duplicating footnote DEFINITIONS by their text. + * + * Two definitions with the same key are the SAME footnote — so the inline + * authoring tool reuses one id (one number, one definition, several references) + * instead of minting a second definition. Key = plaintext (whitespace-collapsed, + * trimmed) PLUS a signature of the inline mark types in order, so two notes that + * read the same but differ in formatting (one bold, one plain) are NOT merged. + * Conservative: only an exact match merges. + */ +export function footnoteContentKey(defNode: any): string { + const parts: string[] = []; + const visit = (n: any): void => { + if (!n || typeof n !== "object") return; + if (n.type === "text" && typeof n.text === "string") { + const marks = Array.isArray(n.marks) + ? n.marks.map((m: any) => m?.type).filter(Boolean).sort().join(",") + : ""; + parts.push(`${n.text}${marks}`); + } + if (Array.isArray(n.content)) for (const c of n.content) visit(c); + }; + visit(defNode); + // Collapse the assembled text's whitespace and trim, keeping the mark + // signature attached so formatting differences still distinguish notes. + return parts + .join("") + .replace(/[ \t\r\n]+/g, " ") + .trim(); +} + +/** + * Build a footnoteDefinition node from inline ProseMirror nodes, keyed by id. + */ +export function makeFootnoteDefinition(id: string, inlineNodes: any[]): any { + const content = Array.isArray(inlineNodes) ? cloneJson(inlineNodes) : []; + return { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id }, + content: [{ type: "paragraph", content }], + }; +} + +/** + * Generate a uuidv7-style id (time-ordered), matching editor-ext's + * `generateFootnoteId`. Used for a genuinely-new inline footnote id. + */ +export function generateFootnoteId(): string { + const now = Date.now(); + const timeHex = now.toString(16).padStart(12, "0"); + const rand = (length: number) => { + let s = ""; + for (let i = 0; i < length; i++) + s += Math.floor(Math.random() * 16).toString(16); + return s; + }; + const versioned = "7" + rand(3); + const variantNibble = (8 + Math.floor(Math.random() * 4)).toString(16); + const variant = variantNibble + rand(3); + return ( + timeHex.slice(0, 8) + + "-" + + timeHex.slice(8, 12) + + "-" + + versioned + + "-" + + variant + + "-" + + rand(12) + ); +} diff --git a/packages/mcp/src/lib/footnote-canonicalize.ts b/packages/mcp/src/lib/footnote-canonicalize.ts index c05af3da..d5a4a257 100644 --- a/packages/mcp/src/lib/footnote-canonicalize.ts +++ b/packages/mcp/src/lib/footnote-canonicalize.ts @@ -1,5 +1,5 @@ /** - * Server-side footnote canonicalizer + inline authoring helper (MCP mirror). + * Server-side footnote canonicalizer (MCP mirror — PURE). * * `canonicalizeFootnotes(doc)` is a pure ProseMirror-JSON port of the editor's * `footnoteSyncPlugin` end-state, identical in behaviour to @@ -8,7 +8,13 @@ * `docmost-schema.ts` nodes are mirrored: the MCP package is deliberately * decoupled from the browser/React-heavy editor barrel and operates on plain * JSON. The editor-ext copy owns the golden test against the live plugin; this - * copy must stay behaviourally identical. + * copy must stay behaviourally identical (a SHARED golden corpus, exercised by + * both test suites, pins that — see `test/unit/footnote-corpus.mjs`). + * + * This module is the pure MIRROR only. The inline-authoring helpers + * (`footnoteContentKey`, `makeFootnoteDefinition`, `generateFootnoteId`) used by + * `insertInlineFootnote` live in the sibling `footnote-authoring.ts`, so this + * file is compositionally symmetric to the editor-ext copy. * * Why it exists: every NON-editor write path (markdown import, update_page_json, * docmost_transform, insert_footnote) builds ProseMirror JSON directly, so the @@ -28,38 +34,6 @@ function cloneJson(v: T): T { return JSON.parse(JSON.stringify(v)) as T; } -/** - * Deterministic unique id for the k-th (k >= 2) duplicate of an id during - * collision resolution. Pure function of (originalId, occurrence, taken) — no - * Math.random/Date.now — mirroring editor-ext's `deriveFootnoteId`. Kept local - * (the importer's first-wins de-dup means duplicates are rare here, but the - * canonicalizer must still resolve them deterministically). - */ -export function deriveFootnoteId( - originalId: string, - occurrence: number, - taken: Set | ReadonlySet, -): string { - let candidate = `${originalId}__${occurrence}`; - let n = 0; - while (taken.has(candidate)) { - n += 1; - candidate = `${originalId}__${occurrence}${suffix(n)}`; - } - return candidate; -} - -function suffix(n: number): string { - let out = ""; - let x = n; - while (x > 0) { - const rem = (x - 1) % 25; - out = String.fromCharCode(98 + rem) + out; // 98 = 'b' - x = Math.floor((x - 1) / 25); - } - return out; -} - function isEmptyParagraph(node: any): boolean { return ( !!node && @@ -98,6 +72,36 @@ function emptyDefinition(id: string): any { }; } +/** + * Order-insensitive deep equality over plain JSON (objects/arrays/primitives). + * Used to detect an already-canonical footnotesList so its physical position is + * preserved (placement parity with the live plugin). + */ +function deepEqualJson(a: any, b: any): boolean { + if (a === b) return true; + if (a == null || b == null || typeof a !== typeof b) return false; + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (!deepEqualJson(a[i], b[i])) return false; + } + return true; + } + if (typeof a === "object") { + const ka = Object.keys(a); + const kb = Object.keys(b); + if (ka.length !== kb.length) return false; + for (const k of ka) { + if (!Object.prototype.hasOwnProperty.call(b, k)) return false; + if (!deepEqualJson(a[k], b[k])) return false; + } + return true; + } + return false; +} + /** * Canonicalize footnotes in a ProseMirror-JSON document. See the file header and * the editor-ext twin for the full contract. Pure (deep-clones input, @@ -113,131 +117,72 @@ export function canonicalizeFootnotes(doc: T): T { } const out = cloneJson(doc) as any; + // 1) Distinct reference ids in document order (deep — refs can live in + // callouts, tables, list items, ...). The ordering/numbering truth. const referenceIds: string[] = []; collectReferenceIds(out, referenceIds, new Set()); + // 2) Every definition node in document order (deep). const defNodes: any[] = []; collectDefinitions(out, defNodes); - const taken = new Set(referenceIds); + // 3) First definition per id wins; later duplicates carry the SAME id, so they + // cannot be referenced separately and would be orphans — they are dropped. + const defById = new Map(); for (const d of defNodes) { const id = d?.attrs?.id; - if (id) taken.add(id); - } - const occurrenceOf = new Map(); - const seenDefIds = new Set(); - const defByFinalId = new Map(); - for (const d of defNodes) { - const origId = d?.attrs?.id; - if (!origId) continue; - if (!seenDefIds.has(origId)) { - seenDefIds.add(origId); - defByFinalId.set(origId, d); - } else { - const next = (occurrenceOf.get(origId) ?? 1) + 1; - occurrenceOf.set(origId, next); - const newId = deriveFootnoteId(origId, next, taken); - taken.add(newId); - defByFinalId.set(newId, d); - } + if (id && !defById.has(id)) defById.set(id, d); } + // 4) Build the ordered definition list: one per referenced id, in REFERENCE + // order, reusing the existing node (shallow-copied, id normalized — `out` is + // already deep-cloned and the old lists are cut) or synthesizing an empty + // one. Definitions whose id is not referenced are orphans and never added. const orderedDefs: any[] = []; for (const id of referenceIds) { - const existing = defByFinalId.get(id); + const existing = defById.get(id); if (existing) { - const node = cloneJson(existing); - node.attrs = { ...(node.attrs ?? {}), id }; - orderedDefs.push(node); + orderedDefs.push({ + ...existing, + attrs: { ...(existing.attrs ?? {}), id }, + }); } else { orderedDefs.push(emptyDefinition(id)); } } - const top: any[] = out.content.filter( - (n: any) => !(n && n.type === FOOTNOTES_LIST_NAME), - ); - + // 5) No references -> there must be NO list at all. if (referenceIds.length === 0) { - out.content = top; + out.content = out.content.filter( + (n: any) => !(n && n.type === FOOTNOTES_LIST_NAME), + ); return out; } + // 6) Placement parity with the live plugin: when the document is ALREADY in the + // canonical single-list state, leave that list exactly where it sits rather + // than cutting and re-inserting it at the end (the plugin never repositions a + // sole correct list, so moving it would silently reorder any content that + // follows the list on the first write). + const topLevelLists = out.content.filter( + (n: any) => n && n.type === FOOTNOTES_LIST_NAME, + ); + if ( + topLevelLists.length === 1 && + defNodes.length === orderedDefs.length && + deepEqualJson(topLevelLists[0].content, orderedDefs) + ) { + return out; + } + + // 7) Otherwise rebuild: strip every footnotesList and re-insert exactly one + // after the last meaningful (non-empty paragraph) block. + const top: any[] = out.content.filter( + (n: any) => !(n && n.type === FOOTNOTES_LIST_NAME), + ); let insertAt = top.length; while (insertAt > 0 && isEmptyParagraph(top[insertAt - 1])) insertAt--; top.splice(insertAt, 0, { type: FOOTNOTES_LIST_NAME, content: orderedDefs }); out.content = top; return out; } - -/** - * Normalized content key for de-duplicating footnote DEFINITIONS by their text. - * - * Two definitions with the same key are the SAME footnote — so the inline - * authoring tool reuses one id (one number, one definition, several references) - * instead of minting a second definition. Key = plaintext (whitespace-collapsed, - * trimmed) PLUS a signature of the inline mark types in order, so two notes that - * read the same but differ in formatting (one bold, one plain) are NOT merged. - * Conservative: only an exact match merges. - */ -export function footnoteContentKey(defNode: any): string { - const parts: string[] = []; - const visit = (n: any): void => { - if (!n || typeof n !== "object") return; - if (n.type === "text" && typeof n.text === "string") { - const marks = Array.isArray(n.marks) - ? n.marks.map((m: any) => m?.type).filter(Boolean).sort().join(",") - : ""; - parts.push(`${n.text}${marks}`); - } - if (Array.isArray(n.content)) for (const c of n.content) visit(c); - }; - visit(defNode); - // Collapse the assembled text's whitespace and trim, keeping the mark - // signature attached so formatting differences still distinguish notes. - return parts - .join("") - .replace(/[ \t\r\n]+/g, " ") - .trim(); -} - -/** - * Build a footnoteDefinition node from inline ProseMirror nodes, keyed by id. - */ -export function makeFootnoteDefinition(id: string, inlineNodes: any[]): any { - const content = Array.isArray(inlineNodes) ? cloneJson(inlineNodes) : []; - return { - type: FOOTNOTE_DEFINITION_NAME, - attrs: { id }, - content: [{ type: "paragraph", content }], - }; -} - -/** - * Generate a uuidv7-style id (time-ordered), matching editor-ext's - * `generateFootnoteId`. Used for a genuinely-new inline footnote id. - */ -export function generateFootnoteId(): string { - const now = Date.now(); - const timeHex = now.toString(16).padStart(12, "0"); - const rand = (length: number) => { - let s = ""; - for (let i = 0; i < length; i++) - s += Math.floor(Math.random() * 16).toString(16); - return s; - }; - const versioned = "7" + rand(3); - const variantNibble = (8 + Math.floor(Math.random() * 4)).toString(16); - const variant = variantNibble + rand(3); - return ( - timeHex.slice(0, 8) + - "-" + - timeHex.slice(8, 12) + - "-" + - versioned + - "-" + - variant + - "-" + - rand(12) - ); -} diff --git a/packages/mcp/src/lib/transforms.ts b/packages/mcp/src/lib/transforms.ts index 5c595f86..65313d49 100644 --- a/packages/mcp/src/lib/transforms.ts +++ b/packages/mcp/src/lib/transforms.ts @@ -15,12 +15,12 @@ */ import { blockPlainText } from "./node-ops.js"; +import { canonicalizeFootnotes } from "./footnote-canonicalize.js"; import { - canonicalizeFootnotes, footnoteContentKey, makeFootnoteDefinition, generateFootnoteId, -} from "./footnote-canonicalize.js"; +} from "./footnote-authoring.js"; export { canonicalizeFootnotes } from "./footnote-canonicalize.js"; @@ -113,6 +113,30 @@ export function insertMarkerAfter( anchor: string, marker: string, opts: InsertMarkerOptions = {}, +): { doc: any; inserted: boolean } { + // A plain marker is a leading-space-padded unmarked text run. + return insertNodesAfterAnchor( + doc, + anchor, + () => [{ type: "text", text: " " + marker }], + opts, + ); +} + +/** + * Mark-safe insertion CORE: split the inline text run that holds the END of + * `anchor` (preserving the surrounding marks) and splice the nodes produced by + * `makeMiddle()` in at the split point. `insertMarkerAfter` (plain text marker) + * and `insertInlineFootnote` (a `footnoteReference` node) are both thin callers — + * the only difference is WHAT is inserted (a space-padded text run vs. a node + * that should hug the preceding word), which is exactly what `makeMiddle` + * decides. Operates on a clone; returns `{ doc, inserted }`. + */ +function insertNodesAfterAnchor( + doc: any, + anchor: string, + makeMiddle: () => any[], + opts: InsertMarkerOptions = {}, ): { doc: any; inserted: boolean } { const out = clone(doc); if (!isObject(out) || !Array.isArray(out.content) || !anchor) { @@ -174,8 +198,9 @@ export function insertMarkerAfter( if (before.length > 0) { parts.push({ ...n, text: before, marks: [...marks] }); } - // Marker is a PLAIN run: no marks copied. Leading space separates it. - parts.push({ type: "text", text: " " + marker }); + // The inserted nodes are caller-decided (a space-padded marker run, + // or a node that hugs the word). They carry no copied marks. + parts.push(...makeMiddle()); if (after.length > 0) { parts.push({ ...n, text: after, marks: [...marks] }); } @@ -587,9 +612,6 @@ export interface InsertInlineFootnoteResult { reused: boolean; } -/** A NUL-delimited sentinel that cannot occur in real prose. */ -const INLINE_FOOTNOTE_SENTINEL = "\u0000IFN\u0000"; - /** * AUTHOR-INLINE footnote insertion. The caller supplies WHERE (anchorText) and * WHAT (markdown text); numbering and the bottom list are derived server-side by @@ -603,10 +625,10 @@ const INLINE_FOOTNOTE_SENTINEL = "\u0000IFN\u0000"; * minted and a new definition added. Conservative — only an exact content match * merges. * - * Mechanics: the marker is inserted with the same mark-safe `insertMarkerAfter` - * split used elsewhere, via a sentinel that is then replaced by a real - * `footnoteReference` node (dropping the inserted leading space so the marker - * attaches to the preceding word). The whole document is then canonicalized. + * Mechanics: the `footnoteReference` node is inserted DIRECTLY at the anchor via + * the same mark-safe split as `insertMarkerAfter` (the shared + * `insertNodesAfterAnchor` core), so it hugs the preceding word with no text + * sentinel round-trip. The whole document is then canonicalized. * * Operates on a clone of `doc`. When the anchor is not found, returns the input * unchanged with `inserted:false`. @@ -639,16 +661,18 @@ export function insertInlineFootnote( } if (footnoteId == null) footnoteId = generateFootnoteId(); - // Insert a sentinel marker after the anchor (mark-safe split). - const r = insertMarkerAfter(doc, (opts.anchorText ?? "").trimEnd(), INLINE_FOOTNOTE_SENTINEL); + // Insert the footnoteReference node directly after the anchor (mark-safe + // split); it hugs the preceding word with no leading space. + const r = insertNodesAfterAnchor( + doc, + (opts.anchorText ?? "").trimEnd(), + () => [{ type: "footnoteReference", attrs: { id: footnoteId } }], + ); if (!r.inserted) { return { doc: clone(doc), inserted: false, footnoteId, reused }; } let working = r.doc; - // Replace the sentinel run with a real footnoteReference node. - replaceSentinelWithReference(working, footnoteId); - // Add a NEW definition (canonicalize will order/place it); a reused id needs // no new definition (the existing one is shared). if (!reused) { @@ -660,47 +684,6 @@ export function insertInlineFootnote( return { doc: working, inserted: true, footnoteId, reused }; } -/** - * Replace the lone sentinel text run (created by insertMarkerAfter as - * `" " + sentinel`) with a footnoteReference node, dropping the leading space so - * the marker attaches to the preceding word. Mutates `doc` in place. - */ -function replaceSentinelWithReference(doc: any, footnoteId: string): void { - let done = false; - const visit = (container: any): void => { - if (done || !isObject(container) || !Array.isArray(container.content)) return; - const arr = container.content; - for (let i = 0; i < arr.length; i++) { - const n = arr[i]; - if ( - isObject(n) && - n.type === "text" && - typeof n.text === "string" && - n.text.includes(INLINE_FOOTNOTE_SENTINEL) - ) { - const idx = n.text.indexOf(INLINE_FOOTNOTE_SENTINEL); - // Text before the sentinel, with a single trailing space (the one - // insertMarkerAfter prepended) stripped so the ref hugs the word. - const before = n.text.slice(0, idx).replace(/ $/, ""); - const after = n.text.slice(idx + INLINE_FOOTNOTE_SENTINEL.length); - const marks = Array.isArray(n.marks) ? n.marks : []; - const parts: any[] = []; - if (before.length > 0) parts.push({ ...n, text: before, marks: [...marks] }); - parts.push({ type: "footnoteReference", attrs: { id: footnoteId } }); - if (after.length > 0) parts.push({ ...n, text: after, marks: [...marks] }); - arr.splice(i, 1, ...parts); - done = true; - return; - } - } - for (const child of arr) { - visit(child); - if (done) return; - } - }; - visit(doc); -} - /** * Append a definition node so the canonicalizer can order/place it: into the * first existing footnotesList, or a new trailing list when none exists. diff --git a/packages/mcp/test/mock/footnote-write.test.mjs b/packages/mcp/test/mock/footnote-write.test.mjs new file mode 100644 index 00000000..d013d7a3 --- /dev/null +++ b/packages/mcp/test/mock/footnote-write.test.mjs @@ -0,0 +1,152 @@ +// Mock-HTTP orchestration tests for the footnote WRITE wrappers on DocmostClient +// (issue #228): +// - insertFootnote (#11): the required-argument guards reject BEFORE any write, +// and never touch the collab/mutate path. +// - transformPage / docmost_transform (#13): the auto-canonicalize step +// (`result = canonicalizeFootnotes(raw)`) runs after every transform, so a +// transform that introduces an orphan footnote definition is silently tidied +// away — observable as an EMPTY diff in a dryRun preview. +// +// These stand a local http.createServer in for Docmost and only exercise plain +// HTTP routes (login / comments / pages.info), deliberately avoiding the live +// Hocuspocus collab WebSocket: the insertFootnote guards short-circuit before it, +// and docmost_transform's dryRun preview never opens it. The full collab mutate +// path (abort-via-throw on a missing anchor, the reused/message response branch) +// is covered at the pure level by insertInlineFootnote in +// test/unit/footnote-canonicalize.test.mjs. +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { DocmostClient } from "../../build/client.js"; + +function readBody(req) { + return new Promise((resolve) => { + let raw = ""; + req.on("data", (c) => (raw += c)); + req.on("end", () => resolve(raw)); + }); +} +function startServer(handler) { + return new Promise((resolve) => { + const server = http.createServer(handler); + server.listen(0, "127.0.0.1", () => { + const { port } = server.address(); + resolve({ server, baseURL: `http://127.0.0.1:${port}/api` }); + }); + }); +} +function sendJson(res, status, obj, extraHeaders = {}) { + res.writeHead(status, { "Content-Type": "application/json", ...extraHeaders }); + res.end(JSON.stringify(obj)); +} +const openServers = []; +async function spawn(handler) { + const { server, baseURL } = await startServer(handler); + openServers.push(server); + return { baseURL }; +} +after(async () => { + await Promise.all(openServers.map((s) => new Promise((r) => s.close(r)))); +}); + +const ref = (id) => ({ type: "footnoteReference", attrs: { id } }); +const def = (id, text) => ({ + type: "footnoteDefinition", + attrs: { id }, + content: [{ type: "paragraph", content: [{ type: "text", text }] }], +}); + +// --------------------------------------------------------------------------- +// #11 insertFootnote guards: missing anchorText / text reject and never write. +// --------------------------------------------------------------------------- +test("insertFootnote rejects a missing anchorText before any write", async () => { + const otherRoutes = []; + const { baseURL } = await spawn(async (req, res) => { + await readBody(req); + if (req.url === "/api/auth/login") { + return sendJson(res, 200, { success: true }, { + "Set-Cookie": "authToken=t; Path=/; HttpOnly", + }); + } + otherRoutes.push(req.url); + sendJson(res, 404, { message: "not found" }); + }); + const client = new DocmostClient(baseURL, "user@example.com", "pw"); + await assert.rejects( + () => client.insertFootnote("page-1", " ", "a note"), + /anchorText is required/i, + ); + assert.deepEqual(otherRoutes, [], "must not hit any write route"); +}); + +test("insertFootnote rejects an empty text before any write", async () => { + const otherRoutes = []; + const { baseURL } = await spawn(async (req, res) => { + await readBody(req); + if (req.url === "/api/auth/login") { + return sendJson(res, 200, { success: true }, { + "Set-Cookie": "authToken=t; Path=/; HttpOnly", + }); + } + otherRoutes.push(req.url); + sendJson(res, 404, { message: "not found" }); + }); + const client = new DocmostClient(baseURL, "user@example.com", "pw"); + await assert.rejects( + () => client.insertFootnote("page-1", "anchor", " "), + /text is required/i, + ); + assert.deepEqual(otherRoutes, [], "must not hit any write route"); +}); + +// --------------------------------------------------------------------------- +// #13 docmost_transform auto-canonicalization: a transform that adds an orphan +// footnote definition produces NO net change (the canonicalizer drops it), so a +// dryRun preview reports an empty diff. Without the auto-canonicalize step the +// orphan would survive and the diff would be non-empty. +// --------------------------------------------------------------------------- +test("transformPage dryRun auto-canonicalizes footnotes (orphan def is dropped)", async () => { + // A page already in canonical footnote state (refs b,a; defs b,a). + const pageContent = { + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: "x" }, ref("b"), ref("a")] }, + { type: "footnotesList", content: [def("b", "B"), def("a", "A")] }, + ], + }; + const { baseURL } = await spawn(async (req, res) => { + await readBody(req); + if (req.url === "/api/auth/login") { + return sendJson(res, 200, { success: true }, { + "Set-Cookie": "authToken=t; Path=/; HttpOnly", + }); + } + if (req.url === "/api/comments") { + return sendJson(res, 200, { data: { items: [], meta: { nextCursor: null } } }); + } + if (req.url === "/api/pages/info") { + return sendJson(res, 200, { + data: { id: "page-1", slugId: "s", title: "P", spaceId: "sp", content: pageContent }, + }); + } + sendJson(res, 404, { message: "not found" }); + }); + const client = new DocmostClient(baseURL, "user@example.com", "pw"); + + // The transform appends an ORPHAN definition (id "z", no matching reference). + const transformJs = `(doc) => { + const list = doc.content.find((n) => n.type === "footnotesList"); + list.content.push({ + type: "footnoteDefinition", + attrs: { id: "z" }, + content: [{ type: "paragraph", content: [{ type: "text", text: "orphan" }] }], + }); + return doc; + }`; + + const result = await client.transformPage("page-1", transformJs, { dryRun: true }); + assert.equal(result.pushed, false); + // Auto-canonicalize dropped the orphan, so the doc is unchanged => empty diff. + assert.equal(result.diff.summary.inserted, 0, "orphan def must be canonicalized away"); + assert.equal(result.diff.summary.deleted, 0); +}); diff --git a/packages/mcp/test/unit/footnote-canonicalize.test.mjs b/packages/mcp/test/unit/footnote-canonicalize.test.mjs index c2dd3005..d25a265b 100644 --- a/packages/mcp/test/unit/footnote-canonicalize.test.mjs +++ b/packages/mcp/test/unit/footnote-canonicalize.test.mjs @@ -1,10 +1,8 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { - canonicalizeFootnotes, - footnoteContentKey, -} from "../../build/lib/footnote-canonicalize.js"; +import { canonicalizeFootnotes } from "../../build/lib/footnote-canonicalize.js"; +import { footnoteContentKey } from "../../build/lib/footnote-authoring.js"; import { insertInlineFootnote } from "../../build/lib/transforms.js"; import { markdownToProseMirror } from "../../build/lib/collaboration.js"; diff --git a/packages/mcp/test/unit/footnote-corpus.mjs b/packages/mcp/test/unit/footnote-corpus.mjs new file mode 100644 index 00000000..3a213491 --- /dev/null +++ b/packages/mcp/test/unit/footnote-corpus.mjs @@ -0,0 +1,1164 @@ +// MIRROR (data only) of +// packages/editor-ext/src/lib/footnote/footnote-corpus.ts — keep the two in +// sync. Shared golden corpus for the footnote canonicalizer (issue #228): each +// case is { name, input, expected } where `expected` is exactly what +// `canonicalizeFootnotes(input)` must return. Running BOTH the editor-ext copy +// and this MCP mirror against the same corpus makes "the two pure copies behave +// identically" a checkable property without coupling the packages. +export const FOOTNOTE_CORPUS = [ + { + "name": "out-of-order defs ordered by first reference", + "input": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "x" + }, + { + "type": "footnoteReference", + "attrs": { + "id": "b" + } + }, + { + "type": "footnoteReference", + "attrs": { + "id": "a" + } + }, + { + "type": "footnoteReference", + "attrs": { + "id": "d" + } + }, + { + "type": "footnoteReference", + "attrs": { + "id": "c" + } + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "a" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "A" + } + ] + } + ] + }, + { + "type": "footnoteDefinition", + "attrs": { + "id": "c" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "C" + } + ] + } + ] + }, + { + "type": "footnoteDefinition", + "attrs": { + "id": "b" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "B" + } + ] + } + ] + }, + { + "type": "footnoteDefinition", + "attrs": { + "id": "d" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "D" + } + ] + } + ] + } + ] + } + ] + }, + "expected": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "x" + }, + { + "type": "footnoteReference", + "attrs": { + "id": "b" + } + }, + { + "type": "footnoteReference", + "attrs": { + "id": "a" + } + }, + { + "type": "footnoteReference", + "attrs": { + "id": "d" + } + }, + { + "type": "footnoteReference", + "attrs": { + "id": "c" + } + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "b" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "B" + } + ] + } + ] + }, + { + "type": "footnoteDefinition", + "attrs": { + "id": "a" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "A" + } + ] + } + ] + }, + { + "type": "footnoteDefinition", + "attrs": { + "id": "d" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "D" + } + ] + } + ] + }, + { + "type": "footnoteDefinition", + "attrs": { + "id": "c" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "C" + } + ] + } + ] + } + ] + } + ] + } + }, + { + "name": "orphan definition dropped", + "input": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "x" + }, + { + "type": "footnoteReference", + "attrs": { + "id": "a" + } + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "a" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "A" + } + ] + } + ] + }, + { + "type": "footnoteDefinition", + "attrs": { + "id": "orphan" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "O" + } + ] + } + ] + } + ] + } + ] + }, + "expected": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "x" + }, + { + "type": "footnoteReference", + "attrs": { + "id": "a" + } + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "a" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "A" + } + ] + } + ] + } + ] + } + ] + } + }, + { + "name": "no references removes the list", + "input": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "plain" + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "orphan" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "O" + } + ] + } + ] + } + ] + } + ] + }, + "expected": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "plain" + } + ] + } + ] + } + }, + { + "name": "reuse: repeated references collapse to one definition", + "input": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "footnoteReference", + "attrs": { + "id": "d" + } + }, + { + "type": "text", + "text": " a " + }, + { + "type": "footnoteReference", + "attrs": { + "id": "d" + } + }, + { + "type": "footnoteReference", + "attrs": { + "id": "d" + } + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "d" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "shared" + } + ] + } + ] + } + ] + } + ] + }, + "expected": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "footnoteReference", + "attrs": { + "id": "d" + } + }, + { + "type": "text", + "text": " a " + }, + { + "type": "footnoteReference", + "attrs": { + "id": "d" + } + }, + { + "type": "footnoteReference", + "attrs": { + "id": "d" + } + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "d" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "shared" + } + ] + } + ] + } + ] + } + ] + } + }, + { + "name": "duplicate definitions: first wins", + "input": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "x" + }, + { + "type": "footnoteReference", + "attrs": { + "id": "d" + } + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "d" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "first" + } + ] + } + ] + }, + { + "type": "footnoteDefinition", + "attrs": { + "id": "d" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "second" + } + ] + } + ] + }, + { + "type": "footnoteDefinition", + "attrs": { + "id": "d" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "third" + } + ] + } + ] + } + ] + } + ] + }, + "expected": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "x" + }, + { + "type": "footnoteReference", + "attrs": { + "id": "d" + } + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "d" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "first" + } + ] + } + ] + } + ] + } + ] + } + }, + { + "name": "synthesizes an empty definition for a reference with none", + "input": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "x" + }, + { + "type": "footnoteReference", + "attrs": { + "id": "missing" + } + } + ] + } + ] + }, + "expected": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "x" + }, + { + "type": "footnoteReference", + "attrs": { + "id": "missing" + } + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "missing" + }, + "content": [ + { + "type": "paragraph" + } + ] + } + ] + } + ] + } + }, + { + "name": "merges multiple footnotesList nodes into one", + "input": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "a" + }, + { + "type": "footnoteReference", + "attrs": { + "id": "x" + } + }, + { + "type": "footnoteReference", + "attrs": { + "id": "y" + } + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "x" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "X" + } + ] + } + ] + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "tail" + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "y" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Y" + } + ] + } + ] + } + ] + } + ] + }, + "expected": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "a" + }, + { + "type": "footnoteReference", + "attrs": { + "id": "x" + } + }, + { + "type": "footnoteReference", + "attrs": { + "id": "y" + } + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "tail" + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "x" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "X" + } + ] + } + ] + }, + { + "type": "footnoteDefinition", + "attrs": { + "id": "y" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Y" + } + ] + } + ] + } + ] + } + ] + } + }, + { + "name": "single canonical list before a trailing empty paragraph stays put", + "input": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "x" + }, + { + "type": "footnoteReference", + "attrs": { + "id": "a" + } + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "a" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "A" + } + ] + } + ] + } + ] + }, + { + "type": "paragraph" + } + ] + }, + "expected": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "x" + }, + { + "type": "footnoteReference", + "attrs": { + "id": "a" + } + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "a" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "A" + } + ] + } + ] + } + ] + }, + { + "type": "paragraph" + } + ] + } + }, + { + "name": "single canonical list with NON-EMPTY content after it is NOT moved (plugin parity)", + "input": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "x" + }, + { + "type": "footnoteReference", + "attrs": { + "id": "a" + } + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "a" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "A" + } + ] + } + ] + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "epilogue text" + } + ] + } + ] + }, + "expected": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "x" + }, + { + "type": "footnoteReference", + "attrs": { + "id": "a" + } + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "a" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "A" + } + ] + } + ] + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "epilogue text" + } + ] + } + ] + } + }, + { + "name": "reference inside a nested container (callout) is collected", + "input": { + "type": "doc", + "content": [ + { + "type": "callout", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "see " + }, + { + "type": "footnoteReference", + "attrs": { + "id": "n" + } + } + ] + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "n" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "note" + } + ] + } + ] + } + ] + } + ] + }, + "expected": { + "type": "doc", + "content": [ + { + "type": "callout", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "see " + }, + { + "type": "footnoteReference", + "attrs": { + "id": "n" + } + } + ] + } + ] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "n" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "note" + } + ] + } + ] + } + ] + } + ] + } + }, + { + "name": "no footnotes at all is unchanged", + "input": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "just text" + } + ] + } + ] + }, + "expected": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "just text" + } + ] + } + ] + } + } +]; diff --git a/packages/mcp/test/unit/footnote-corpus.test.mjs b/packages/mcp/test/unit/footnote-corpus.test.mjs new file mode 100644 index 00000000..c58fa02a --- /dev/null +++ b/packages/mcp/test/unit/footnote-corpus.test.mjs @@ -0,0 +1,19 @@ +// Runs the MCP mirror of `canonicalizeFootnotes` against the SHARED golden +// corpus (the same { input -> expected } cases the editor-ext copy is tested +// against in footnote-canonicalize.test.ts). Pinning identical expected outputs +// in both suites makes "the editor-ext copy and the MCP mirror behave +// identically" a checkable property without coupling the two packages +// (architecture item A). The corpus data is mirrored in footnote-corpus.mjs. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { canonicalizeFootnotes } from "../../build/lib/footnote-canonicalize.js"; +import { FOOTNOTE_CORPUS } from "./footnote-corpus.mjs"; + +for (const { name, input, expected } of FOOTNOTE_CORPUS) { + test(`shared corpus (MCP mirror): ${name}`, () => { + assert.deepEqual(canonicalizeFootnotes(input), expected); + // Idempotent on the corpus too. + assert.deepEqual(canonicalizeFootnotes(expected), expected); + }); +} From a77a0bc92b26b4be73797d3dc15eaa2e22a594d0 Mon Sep 17 00:00:00 2001 From: a Date: Sat, 27 Jun 2026 21:41:10 +0300 Subject: [PATCH 4/7] =?UTF-8?q?fix(footnotes):=20re-review=20#232=20?= =?UTF-8?q?=E2=80=94=20refuse=20footnoteRef=20into=20codeBlock/definition,?= =?UTF-8?q?=20deep-strip=20nested=20lists,=20docs=20+=20cross-copy=20guard?= =?UTF-8?q?=20(#228)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Must-fix: - REAL BUG: insertInlineFootnote could splice a footnoteReference (inline atom) into a codeBlock or an existing footnoteDefinition, persisting a schema-invalid doc (insert_footnote skips validateDocStructure). Now the search is bounded to the BODY (before the first footnotesList) and the insertNodesAfterAnchor core refuses textblocks that can't hold the atom (codeBlock); when the only match is in such a place the insert returns inserted:false and the write aborts cleanly. Reachable via docmost_transform too. Added codeBlock / definition / fall-through tests. - Fixed the deepEqualJson doc comment in both copies: arrays are order-SENSITIVE (correctness depends on it), only object keys are order-insensitive. - README.ru.md MCP tool count 38 -> 39 (lines 36/47/63), matching README.md/AGENTS. - CHANGELOG [Unreleased] Added entry for insert_footnote + server-side footnote canonicalization on non-editor write paths (#228). Suggestions: - canonicalize step 5/7 now strips footnotesList at ANY depth (both copies), so a schema-valid list nested in a callout/blockquote can't leave duplicate defs. - Exclude the test-only footnote-corpus.ts fixture from the editor-ext build (tsconfig), so it no longer ships in dist/. - Removed the duplicate manual canonicalize cases from the MCP unit test (the shared corpus covers them via full deepEqual); kept idempotence + immutability. - insertInlineFootnote dedup key now keys off the inline array directly (footnoteContentKey({ content: inline })) instead of a throwaway node. Tests / architecture: - New client-wrapper test (#9): overrides a small mutatePage seam to assert the not-found path throws and persists NOTHING, and the success path shapes footnoteId/reused/message/verify and writes the right content. Fixed the misleading comment in footnote-write.test.mjs. - B: cross-copy corpus parity guard test (loads both corpora, asserts deep-equal) so a typo in one copy can't pass both suites green. - A: declined — the full-vs-fragment decision lives at the call site, so a prepareDocForPersist wrapper would be a bare alias for canonicalizeFootnotes; kept the existing per-call-site comments instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 14 +++ README.ru.md | 6 +- .../src/lib/footnote/footnote-canonicalize.ts | 38 ++++--- packages/editor-ext/tsconfig.json | 8 +- packages/mcp/build/client.js | 14 ++- .../mcp/build/lib/footnote-canonicalize.js | 29 +++-- packages/mcp/build/lib/transforms.js | 36 ++++++- packages/mcp/src/client.ts | 20 +++- packages/mcp/src/lib/footnote-canonicalize.ts | 34 +++--- packages/mcp/src/lib/transforms.ts | 48 ++++++++- .../mcp/test/mock/footnote-write.test.mjs | 9 +- .../mock/insert-footnote-wrapper.test.mjs | 100 +++++++++++++++++ .../test/unit/footnote-canonicalize.test.mjs | 101 ++++++++++-------- .../test/unit/footnote-corpus-parity.test.mjs | 49 +++++++++ 14 files changed, 410 insertions(+), 96 deletions(-) create mode 100644 packages/mcp/test/mock/insert-footnote-wrapper.test.mjs create mode 100644 packages/mcp/test/unit/footnote-corpus-parity.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a46c61b8..840f7cda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `AI_AGENT_ROLES_CATALOG_URL` env var — an `http(s)://` base URL to the catalog's raw files; the image ships a per-branch default baked in CI, and it can be overridden at runtime via the env var (see `.env.example`). (#222) +- **Author footnotes inline from an agent, and deterministic server-side footnote + canonicalization on every non-editor write path.** A new MCP `insert_footnote` + tool places a footnote at a body anchor by content only — the agent supplies + WHERE (anchor text) and WHAT (markdown); the number and the bottom + `footnotesList` are derived server-side, so an agent can never assign a number, + edit the list, or desync, and a same-content note reuses one definition. Under + the hood, the editor's footnote-integrity invariant (one trailing list, + numbering by first reference, no orphans/duplicates, no raw `[^id]`) is now + enforced as a pure `canonicalizeFootnotes(doc)` on the write paths that bypass + the editor's plugins: server markdown/HTML import, `PageService` create and + full-document (`replace`) updates, the client markdown paste, and the MCP + `markdownToProseMirror` / `update_page_json` / `docmost_transform` / + `insert_footnote` paths. It is idempotent (a no-op once canonical) and is + deliberately NOT applied to append/prepend fragments. (#228) ### Fixed diff --git a/README.ru.md b/README.ru.md index 132ba442..ca980d31 100644 --- a/README.ru.md +++ b/README.ru.md @@ -33,7 +33,7 @@ | --- | --- | | **Удалён EE-код** | Вырезан весь код Enterprise-редакции на клиенте и сервере; это чистая community/AGPL-сборка без лицензионных проверок. | | **Резолв комментариев** | Переписан с нуля как community-функция (резолв / переоткрытие с вкладками «Открытые» / «Решённые»). EE-код не используется, доступно любому, кто может комментировать. | -| **Встроенный MCP-сервер** | Community MCP-сервер (`@docmost/mcp`, 38 инструментов) отдаётся по HTTP на `/mcp` — без enterprise-лицензии. Заменяет удалённый лицензируемый EE MCP. | +| **Встроенный MCP-сервер** | Community MCP-сервер (`@docmost/mcp`, 39 инструментов) отдаётся по HTTP на `/mcp` — без enterprise-лицензии. Заменяет удалённый лицензируемый EE MCP. | | **Чат с AI-агентом** | Встроенный чат с AI-агентом по содержимому вики, написанный с нуля как community-функция — без enterprise-лицензии. Агент читает и редактирует страницы от вашего имени (в рамках ваших прав), с полнотекстовым + векторным (RAG) поиском и опциональным доступом в интернет через внешние MCP-серверы. | | **Ребрендинг** | Логотип / название приложения изменены с *Docmost* на *Gitmost*. | | **Компактное дерево страниц** | Отступ дерева страниц по умолчанию уменьшен с 16px до 8px на уровень вложенности. | @@ -44,7 +44,7 @@ В Gitmost есть **наш собственный MCP-сервер** — [docmost-mcp](https://github.com/vvzvlad/docmost-mcp), который мы написали сами, — **встроенный прямо в приложение** и доступный на `/mcp`. Он даёт -**38 agent-native инструментов**: точечное редактирование по блокам (patch / insert / delete +**39 agent-native инструментов**: точечное редактирование по блокам (patch / insert / delete по id), find/replace с сохранением структуры, скриптовые трансформации `(doc) => doc` с предпросмотром диффа, структурное редактирование таблиц, история версий с диффом / восстановлением, комментарии, изображения и ссылки на шаринг — всё применяется через слой @@ -60,7 +60,7 @@ real-time-коллаборации Docmost, поэтому запись нико | | **`/mcp` в Gitmost (наш docmost-mcp)** | Родной MCP у Docmost | | --- | :---: | :---: | | **Enterprise-лицензия** | Не нужна | Нужна | -| **Инструменты** | 38, agent-native | Примитивные (Markdown, CRUD страниц, замена целиком) | +| **Инструменты** | 39, agent-native | Примитивные (Markdown, CRUD страниц, замена целиком) | | **Правки по блокам / find-replace / скриптовые трансформации** | ✅ | — | | **Структурное редактирование таблиц, дифф / восстановление версий** | ✅ | — | | **Комментарии, изображения, ссылки на шаринг** | ✅ | — | diff --git a/packages/editor-ext/src/lib/footnote/footnote-canonicalize.ts b/packages/editor-ext/src/lib/footnote/footnote-canonicalize.ts index db543519..3d52ea5f 100644 --- a/packages/editor-ext/src/lib/footnote/footnote-canonicalize.ts +++ b/packages/editor-ext/src/lib/footnote/footnote-canonicalize.ts @@ -119,11 +119,9 @@ export function canonicalizeFootnotes(doc: T): T { } } - // 5) No references -> there must be NO list at all. + // 5) No references -> there must be NO list at all (at any depth). if (referenceIds.length === 0) { - out.content = out.content.filter( - (n: any) => !(n && n.type === FOOTNOTES_LIST_NAME), - ); + stripFootnotesListsDeep(out); return out; } @@ -147,13 +145,15 @@ export function canonicalizeFootnotes(doc: T): T { return out; } - // 7) Otherwise rebuild: strip every footnotesList and re-insert exactly one - // after the last meaningful (non-empty paragraph) block, so it coexists with - // a trailing-node empty paragraph. This both repairs a non-canonical doc and - // (in the import case) physically reorders the list into reference order. - const top: any[] = out.content.filter( - (n: any) => !(n && n.type === FOOTNOTES_LIST_NAME), - ); + // 7) Otherwise rebuild: strip every footnotesList at ANY depth (collectDefinitions + // gathers defs recursively, so a list nested in a callout/blockquote would + // otherwise have its defs copied into the new list while the original + // survives — duplicates) and re-insert exactly one after the last meaningful + // (non-empty paragraph) top-level block, so it coexists with a trailing-node + // empty paragraph. This both repairs a non-canonical doc and (in the import + // case) physically reorders the list into reference order. + stripFootnotesListsDeep(out); + const top: any[] = out.content; let insertAt = top.length; while (insertAt > 0 && isEmptyParagraph(top[insertAt - 1])) insertAt--; top.splice(insertAt, 0, { type: FOOTNOTES_LIST_NAME, content: orderedDefs }); @@ -161,10 +161,20 @@ export function canonicalizeFootnotes(doc: T): T { return out; } +/** Remove every `footnotesList` node at ANY depth (mutates the given clone). */ +function stripFootnotesListsDeep(node: any): void { + if (!node || typeof node !== 'object' || !Array.isArray(node.content)) return; + node.content = node.content.filter( + (c: any) => !(c && c.type === FOOTNOTES_LIST_NAME), + ); + for (const child of node.content) stripFootnotesListsDeep(child); +} + /** - * Order-insensitive deep equality over plain JSON (objects/arrays/primitives). - * Used to detect an already-canonical footnotesList so its physical position is - * preserved (placement parity with the live plugin). + * Deep equality over plain JSON: arrays are compared POSITIONALLY + * (order-SENSITIVE), object keys order-insensitively. The array order-sensitivity + * is required for correctness here — a reordered `footnotesList.content` must + * compare UNEQUAL so the canonical rebuild fires instead of leaving it in place. */ function deepEqualJson(a: any, b: any): boolean { if (a === b) return true; diff --git a/packages/editor-ext/tsconfig.json b/packages/editor-ext/tsconfig.json index a4ad0d72..5fcc2435 100644 --- a/packages/editor-ext/tsconfig.json +++ b/packages/editor-ext/tsconfig.json @@ -22,5 +22,11 @@ "noFallthroughCasesInSwitch": false }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "src/**/*.spec.ts", "src/**/*.test.ts"] + "exclude": [ + "node_modules", + "dist", + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/lib/footnote/footnote-corpus.ts" + ] } diff --git a/packages/mcp/build/client.js b/packages/mcp/build/client.js index f9cf5a75..6eba7ea1 100644 --- a/packages/mcp/build/client.js +++ b/packages/mcp/build/client.js @@ -1107,9 +1107,12 @@ export class DocmostClient { } const collabToken = await this.getCollabTokenWithReauth(); let result = null; - const mutation = await mutatePageContent(pageId, collabToken, this.apiUrl, (liveDoc) => { + const mutation = await this.mutatePage(pageId, collabToken, this.apiUrl, (liveDoc) => { const r = insertInlineFootnote(liveDoc, { anchorText, text }); if (!r.inserted) { + // Abort the page-locked write by throwing: mutatePageContent does not + // persist when the transform throws, so a missing anchor leaves the + // page untouched (no partial write). throw new Error(`insert_footnote: anchor text not found: ${JSON.stringify(anchorText.slice(0, 80))}`); } result = { footnoteId: r.footnoteId, reused: r.reused }; @@ -1127,6 +1130,15 @@ export class DocmostClient { verify: mutation.verify, }; } + /** + * Page-locked write seam over collaboration.mutatePageContent. Production just + * delegates; it exists as an overridable method so the insert_footnote wrapper + * (transform abort-on-not-found + response shaping) can be unit-tested without + * standing up a live Hocuspocus collab socket. + */ + mutatePage(pageId, collabToken, apiUrl, transform) { + return mutatePageContent(pageId, collabToken, apiUrl, transform); + } /** * Export a page to a single self-contained Docmost-flavoured markdown file: * meta block + body (with inline comment anchors + diagrams) + comment diff --git a/packages/mcp/build/lib/footnote-canonicalize.js b/packages/mcp/build/lib/footnote-canonicalize.js index 92511ae1..b6673082 100644 --- a/packages/mcp/build/lib/footnote-canonicalize.js +++ b/packages/mcp/build/lib/footnote-canonicalize.js @@ -70,9 +70,10 @@ function emptyDefinition(id) { }; } /** - * Order-insensitive deep equality over plain JSON (objects/arrays/primitives). - * Used to detect an already-canonical footnotesList so its physical position is - * preserved (placement parity with the live plugin). + * Deep equality over plain JSON: arrays are compared POSITIONALLY + * (order-SENSITIVE), object keys order-insensitively. The array order-sensitivity + * is required for correctness here — a reordered `footnotesList.content` must + * compare UNEQUAL so the canonical rebuild fires instead of leaving it in place. */ function deepEqualJson(a, b) { if (a === b) @@ -148,9 +149,9 @@ export function canonicalizeFootnotes(doc) { orderedDefs.push(emptyDefinition(id)); } } - // 5) No references -> there must be NO list at all. + // 5) No references -> there must be NO list at all (at any depth). if (referenceIds.length === 0) { - out.content = out.content.filter((n) => !(n && n.type === FOOTNOTES_LIST_NAME)); + stripFootnotesListsDeep(out); return out; } // 6) Placement parity with the live plugin: when the document is ALREADY in the @@ -164,9 +165,13 @@ export function canonicalizeFootnotes(doc) { deepEqualJson(topLevelLists[0].content, orderedDefs)) { return out; } - // 7) Otherwise rebuild: strip every footnotesList and re-insert exactly one - // after the last meaningful (non-empty paragraph) block. - const top = out.content.filter((n) => !(n && n.type === FOOTNOTES_LIST_NAME)); + // 7) Otherwise rebuild: strip every footnotesList at ANY depth (collectDefinitions + // gathers defs recursively, so a list nested in a callout/blockquote would + // otherwise have its defs copied into the new list while the original + // survives — duplicates) and re-insert exactly one after the last meaningful + // (non-empty paragraph) top-level block. + stripFootnotesListsDeep(out); + const top = out.content; let insertAt = top.length; while (insertAt > 0 && isEmptyParagraph(top[insertAt - 1])) insertAt--; @@ -174,3 +179,11 @@ export function canonicalizeFootnotes(doc) { out.content = top; return out; } +/** Remove every `footnotesList` node at ANY depth (mutates the given clone). */ +function stripFootnotesListsDeep(node) { + if (!node || typeof node !== "object" || !Array.isArray(node.content)) + return; + node.content = node.content.filter((c) => !(c && c.type === FOOTNOTES_LIST_NAME)); + for (const child of node.content) + stripFootnotesListsDeep(child); +} diff --git a/packages/mcp/build/lib/transforms.js b/packages/mcp/build/lib/transforms.js index ff5862a6..9c5ecb7e 100644 --- a/packages/mcp/build/lib/transforms.js +++ b/packages/mcp/build/lib/transforms.js @@ -67,6 +67,15 @@ export function getList(doc, predicate) { }); return found; } +/** + * Textblocks that hold raw text but do NOT accept inline atom nodes. A + * `footnoteReference` is `group:"inline", atom:true`; `codeBlock` is + * `content:"text*"` (text only), so splicing a footnoteReference into it yields + * an invalid document. (paragraph/heading/detailsSummary are `inline*` and DO + * accept it; footnote definitions live inside a footnotesList which the + * footnote inserter excludes via `beforeBlock`.) + */ +const INLINE_ATOM_FORBIDDEN_BLOCKS = new Set(["codeBlock"]); /** * Insert `marker` as a PLAIN (unmarked) text run right after the first * occurrence of `anchor`. @@ -131,6 +140,14 @@ function insertNodesAfterAnchor(doc, anchor, makeMiddle, opts = {}) { // Detect whether this array is an inline array (contains text nodes). const hasText = inline.some((n) => isObject(n) && n.type === "text"); if (hasText) { + // Refuse a textblock whose content spec cannot hold the inserted nodes + // (e.g. a codeBlock for an inline atom). Keep `offset` aligned for any + // sibling textblocks in this same block, then bail so the search falls + // through to the next candidate block. + if (opts.forbidBlockTypes && opts.forbidBlockTypes.has(container.type)) { + offset += blockPlainText(container).length; + return; + } for (let i = 0; i < inline.length; i++) { const n = inline[i]; const len = isObject(n) ? blockPlainText(n).length : 0; @@ -511,7 +528,9 @@ export function commentsToFootnotes(doc, comments, opts = {}) { */ export function insertInlineFootnote(doc, opts) { const inline = mdToInlineNodes(opts.text ?? ""); - const key = footnoteContentKey(makeFootnoteDefinition("", inline)); + // footnoteContentKey only reads `.content`, so key off the inline array + // directly instead of building a throwaway definition node. + const key = footnoteContentKey({ content: inline }); // Content dedup: reuse an existing definition's id when its key matches. let footnoteId = null; let reused = false; @@ -532,8 +551,19 @@ export function insertInlineFootnote(doc, opts) { if (footnoteId == null) footnoteId = generateFootnoteId(); // Insert the footnoteReference node directly after the anchor (mark-safe - // split); it hugs the preceding word with no leading space. - const r = insertNodesAfterAnchor(doc, (opts.anchorText ?? "").trimEnd(), () => [{ type: "footnoteReference", attrs: { id: footnoteId } }]); + // split); it hugs the preceding word with no leading space. The search is + // bounded to the BODY (before the first footnotesList) and refuses codeBlocks, + // so the inline atom can never be spliced into a footnote definition or a code + // block — which would persist a schema-invalid doc (insert_footnote skips + // validateDocStructure). When the only match is in such a place the insert is + // refused and the write aborts cleanly (inserted:false). + const listIdx = Array.isArray(doc?.content) + ? doc.content.findIndex((n) => isObject(n) && n.type === "footnotesList") + : -1; + const r = insertNodesAfterAnchor(doc, (opts.anchorText ?? "").trimEnd(), () => [{ type: "footnoteReference", attrs: { id: footnoteId } }], { + ...(listIdx >= 0 ? { beforeBlock: listIdx } : {}), + forbidBlockTypes: INLINE_ATOM_FORBIDDEN_BLOCKS, + }); if (!r.inserted) { return { doc: clone(doc), inserted: false, footnoteId, reused }; } diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index 9169237d..2b449924 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -1399,13 +1399,16 @@ export class DocmostClient { } const collabToken = await this.getCollabTokenWithReauth(); let result: { footnoteId: string; reused: boolean } | null = null; - const mutation = await mutatePageContent( + const mutation = await this.mutatePage( pageId, collabToken, this.apiUrl, (liveDoc: any) => { const r = insertInlineFootnote(liveDoc, { anchorText, text }); if (!r.inserted) { + // Abort the page-locked write by throwing: mutatePageContent does not + // persist when the transform throws, so a missing anchor leaves the + // page untouched (no partial write). throw new Error( `insert_footnote: anchor text not found: ${JSON.stringify( anchorText.slice(0, 80), @@ -1429,6 +1432,21 @@ export class DocmostClient { }; } + /** + * Page-locked write seam over collaboration.mutatePageContent. Production just + * delegates; it exists as an overridable method so the insert_footnote wrapper + * (transform abort-on-not-found + response shaping) can be unit-tested without + * standing up a live Hocuspocus collab socket. + */ + protected mutatePage( + pageId: string, + collabToken: string, + apiUrl: string, + transform: (doc: any) => any, + ): Promise<{ doc?: any; verify?: any }> { + return mutatePageContent(pageId, collabToken, apiUrl, transform); + } + /** * Export a page to a single self-contained Docmost-flavoured markdown file: * meta block + body (with inline comment anchors + diagrams) + comment diff --git a/packages/mcp/src/lib/footnote-canonicalize.ts b/packages/mcp/src/lib/footnote-canonicalize.ts index d5a4a257..1e544a92 100644 --- a/packages/mcp/src/lib/footnote-canonicalize.ts +++ b/packages/mcp/src/lib/footnote-canonicalize.ts @@ -73,9 +73,10 @@ function emptyDefinition(id: string): any { } /** - * Order-insensitive deep equality over plain JSON (objects/arrays/primitives). - * Used to detect an already-canonical footnotesList so its physical position is - * preserved (placement parity with the live plugin). + * Deep equality over plain JSON: arrays are compared POSITIONALLY + * (order-SENSITIVE), object keys order-insensitively. The array order-sensitivity + * is required for correctness here — a reordered `footnotesList.content` must + * compare UNEQUAL so the canonical rebuild fires instead of leaving it in place. */ function deepEqualJson(a: any, b: any): boolean { if (a === b) return true; @@ -151,11 +152,9 @@ export function canonicalizeFootnotes(doc: T): T { } } - // 5) No references -> there must be NO list at all. + // 5) No references -> there must be NO list at all (at any depth). if (referenceIds.length === 0) { - out.content = out.content.filter( - (n: any) => !(n && n.type === FOOTNOTES_LIST_NAME), - ); + stripFootnotesListsDeep(out); return out; } @@ -175,14 +174,25 @@ export function canonicalizeFootnotes(doc: T): T { return out; } - // 7) Otherwise rebuild: strip every footnotesList and re-insert exactly one - // after the last meaningful (non-empty paragraph) block. - const top: any[] = out.content.filter( - (n: any) => !(n && n.type === FOOTNOTES_LIST_NAME), - ); + // 7) Otherwise rebuild: strip every footnotesList at ANY depth (collectDefinitions + // gathers defs recursively, so a list nested in a callout/blockquote would + // otherwise have its defs copied into the new list while the original + // survives — duplicates) and re-insert exactly one after the last meaningful + // (non-empty paragraph) top-level block. + stripFootnotesListsDeep(out); + const top: any[] = out.content; let insertAt = top.length; while (insertAt > 0 && isEmptyParagraph(top[insertAt - 1])) insertAt--; top.splice(insertAt, 0, { type: FOOTNOTES_LIST_NAME, content: orderedDefs }); out.content = top; return out; } + +/** Remove every `footnotesList` node at ANY depth (mutates the given clone). */ +function stripFootnotesListsDeep(node: any): void { + if (!node || typeof node !== "object" || !Array.isArray(node.content)) return; + node.content = node.content.filter( + (c: any) => !(c && c.type === FOOTNOTES_LIST_NAME), + ); + for (const child of node.content) stripFootnotesListsDeep(child); +} diff --git a/packages/mcp/src/lib/transforms.ts b/packages/mcp/src/lib/transforms.ts index 65313d49..639f6d9e 100644 --- a/packages/mcp/src/lib/transforms.ts +++ b/packages/mcp/src/lib/transforms.ts @@ -81,15 +81,33 @@ export function getList( return found; } -/** Options for insertMarkerAfter. */ +/** Options for insertMarkerAfter / insertNodesAfterAnchor. */ export interface InsertMarkerOptions { /** * Limit the search to TOP-LEVEL blocks with index < beforeBlock. Used to keep * footnote markers in the body and out of the notes section. */ beforeBlock?: number; + /** + * Textblock node types that MUST NOT receive the inserted nodes. When the + * split point lands inside such a block it is refused (skipped), so an inline + * ATOM (e.g. footnoteReference) is never spliced into a block whose content + * spec forbids it — which would persist a schema-invalid doc. Plain-text + * markers leave this unset (text is valid inside a codeBlock). + */ + forbidBlockTypes?: ReadonlySet; } +/** + * Textblocks that hold raw text but do NOT accept inline atom nodes. A + * `footnoteReference` is `group:"inline", atom:true`; `codeBlock` is + * `content:"text*"` (text only), so splicing a footnoteReference into it yields + * an invalid document. (paragraph/heading/detailsSummary are `inline*` and DO + * accept it; footnote definitions live inside a footnotesList which the + * footnote inserter excludes via `beforeBlock`.) + */ +const INLINE_ATOM_FORBIDDEN_BLOCKS: ReadonlySet = new Set(["codeBlock"]); + /** * Insert `marker` as a PLAIN (unmarked) text run right after the first * occurrence of `anchor`. @@ -175,6 +193,14 @@ function insertNodesAfterAnchor( (n: any) => isObject(n) && n.type === "text", ); if (hasText) { + // Refuse a textblock whose content spec cannot hold the inserted nodes + // (e.g. a codeBlock for an inline atom). Keep `offset` aligned for any + // sibling textblocks in this same block, then bail so the search falls + // through to the next candidate block. + if (opts.forbidBlockTypes && opts.forbidBlockTypes.has(container.type)) { + offset += blockPlainText(container).length; + return; + } for (let i = 0; i < inline.length; i++) { const n = inline[i]; const len = isObject(n) ? blockPlainText(n).length : 0; @@ -638,7 +664,9 @@ export function insertInlineFootnote( opts: InsertInlineFootnoteOptions, ): InsertInlineFootnoteResult { const inline = mdToInlineNodes(opts.text ?? ""); - const key = footnoteContentKey(makeFootnoteDefinition("", inline)); + // footnoteContentKey only reads `.content`, so key off the inline array + // directly instead of building a throwaway definition node. + const key = footnoteContentKey({ content: inline }); // Content dedup: reuse an existing definition's id when its key matches. let footnoteId: string | null = null; @@ -662,11 +690,25 @@ export function insertInlineFootnote( if (footnoteId == null) footnoteId = generateFootnoteId(); // Insert the footnoteReference node directly after the anchor (mark-safe - // split); it hugs the preceding word with no leading space. + // split); it hugs the preceding word with no leading space. The search is + // bounded to the BODY (before the first footnotesList) and refuses codeBlocks, + // so the inline atom can never be spliced into a footnote definition or a code + // block — which would persist a schema-invalid doc (insert_footnote skips + // validateDocStructure). When the only match is in such a place the insert is + // refused and the write aborts cleanly (inserted:false). + const listIdx = Array.isArray(doc?.content) + ? doc.content.findIndex( + (n: any) => isObject(n) && n.type === "footnotesList", + ) + : -1; const r = insertNodesAfterAnchor( doc, (opts.anchorText ?? "").trimEnd(), () => [{ type: "footnoteReference", attrs: { id: footnoteId } }], + { + ...(listIdx >= 0 ? { beforeBlock: listIdx } : {}), + forbidBlockTypes: INLINE_ATOM_FORBIDDEN_BLOCKS, + }, ); if (!r.inserted) { return { doc: clone(doc), inserted: false, footnoteId, reused }; diff --git a/packages/mcp/test/mock/footnote-write.test.mjs b/packages/mcp/test/mock/footnote-write.test.mjs index d013d7a3..29196b39 100644 --- a/packages/mcp/test/mock/footnote-write.test.mjs +++ b/packages/mcp/test/mock/footnote-write.test.mjs @@ -10,10 +10,11 @@ // These stand a local http.createServer in for Docmost and only exercise plain // HTTP routes (login / comments / pages.info), deliberately avoiding the live // Hocuspocus collab WebSocket: the insertFootnote guards short-circuit before it, -// and docmost_transform's dryRun preview never opens it. The full collab mutate -// path (abort-via-throw on a missing anchor, the reused/message response branch) -// is covered at the pure level by insertInlineFootnote in -// test/unit/footnote-canonicalize.test.mjs. +// and docmost_transform's dryRun preview never opens it. The collab mutate path +// itself — abort-via-throw on a missing anchor with NO persisted write, and the +// reused-vs-new response shaping — is covered in +// test/mock/insert-footnote-wrapper.test.mjs (which overrides the mutatePage +// seam to drive the transform), not here. import { test, after } from "node:test"; import assert from "node:assert/strict"; import http from "node:http"; diff --git a/packages/mcp/test/mock/insert-footnote-wrapper.test.mjs b/packages/mcp/test/mock/insert-footnote-wrapper.test.mjs new file mode 100644 index 00000000..887806b7 --- /dev/null +++ b/packages/mcp/test/mock/insert-footnote-wrapper.test.mjs @@ -0,0 +1,100 @@ +// Wrapper tests for DocmostClient.insertFootnote (issue #228, review #11/#9): +// the page-locked write seam (mutatePage) is overridden so the wrapper's +// transform + response shaping can be exercised WITHOUT a live Hocuspocus collab +// socket. We assert the two guarantees that the pure insertInlineFootnote test +// can NOT prove on its own: +// - a missing anchor makes the transform throw "anchor text not found" and NO +// document is persisted (the no-partial-write guarantee), and +// - a success shapes footnoteId / reused / message / verify and writes a doc +// carrying the new reference + the derived single list. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { DocmostClient } from "../../build/client.js"; + +const para = (...c) => ({ type: "paragraph", content: c }); +const ref = (id) => ({ type: "footnoteReference", attrs: { id } }); +const def = (id, text) => ({ + type: "footnoteDefinition", + attrs: { id }, + content: [{ type: "paragraph", content: [{ type: "text", text }] }], +}); +const list = (...d) => ({ type: "footnotesList", content: d }); + +function findAll(node, type, acc = []) { + if (!node || typeof node !== "object") return acc; + if (node.type === type) acc.push(node); + if (Array.isArray(node.content)) for (const c of node.content) findAll(c, type, acc); + return acc; +} + +// A DocmostClient whose auth + page-locked write are stubbed; `mutatePage` +// mirrors collaboration.mutatePageContent (run the transform against a clone of +// the live doc; if it throws, persist NOTHING and rethrow). +function makeClient(liveDoc) { + const calls = { writes: [] }; + class TestClient extends DocmostClient { + async ensureAuthenticated() {} + async getCollabTokenWithReauth() { + return "collab-token"; + } + async mutatePage(pageId, token, apiUrl, transform) { + calls.pageId = pageId; + calls.token = token; + const newDoc = transform(structuredClone(liveDoc)); + calls.writes.push(newDoc); + return { doc: newDoc, verify: { ok: true, marker: "v" } }; + } + } + const client = new TestClient("http://127.0.0.1:1/api", "e@x.com", "pw"); + return { client, calls }; +} + +test("insertFootnote: anchor not found -> throws and persists nothing", async () => { + const { client, calls } = makeClient({ + type: "doc", + content: [para({ type: "text", text: "nothing to anchor on" })], + }); + await assert.rejects( + () => client.insertFootnote("p1", "ZZZ", "a note"), + /anchor text not found/i, + ); + assert.equal(calls.writes.length, 0, "no document may be persisted on a missing anchor"); +}); + +test("insertFootnote: success (new) writes a reference + derived list and shapes the response", async () => { + const { client, calls } = makeClient({ + type: "doc", + content: [para({ type: "text", text: "The sky is blue today." })], + }); + const res = await client.insertFootnote("p1", "blue", "Rayleigh scattering."); + assert.equal(res.success, true); + assert.equal(res.modified, true); + assert.equal(res.pageId, "p1"); + assert.equal(res.reused, false); + assert.equal(typeof res.footnoteId, "string"); + assert.ok(res.footnoteId.length > 0); + assert.equal(res.message, "Footnote inserted."); + assert.deepEqual(res.verify, { ok: true, marker: "v" }); + assert.equal(calls.writes.length, 1, "exactly one write persisted"); + assert.equal(findAll(calls.writes[0], "footnoteReference").length, 1); + assert.equal(findAll(calls.writes[0], "footnotesList").length, 1); + assert.equal(calls.pageId, "p1"); +}); + +test("insertFootnote: success (reused) reuses the existing definition and reports it", async () => { + const liveDoc = { + type: "doc", + content: [ + para({ type: "text", text: "Alpha and beta." }, ref("a")), + list(def("a", "shared note")), + ], + }; + const { client, calls } = makeClient(liveDoc); + const res = await client.insertFootnote("p1", "beta", "shared note"); + assert.equal(res.reused, true); + assert.equal(res.footnoteId, "a"); + assert.match(res.message, /reused an existing same-content definition/i); + // Still exactly one definition (the reused one), two references to it. + assert.equal(findAll(calls.writes[0], "footnoteDefinition").length, 1); + assert.equal(findAll(calls.writes[0], "footnoteReference").length, 2); +}); diff --git a/packages/mcp/test/unit/footnote-canonicalize.test.mjs b/packages/mcp/test/unit/footnote-canonicalize.test.mjs index d25a265b..c4b68ce5 100644 --- a/packages/mcp/test/unit/footnote-canonicalize.test.mjs +++ b/packages/mcp/test/unit/footnote-canonicalize.test.mjs @@ -28,52 +28,10 @@ const def = (id, text) => ({ const para = (...inline) => ({ type: "paragraph", content: inline }); const list = (...defs) => ({ type: "footnotesList", content: defs }); -test("canonicalize orders definitions by first reference (out-of-order -> 1..N)", () => { - const doc = { - type: "doc", - content: [ - para({ type: "text", text: "x" }, ref("b"), ref("a"), ref("d"), ref("c")), - list(def("a", "A"), def("c", "C"), def("b", "B"), def("d", "D")), - ], - }; - const out = canonicalizeFootnotes(doc); - assert.deepEqual(defIds(out), ["b", "a", "d", "c"]); - assert.equal(findAll(out, "footnotesList").length, 1); -}); - -test("canonicalize drops orphan definitions", () => { - const doc = { - type: "doc", - content: [ - para({ type: "text", text: "x" }, ref("a")), - list(def("a", "A"), def("orphan", "O")), - ], - }; - assert.deepEqual(defIds(canonicalizeFootnotes(doc)), ["a"]); -}); - -test("canonicalize: no references -> no list", () => { - const doc = { - type: "doc", - content: [para({ type: "text", text: "x" }), list(def("o", "O"))], - }; - const out = canonicalizeFootnotes(doc); - assert.equal(findAll(out, "footnotesList").length, 0); -}); - -test("canonicalize: duplicate definitions -> first wins, rest dropped", () => { - const doc = { - type: "doc", - content: [ - para({ type: "text", text: "x" }, ref("d")), - list(def("d", "first"), def("d", "second")), - ], - }; - const out = canonicalizeFootnotes(doc); - assert.deepEqual(defIds(out), ["d"]); - assert.match(JSON.stringify(out), /"first"/); - assert.doesNotMatch(JSON.stringify(out), /"second"/); -}); +// The ordering / orphan-drop / no-refs / duplicate-first-wins cases are covered +// (with full deepEqual on input -> expected) by the shared golden corpus in +// footnote-corpus.test.mjs; only the input-immutability and idempotence +// properties — which the corpus does not assert — are kept here. test("canonicalize is idempotent", () => { const doc = { @@ -181,6 +139,57 @@ test("insertInlineFootnote: anchor not found -> inserted:false, no write", () => assert.equal(findAll(r.doc, "footnoteReference").length, 0); }); +test("insertInlineFootnote: anchor ONLY inside a codeBlock -> refused (no invalid doc)", () => { + // A footnoteReference is an inline atom; codeBlock content is text-only, so + // splicing one in would persist a schema-invalid doc. The insert must refuse. + const doc = { + type: "doc", + content: [{ type: "codeBlock", content: [{ type: "text", text: "const blue = 1;" }] }], + }; + const r = insertInlineFootnote(doc, { anchorText: "blue", text: "Rayleigh." }); + assert.equal(r.inserted, false); + assert.equal(findAll(r.doc, "footnoteReference").length, 0); + assert.equal(findAll(r.doc, "footnotesList").length, 0); + // The codeBlock text is untouched. + assert.deepEqual(r.doc, doc); +}); + +test("insertInlineFootnote: anchor ONLY inside an existing footnote definition -> refused", () => { + // The anchor text lives in a definition (inside the footnotesList). The search + // is bounded to the BODY (before the first list), so it is not matched there + // and the insert is refused rather than nesting a reference in a definition. + const doc = { + type: "doc", + content: [ + para({ type: "text", text: "Hello world." }, ref("a")), + list(def("a", "the sky is blue")), + ], + }; + const r = insertInlineFootnote(doc, { anchorText: "sky", text: "note" }); + assert.equal(r.inserted, false); + // No EXTRA reference and still exactly one (the pre-existing) list/definition. + assert.equal(findAll(r.doc, "footnoteReference").length, 1); + assert.deepEqual(defIds(r.doc), ["a"]); +}); + +test("insertInlineFootnote: codeBlock match is skipped, a later body paragraph still anchors", () => { + // The anchor first appears in a codeBlock (refused) but also in a normal + // paragraph after it; the insert falls through to the valid block. + const doc = { + type: "doc", + content: [ + { type: "codeBlock", content: [{ type: "text", text: "let token = 1;" }] }, + para({ type: "text", text: "The token is rotated daily." }), + ], + }; + const r = insertInlineFootnote(doc, { anchorText: "token", text: "secret" }); + assert.equal(r.inserted, true); + // The reference landed in the paragraph, NOT the codeBlock. + const code = findAll(r.doc, "codeBlock")[0]; + assert.equal(findAll(code, "footnoteReference").length, 0); + assert.equal(findAll(r.doc, "footnoteReference").length, 1); +}); + test("markdown import: out-of-order definitions render as a reference-ordered list", async () => { // References appear b, a, c in the body; definitions are written in a, b, c // order (the import order). After canonicalization the bottom list follows diff --git a/packages/mcp/test/unit/footnote-corpus-parity.test.mjs b/packages/mcp/test/unit/footnote-corpus-parity.test.mjs new file mode 100644 index 00000000..5a944395 --- /dev/null +++ b/packages/mcp/test/unit/footnote-corpus-parity.test.mjs @@ -0,0 +1,49 @@ +// CI guard for architecture item B: the shared golden corpus is duplicated (the +// canonical TS copy in editor-ext + the MCP .mjs mirror), so a typo in one copy +// would otherwise pass BOTH per-package suites green while silently breaking the +// cross-copy invariant. This test loads BOTH copies and asserts they are +// deep-equal, turning "the two corpora stay identical" into a checked property. +// +// The editor-ext copy is a .ts module (not importable from node:test), so it is +// read as text and its array literal — which is pure JSON produced by +// JSON.stringify — is parsed out directly. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +import { FOOTNOTE_CORPUS as MCP_CORPUS } from "./footnote-corpus.mjs"; + +function loadEditorExtCorpus() { + const here = dirname(fileURLToPath(import.meta.url)); + const tsPath = resolve( + here, + "../../../editor-ext/src/lib/footnote/footnote-corpus.ts", + ); + const src = readFileSync(tsPath, "utf8"); + // The value is `export const FOOTNOTE_CORPUS: FootnoteCorpusCase[] = [ ... ];` + // where `[ ... ]` is strict JSON (JSON.stringify output). Slice from the + // assignment's opening bracket to the final closing bracket and parse. + const assignAt = src.indexOf("] = "); + assert.ok(assignAt >= 0, "could not locate the editor-ext corpus assignment"); + const jsonStart = src.indexOf("[", assignAt + 3); + const jsonEnd = src.lastIndexOf("]"); + assert.ok(jsonStart >= 0 && jsonEnd > jsonStart, "could not bound the corpus array"); + return JSON.parse(src.slice(jsonStart, jsonEnd + 1)); +} + +test("the editor-ext and MCP golden corpora are byte-for-byte identical", () => { + const editorExt = loadEditorExtCorpus(); + assert.ok(Array.isArray(editorExt) && editorExt.length > 0, "editor-ext corpus is non-empty"); + assert.equal( + MCP_CORPUS.length, + editorExt.length, + "the two corpora must have the same number of cases", + ); + assert.deepEqual( + MCP_CORPUS, + editorExt, + "the MCP corpus mirror has drifted from the editor-ext canonical copy — re-sync them", + ); +}); From 3fd66b4245b88b56c4ad810dfaddd38e6ee1a872 Mon Sep 17 00:00:00 2001 From: a Date: Sat, 27 Jun 2026 22:17:15 +0300 Subject: [PATCH 5/7] fix(footnotes): don't canonicalize comment bodies (data loss); canonicalize only page write paths (#228) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Must-fix (REAL DATA LOSS): - markdownToProseMirror is reused for COMMENT bodies (createComment/updateComment). It unconditionally canonicalized, so a comment carrying a standalone footnote definition ([^1]: text with no matching reference) had its whole footnotesList stripped (referenceIds.length===0 -> stripFootnotesListsDeep) — the text vanished. Fix: markdownToProseMirror no longer canonicalizes (content-preserving primitive); a new markdownToProseMirrorCanonical wraps it for the PAGE write paths (markdown import via importPageMarkdown, update_page markdown via updatePageContentRealtime). Comment callers keep the non-canonicalizing primitive. Updated the now-false header comment and added create/update-comment inline notes. Added collaboration tests: comment path PRESERVES a reference-less definition; page path still drops it AND still reorders real footnotes. Updated the page-import canonicalization test to use the canonical variant. Suggestions / architecture: - #2: collapsed transforms.footnoteDefinition onto the shared makeFootnoteDefinition factory (adds only the inner paragraph block id); kept the dependency direction transforms -> footnote-authoring (no circular import, mirror stays pure). - #3: confirmed docmost_transform auto-canonicalization is documented (inline comment, tool description, CHANGELOG) — no code change. - #4: copyPageContent is a FULL-document write (replacePageContent of a type:"doc"); added a defensive canonicalizeFootnotes pass (no-op on already-canonical source). - CHANGELOG entry refined to list the FULL-document write paths (incl. copy_page_content) and to state canonicalization is NOT applied to comment bodies. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 15 ++++--- packages/mcp/build/client.js | 20 ++++++--- packages/mcp/build/lib/collaboration.js | 41 +++++++++++------ packages/mcp/build/lib/transforms.js | 14 +++--- packages/mcp/src/client.ts | 20 +++++++-- packages/mcp/src/lib/collaboration.ts | 44 +++++++++++++------ packages/mcp/src/lib/transforms.ts | 14 +++--- packages/mcp/test/unit/collaboration.test.mjs | 36 +++++++++++++++ .../test/unit/footnote-canonicalize.test.mjs | 12 ++--- 9 files changed, 158 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 840f7cda..cb79f364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,12 +49,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 edit the list, or desync, and a same-content note reuses one definition. Under the hood, the editor's footnote-integrity invariant (one trailing list, numbering by first reference, no orphans/duplicates, no raw `[^id]`) is now - enforced as a pure `canonicalizeFootnotes(doc)` on the write paths that bypass - the editor's plugins: server markdown/HTML import, `PageService` create and - full-document (`replace`) updates, the client markdown paste, and the MCP - `markdownToProseMirror` / `update_page_json` / `docmost_transform` / - `insert_footnote` paths. It is idempotent (a no-op once canonical) and is - deliberately NOT applied to append/prepend fragments. (#228) + enforced as a pure `canonicalizeFootnotes(doc)` on the FULL-document write paths + that bypass the editor's plugins: server markdown/HTML import, `PageService` + create and full-document (`replace`) updates, the client markdown paste, and the + MCP markdown page-import / `update_page` (markdown) / `update_page_json` / + `docmost_transform` / `insert_footnote` / `copy_page_content` paths. It is + idempotent (a no-op once canonical) and is deliberately NOT applied to + append/prepend fragments, nor to COMMENT bodies — a comment may legitimately + contain a standalone footnote definition, which canonicalization would drop. + (#228) ### Fixed diff --git a/packages/mcp/build/client.js b/packages/mcp/build/client.js index 6eba7ea1..7b8fc936 100644 --- a/packages/mcp/build/client.js +++ b/packages/mcp/build/client.js @@ -7,7 +7,7 @@ import { TiptapTransformer } from "@hocuspocus/transformer"; import * as Y from "yjs"; import WebSocket from "ws"; import { convertProseMirrorToMarkdown } from "./lib/markdown-converter.js"; -import { updatePageContentRealtime, replacePageContent, markdownToProseMirror, mutatePageContent, buildCollabWsUrl, assertYjsEncodable, applyDocToFragment, } from "./lib/collaboration.js"; +import { updatePageContentRealtime, replacePageContent, markdownToProseMirror, markdownToProseMirrorCanonical, mutatePageContent, buildCollabWsUrl, assertYjsEncodable, applyDocToFragment, } from "./lib/collaboration.js"; import { footnoteWarningsField } from "./lib/footnote-analyze.js"; import { buildPageTree } from "./lib/tree.js"; import { serializeDocmostMarkdown, parseDocmostMarkdown, } from "./lib/markdown-document.js"; @@ -1180,7 +1180,8 @@ export class DocmostClient { async importPageMarkdown(pageId, fullMarkdown) { await this.ensureAuthenticated(); const { meta, body, comments } = parseDocmostMarkdown(fullMarkdown); - const doc = await markdownToProseMirror(body); + // PAGE import: canonicalize footnotes (see markdownToProseMirrorCanonical). + const doc = await markdownToProseMirrorCanonical(body); const collabToken = await this.getCollabTokenWithReauth(); const mutation = await replacePageContent(pageId, doc, collabToken, this.apiUrl); // Collect distinct comment ids that actually became comment marks in the doc. @@ -1260,13 +1261,18 @@ export class DocmostClient { // uses, so copying never lands a javascript:/data: href/src on the target // (parity with updatePageJson; harmless for already-stored source content). this.validateDocUrls(content); + // Defense-in-depth (#228): this is a FULL-document write, so canonicalize + // footnotes before copying — a no-op on already-canonical source content, but + // it guarantees a copy can never propagate a non-canonical footnote topology + // to the target (parity with the other full-doc write paths). + const canonical = canonicalizeFootnotes(content); const collabToken = await this.getCollabTokenWithReauth(); - const mutation = await replacePageContent(targetPageId, content, collabToken, this.apiUrl); + const mutation = await replacePageContent(targetPageId, canonical, collabToken, this.apiUrl); return { success: true, sourcePageId, targetPageId, - copiedNodes: content.content.length, + copiedNodes: canonical.content.length, verify: mutation.verify, }; } @@ -1673,7 +1679,10 @@ export class DocmostClient { } } } - // Convert through the full Docmost schema (consistent with page paths) + // Convert through the full Docmost schema. Deliberately the NON-canonicalizing + // variant: a comment body may carry a footnote definition with no matching + // reference, and canonicalization would drop it (data loss). See + // markdownToProseMirror vs markdownToProseMirrorCanonical. const jsonContent = await markdownToProseMirror(content); const payload = { pageId, @@ -1761,6 +1770,7 @@ export class DocmostClient { } async updateComment(commentId, content) { await this.ensureAuthenticated(); + // NON-canonicalizing on purpose (comment body — see createComment). const jsonContent = await markdownToProseMirror(content); await this.client.post("/comments/update", { commentId, diff --git a/packages/mcp/build/lib/collaboration.js b/packages/mcp/build/lib/collaboration.js index 67942c6d..4504b8d0 100644 --- a/packages/mcp/build/lib/collaboration.js +++ b/packages/mcp/build/lib/collaboration.js @@ -347,24 +347,37 @@ function extractFootnotes(markdown) { /** * Convert markdown to a ProseMirror doc using the full Docmost schema. * - * NOTE: besides the page-import write paths, this is also reused for comment - * bodies (createComment / updateComment). For an ordinary comment the - * canonicalize call below is a no-op (a comment carries no footnotes), so the - * reuse is safe; the only theoretical effect is if footnote markup were ever - * authored INSIDE a comment — a narrow case where canonicalizing the comment's - * own (self-contained) footnotes is still the correct behaviour. + * This conversion does NOT canonicalize footnotes — it is the shared, content- + * preserving primitive used by BOTH page write paths and COMMENT bodies + * (createComment / updateComment). Canonicalization MUST NOT run on a comment + * body: a comment may legitimately contain a footnote-definition line + * (`[^1]: text`) with no matching reference, and the canonicalizer drops a + * reference-less footnotesList — which would silently delete the comment's text. + * + * Page write paths that DO need the canonical footnote topology call + * `markdownToProseMirrorCanonical` instead (markdown import, update_page markdown + * path). Keep this function reference-loss-free. */ export async function markdownToProseMirror(markdownContent) { const withCallouts = await preprocessCallouts(markdownContent); const { body, section } = extractFootnotes(withCallouts); const html = (await marked.parse(body)) + section; const bridged = bridgeTaskLists(html); - const json = generateJSON(bridged, docmostExtensions); - // Canonicalize footnotes on EVERY import: the section above is built in - // definition order, but numbering is derived from REFERENCE order — so without - // this the bottom list renders out of order (`1, 4, 2, 3, …`). Idempotent, so - // it is a no-op when the footnotes are already canonical. - return canonicalizeFootnotes(json); + return generateJSON(bridged, docmostExtensions); +} +/** + * Page-write variant of `markdownToProseMirror`: converts markdown then enforces + * the canonical footnote topology. The footnote `section` markdown is emitted in + * DEFINITION order, but numbering derives from REFERENCE order, so without this + * the bottom list renders out of order (`1, 4, 2, 3, …`); orphan definitions and + * duplicate lists are also normalized. Idempotent — a no-op once canonical, and a + * no-op for footnote-free content. + * + * Use this ONLY for full-document PAGE writes (never for comment bodies, where it + * would drop a reference-less footnote definition — see `markdownToProseMirror`). + */ +export async function markdownToProseMirrorCanonical(markdownContent) { + return canonicalizeFootnotes(await markdownToProseMirror(markdownContent)); } /** * Build the collaboration WebSocket URL from an API base URL: @@ -723,6 +736,8 @@ export async function replacePageContent(pageId, prosemirrorDoc, collabToken, ba * Tables and :::callout::: blocks survive thanks to the full schema. */ export async function updatePageContentRealtime(pageId, markdownContent, collabToken, baseUrl) { - const tiptapJson = await markdownToProseMirror(markdownContent); + // PAGE write: canonicalize footnotes (markdown import builds the bottom list in + // definition order; numbering is reference-ordered). + const tiptapJson = await markdownToProseMirrorCanonical(markdownContent); return await mutatePageContent(pageId, collabToken, baseUrl, () => tiptapJson); } diff --git a/packages/mcp/build/lib/transforms.js b/packages/mcp/build/lib/transforms.js index 9c5ecb7e..935ff33d 100644 --- a/packages/mcp/build/lib/transforms.js +++ b/packages/mcp/build/lib/transforms.js @@ -261,14 +261,16 @@ export function noteItem(inlineNodes) { * Wrap inline ProseMirror nodes in a real footnoteDefinition node keyed by id: * { type:"footnoteDefinition", attrs:{id}, content:[{ type:"paragraph", content }] } * (mirrors the editor-ext / docmost-schema FootnoteDefinition node). + * + * Built on the shared `makeFootnoteDefinition` factory (footnote-authoring.ts); + * the only extra is a fresh block id on the inner paragraph (Docmost stamps one, + * and the canonicalizer preserves attrs as-is). Single factory, one place to + * change the definition shape. */ export function footnoteDefinition(id, inlineNodes) { - const content = Array.isArray(inlineNodes) ? clone(inlineNodes) : []; - return { - type: "footnoteDefinition", - attrs: { id }, - content: [{ type: "paragraph", attrs: { id: freshId() }, content }], - }; + const node = makeFootnoteDefinition(id, inlineNodes); + node.content[0].attrs = { id: freshId() }; + return node; } /** * Replace every `[N]` body marker and `\u0000FN\u0000` comment placeholder in diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index 2b449924..2228b34e 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -17,6 +17,7 @@ import { updatePageContentRealtime, replacePageContent, markdownToProseMirror, + markdownToProseMirrorCanonical, mutatePageContent, buildCollabWsUrl, assertYjsEncodable, @@ -1487,7 +1488,8 @@ export class DocmostClient { async importPageMarkdown(pageId: string, fullMarkdown: string): Promise { await this.ensureAuthenticated(); const { meta, body, comments } = parseDocmostMarkdown(fullMarkdown); - const doc = await markdownToProseMirror(body); + // PAGE import: canonicalize footnotes (see markdownToProseMirrorCanonical). + const doc = await markdownToProseMirrorCanonical(body); const collabToken = await this.getCollabTokenWithReauth(); const mutation = await replacePageContent( pageId, @@ -1582,10 +1584,16 @@ export class DocmostClient { // (parity with updatePageJson; harmless for already-stored source content). this.validateDocUrls(content); + // Defense-in-depth (#228): this is a FULL-document write, so canonicalize + // footnotes before copying — a no-op on already-canonical source content, but + // it guarantees a copy can never propagate a non-canonical footnote topology + // to the target (parity with the other full-doc write paths). + const canonical = canonicalizeFootnotes(content); + const collabToken = await this.getCollabTokenWithReauth(); const mutation = await replacePageContent( targetPageId, - content, + canonical, collabToken, this.apiUrl, ); @@ -1594,7 +1602,7 @@ export class DocmostClient { success: true, sourcePageId, targetPageId, - copiedNodes: content.content.length, + copiedNodes: canonical.content.length, verify: mutation.verify, }; } @@ -2112,7 +2120,10 @@ export class DocmostClient { } } - // Convert through the full Docmost schema (consistent with page paths) + // Convert through the full Docmost schema. Deliberately the NON-canonicalizing + // variant: a comment body may carry a footnote definition with no matching + // reference, and canonicalization would drop it (data loss). See + // markdownToProseMirror vs markdownToProseMirrorCanonical. const jsonContent = await markdownToProseMirror(content); const payload: Record = { pageId, @@ -2215,6 +2226,7 @@ export class DocmostClient { async updateComment(commentId: string, content: string) { await this.ensureAuthenticated(); + // NON-canonicalizing on purpose (comment body — see createComment). const jsonContent = await markdownToProseMirror(content); await this.client.post("/comments/update", { commentId, diff --git a/packages/mcp/src/lib/collaboration.ts b/packages/mcp/src/lib/collaboration.ts index e6f57aa8..c8b1cf40 100644 --- a/packages/mcp/src/lib/collaboration.ts +++ b/packages/mcp/src/lib/collaboration.ts @@ -396,12 +396,16 @@ function extractFootnotes(markdown: string): { /** * Convert markdown to a ProseMirror doc using the full Docmost schema. * - * NOTE: besides the page-import write paths, this is also reused for comment - * bodies (createComment / updateComment). For an ordinary comment the - * canonicalize call below is a no-op (a comment carries no footnotes), so the - * reuse is safe; the only theoretical effect is if footnote markup were ever - * authored INSIDE a comment — a narrow case where canonicalizing the comment's - * own (self-contained) footnotes is still the correct behaviour. + * This conversion does NOT canonicalize footnotes — it is the shared, content- + * preserving primitive used by BOTH page write paths and COMMENT bodies + * (createComment / updateComment). Canonicalization MUST NOT run on a comment + * body: a comment may legitimately contain a footnote-definition line + * (`[^1]: text`) with no matching reference, and the canonicalizer drops a + * reference-less footnotesList — which would silently delete the comment's text. + * + * Page write paths that DO need the canonical footnote topology call + * `markdownToProseMirrorCanonical` instead (markdown import, update_page markdown + * path). Keep this function reference-loss-free. */ export async function markdownToProseMirror( markdownContent: string, @@ -410,12 +414,24 @@ export async function markdownToProseMirror( const { body, section } = extractFootnotes(withCallouts); const html = (await marked.parse(body)) + section; const bridged = bridgeTaskLists(html); - const json = generateJSON(bridged, docmostExtensions); - // Canonicalize footnotes on EVERY import: the section above is built in - // definition order, but numbering is derived from REFERENCE order — so without - // this the bottom list renders out of order (`1, 4, 2, 3, …`). Idempotent, so - // it is a no-op when the footnotes are already canonical. - return canonicalizeFootnotes(json); + return generateJSON(bridged, docmostExtensions); +} + +/** + * Page-write variant of `markdownToProseMirror`: converts markdown then enforces + * the canonical footnote topology. The footnote `section` markdown is emitted in + * DEFINITION order, but numbering derives from REFERENCE order, so without this + * the bottom list renders out of order (`1, 4, 2, 3, …`); orphan definitions and + * duplicate lists are also normalized. Idempotent — a no-op once canonical, and a + * no-op for footnote-free content. + * + * Use this ONLY for full-document PAGE writes (never for comment bodies, where it + * would drop a reference-less footnote definition — see `markdownToProseMirror`). + */ +export async function markdownToProseMirrorCanonical( + markdownContent: string, +): Promise { + return canonicalizeFootnotes(await markdownToProseMirror(markdownContent)); } /** @@ -816,7 +832,9 @@ export async function updatePageContentRealtime( collabToken: string, baseUrl: string, ): Promise { - const tiptapJson = await markdownToProseMirror(markdownContent); + // PAGE write: canonicalize footnotes (markdown import builds the bottom list in + // definition order; numbering is reference-ordered). + const tiptapJson = await markdownToProseMirrorCanonical(markdownContent); return await mutatePageContent( pageId, collabToken, diff --git a/packages/mcp/src/lib/transforms.ts b/packages/mcp/src/lib/transforms.ts index 639f6d9e..2bb96393 100644 --- a/packages/mcp/src/lib/transforms.ts +++ b/packages/mcp/src/lib/transforms.ts @@ -327,14 +327,16 @@ export function noteItem(inlineNodes: any[]): any { * Wrap inline ProseMirror nodes in a real footnoteDefinition node keyed by id: * { type:"footnoteDefinition", attrs:{id}, content:[{ type:"paragraph", content }] } * (mirrors the editor-ext / docmost-schema FootnoteDefinition node). + * + * Built on the shared `makeFootnoteDefinition` factory (footnote-authoring.ts); + * the only extra is a fresh block id on the inner paragraph (Docmost stamps one, + * and the canonicalizer preserves attrs as-is). Single factory, one place to + * change the definition shape. */ export function footnoteDefinition(id: string, inlineNodes: any[]): any { - const content = Array.isArray(inlineNodes) ? clone(inlineNodes) : []; - return { - type: "footnoteDefinition", - attrs: { id }, - content: [{ type: "paragraph", attrs: { id: freshId() }, content }], - }; + const node = makeFootnoteDefinition(id, inlineNodes); + node.content[0].attrs = { id: freshId() }; + return node; } /** diff --git a/packages/mcp/test/unit/collaboration.test.mjs b/packages/mcp/test/unit/collaboration.test.mjs index ab07a414..84801840 100644 --- a/packages/mcp/test/unit/collaboration.test.mjs +++ b/packages/mcp/test/unit/collaboration.test.mjs @@ -4,6 +4,7 @@ import assert from "node:assert/strict"; import { buildCollabWsUrl, markdownToProseMirror, + markdownToProseMirrorCanonical, } from "../../build/lib/collaboration.js"; /** Recursively find the first descendant node (or self) of the given type. */ @@ -124,3 +125,38 @@ test("markdownToProseMirror: an aligned GFM table maps header alignment", async ["left", "center", "right"], ); }); + +// Comment-body data-loss guard (#228 review #4): markdownToProseMirror is reused +// for COMMENT bodies (createComment/updateComment), so it must NOT canonicalize — +// a comment may legitimately carry a standalone footnote definition with no +// matching reference, and canonicalization would drop the whole list (the text +// would vanish). The page-write variant DOES canonicalize. +test("markdownToProseMirror (comment path) PRESERVES a reference-less footnote definition", async () => { + const md = "A comment.\n\n[^1]: a standalone footnote definition"; + const doc = await markdownToProseMirror(md); + const defs = findAll(doc, "footnoteDefinition"); + assert.equal(defs.length, 1, "the footnote definition must be preserved"); + assert.match( + JSON.stringify(doc), + /a standalone footnote definition/, + "the definition text must survive the comment write path", + ); +}); + +test("markdownToProseMirrorCanonical (page path) DROPS a reference-less footnote definition", async () => { + // Same input through the PAGE variant: with no reference, the canonical doc has + // no footnotesList (this is the page-side behavior the comment path must avoid). + const md = "A page.\n\n[^1]: a standalone footnote definition"; + const doc = await markdownToProseMirrorCanonical(md); + assert.equal(findAll(doc, "footnotesList").length, 0); + assert.equal(findAll(doc, "footnoteDefinition").length, 0); +}); + +test("markdownToProseMirrorCanonical still canonicalizes a real page footnote (order)", async () => { + // Page path must STILL canonicalize: refs b,a -> definitions reorder to b,a. + const md = "See[^b] then[^a].\n\n[^a]: alpha\n[^b]: bravo"; + const doc = await markdownToProseMirrorCanonical(md); + const defs = findAll(doc, "footnoteDefinition").map((d) => d.attrs.id); + assert.deepEqual(defs, ["b", "a"]); + assert.equal(findAll(doc, "footnotesList").length, 1); +}); diff --git a/packages/mcp/test/unit/footnote-canonicalize.test.mjs b/packages/mcp/test/unit/footnote-canonicalize.test.mjs index c4b68ce5..3052aad2 100644 --- a/packages/mcp/test/unit/footnote-canonicalize.test.mjs +++ b/packages/mcp/test/unit/footnote-canonicalize.test.mjs @@ -4,7 +4,7 @@ import assert from "node:assert/strict"; import { canonicalizeFootnotes } from "../../build/lib/footnote-canonicalize.js"; import { footnoteContentKey } from "../../build/lib/footnote-authoring.js"; import { insertInlineFootnote } from "../../build/lib/transforms.js"; -import { markdownToProseMirror } from "../../build/lib/collaboration.js"; +import { markdownToProseMirrorCanonical } from "../../build/lib/collaboration.js"; function findAll(node, type, acc = []) { if (!node || typeof node !== "object") return acc; @@ -190,10 +190,12 @@ test("insertInlineFootnote: codeBlock match is skipped, a later body paragraph s assert.equal(findAll(r.doc, "footnoteReference").length, 1); }); -test("markdown import: out-of-order definitions render as a reference-ordered list", async () => { +test("markdown import (page path): out-of-order definitions render as a reference-ordered list", async () => { // References appear b, a, c in the body; definitions are written in a, b, c - // order (the import order). After canonicalization the bottom list follows - // REFERENCE order so the numbers read 1, 2, 3 down the list. + // order (the import order). The PAGE import path (markdownToProseMirrorCanonical) + // canonicalizes so the bottom list follows REFERENCE order — numbers read 1, 2, + // 3 down the list. (The non-canonicalizing markdownToProseMirror, used for + // comment bodies, would keep the import order; see collaboration.test.mjs.) const md = [ "See[^b] then[^a] then[^c].", "", @@ -201,7 +203,7 @@ test("markdown import: out-of-order definitions render as a reference-ordered li "[^b]: bravo", "[^c]: charlie", ].join("\n"); - const json = await markdownToProseMirror(md); + const json = await markdownToProseMirrorCanonical(md); assert.deepEqual(defIds(json), ["b", "a", "c"]); assert.equal(findAll(json, "footnotesList").length, 1); }); From 9c1f952b2f8310b2ea0a265cc75316f26c873afb Mon Sep 17 00:00:00 2001 From: a Date: Sat, 27 Jun 2026 23:40:28 +0300 Subject: [PATCH 6/7] fix(footnotes): guard insert against nested/bare definitions, skip definitions-only paste, doc + reorder fixes (#228) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Must-fix: - insertInlineFootnote could glue a footnoteReference inside an EXISTING definition (nested footnotesList, or a bare footnoteDefinition with no list wrapper), which canonicalize then dropped as an orphan — silently losing the definition's prose. Now: (a) the body/notes boundary is computed from the first top-level block that IS or CONTAINS (recursively) a footnotesList/ footnoteDefinition, not just a top-level list; and (b) the insertNodesAfterAnchor core skips footnotesList/footnoteDefinition subtrees entirely (skipSubtreeTypes), so an anchor whose only match is inside a definition -> inserted:false (clean abort, no write). Added tests: nested-definition, bare-definition, and body-before-nested-list-still-inserts. - editor-ext footnote-canonicalize header listed `markdownToProseMirror` among the canonicalizing MCP paths; it is the NON-canonicalizing primitive. Replaced with `markdownToProseMirrorCanonical` (+ note that the plain primitive is for comment bodies) and added copy_page_content. - Client paste: canonicalizePastedFootnotes now skips a definitions-ONLY paste (no footnoteReference anywhere) — canonicalizing it would strip the reference-less list and yield an EMPTY paste. Added a test. Suggestions: - docmost_transform now runs validateDocStructure/validateDocUrls on the RAW transform output BEFORE canonicalizeFootnotes (mirrors updatePageJson), so a too-deep doc gives the intended max-depth error instead of a stack overflow. - docmost_transform tool description now states the RESULT is footnote-canonical (dryRun diff may show tidy-ups; idempotent after first run). - insertFootnote: dropped the dead `result ? … : undefined` ternaries and the `as any` casts (result is always set by the time we return; the not-found path throws and aborts mutatePage). `const r = result!;`. Tests / architecture: - Added a LIVE-plugin golden case: the real footnoteSyncPlugin leaves a list with non-empty content after it in place, and canonicalize agrees (placement parity is now a driven property, not a hand-set expected). - Added generateFootnoteId uuidv7 shape + uniqueness test. - Item 9: added the ENFORCEMENT-RULE comments at the server parseProsemirrorContent and the MCP canonicalizer header (any NEW full-doc persist path MUST canonicalize; fragments/append/prepend and comment bodies MUST NOT). Kept per-call-site over a brittle grep CI test (the replace-vs-fragment + comment-vs-page nuance makes a single wrapper unsafe). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../markdown-clipboard.canonicalize.test.ts | 26 ++++++ .../editor/extensions/markdown-clipboard.ts | 14 ++++ .../src/core/page/services/page.service.ts | 6 ++ .../footnote/footnote-canonicalize.test.ts | 25 ++++++ .../src/lib/footnote/footnote-canonicalize.ts | 8 +- packages/mcp/build/client.js | 19 +++-- packages/mcp/build/index.js | 5 +- .../mcp/build/lib/footnote-canonicalize.js | 9 +++ packages/mcp/build/lib/transforms.js | 54 ++++++++++--- packages/mcp/src/client.ts | 19 +++-- packages/mcp/src/index.ts | 5 +- packages/mcp/src/lib/footnote-canonicalize.ts | 9 +++ packages/mcp/src/lib/transforms.ts | 65 ++++++++++++--- .../test/unit/footnote-canonicalize.test.mjs | 79 ++++++++++++++++++- 14 files changed, 305 insertions(+), 38 deletions(-) diff --git a/apps/client/src/features/editor/extensions/markdown-clipboard.canonicalize.test.ts b/apps/client/src/features/editor/extensions/markdown-clipboard.canonicalize.test.ts index 65d10481..e4d83288 100644 --- a/apps/client/src/features/editor/extensions/markdown-clipboard.canonicalize.test.ts +++ b/apps/client/src/features/editor/extensions/markdown-clipboard.canonicalize.test.ts @@ -117,6 +117,32 @@ describe("canonicalizePastedFootnotes", () => { editor.destroy(); }); + it("leaves a definitions-ONLY paste untouched (no references -> no empty paste)", () => { + // A whole-block paste of ONLY definitions (a footnotesList with no matching + // footnoteReference anywhere in the selection). Canonicalizing it would strip + // the reference-less list -> an EMPTY paste, losing the pasted text. The hook + // must leave such a block untouched. + const { editor, schema } = makeSchema(); + const slice = new Slice( + Fragment.fromArray([ + schema.nodes[FOOTNOTES_LIST_NAME].create(null, [ + schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "a" }, [ + schema.nodes.paragraph.create(null, [schema.text("note A")]), + ]), + schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "b" }, [ + schema.nodes.paragraph.create(null, [schema.text("note B")]), + ]), + ]), + ]), + 0, + 0, + ); + const out = canonicalizePastedFootnotes(slice, schema); + expect(out).toBe(slice); // returned unchanged (same reference, content kept) + expect(listIds(out)).toEqual(["a", "b"]); + editor.destroy(); + }); + it("leaves an open (partial) slice untouched even if it carries a list", () => { // An open slice (openStart/openEnd > 0) is a partial selection, not a // standalone block, so it is returned as-is BEFORE any footnote handling. diff --git a/apps/client/src/features/editor/extensions/markdown-clipboard.ts b/apps/client/src/features/editor/extensions/markdown-clipboard.ts index 89b7c22e..c28d2690 100644 --- a/apps/client/src/features/editor/extensions/markdown-clipboard.ts +++ b/apps/client/src/features/editor/extensions/markdown-clipboard.ts @@ -8,6 +8,7 @@ import { htmlToMarkdown, canonicalizeFootnotes, FOOTNOTES_LIST_NAME, + FOOTNOTE_REFERENCE_NAME, } from "@docmost/editor-ext"; import type { Schema } from "@tiptap/pm/model"; @@ -165,15 +166,28 @@ export const MarkdownClipboard = Extension.create({ * for the paste/sync plugins to merge. Residual: when the pasted block is merged * into a doc that already has footnotes, ordering RELATIVE to the pre-existing * footnotes is still governed by the sync plugin (which does not reorder). + * + * Also requires at least one footnoteReference in the selection: a definitions-ONLY + * paste (`[^a]: …` with no `[^a]` reference in the same block) has no references, + * so canonicalizeFootnotes would drop the whole list and the paste would come out + * EMPTY — losing the pasted text. Such a block is left as-is for the sync plugin. */ export function canonicalizePastedFootnotes(slice: Slice, schema: Schema): Slice { if (slice.openStart !== 0 || slice.openEnd !== 0) return slice; let hasFootnotesList = false; + let hasReference = false; slice.content.forEach((node) => { if (node.type.name === FOOTNOTES_LIST_NAME) hasFootnotesList = true; + if (node.type.name === FOOTNOTE_REFERENCE_NAME) hasReference = true; + node.descendants((child) => { + if (child.type.name === FOOTNOTE_REFERENCE_NAME) hasReference = true; + }); }); if (!hasFootnotesList) return slice; + // No reference anywhere -> a definitions-only paste; canonicalizing would strip + // the reference-less list (empty paste). Leave it untouched. + if (!hasReference) return slice; const content = slice.content.toJSON(); if (!Array.isArray(content)) return slice; diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index 44382d8a..c6ee150d 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -1328,6 +1328,12 @@ export class PageService { // (Future consolidation, architecture B: the import services persist via a // different path; folding all of these into one "prepare JSON for persist" // helper would centralize the canonicalize call — left as follow-up.) + // + // ENFORCEMENT RULE (#228): any NEW FULL-document persist path MUST call + // `canonicalizeFootnotes(json)` before writing (see createPage and + // updatePageContent 'replace'); append/prepend FRAGMENT writes MUST NOT (it + // would drop or duplicate footnotes — that is exactly why this is per-call-site + // rather than a single wrapper here). try { jsonToNode(prosemirrorJson); } catch (err) { diff --git a/packages/editor-ext/src/lib/footnote/footnote-canonicalize.test.ts b/packages/editor-ext/src/lib/footnote/footnote-canonicalize.test.ts index 80b56874..4bc17bb6 100644 --- a/packages/editor-ext/src/lib/footnote/footnote-canonicalize.test.ts +++ b/packages/editor-ext/src/lib/footnote/footnote-canonicalize.test.ts @@ -308,6 +308,31 @@ describe('canonicalizeFootnotes golden parity with footnoteSyncPlugin', () => { }); } + it('placement parity: the LIVE plugin leaves a list with NON-EMPTY content after it in place, and canonicalize agrees', () => { + // Drives the real footnoteSyncPlugin (not a hand-authored expected): a single + // canonical list with body content AFTER it must NOT be repositioned by the + // plugin, and the server canonicalizer must agree (step-6 placement parity). + const content = { + type: 'doc', + content: [ + para({ type: 'text', text: 'a' }, ref('x')), + list(def('x', 'X')), + para({ type: 'text', text: 'epilogue' }), + ], + }; + const steady = pluginSteadyState(content); + // The plugin did NOT move the list to the end: a non-empty paragraph follows it. + const types = steady.content.map((n: any) => n.type); + const listPos = types.indexOf(FOOTNOTES_LIST_NAME); + expect(listPos).toBeGreaterThanOrEqual(0); + expect(listPos).toBeLessThan(types.length - 1); + const after = steady.content[listPos + 1]; + expect(after.type).toBe('paragraph'); + expect(JSON.stringify(after)).toContain('epilogue'); + // The canonicalizer is a byte-for-byte no-op on that steady state (parity). + expect(canonicalizeFootnotes(steady)).toEqual(steady); + }); + it('the canonicalizer and the editor agree on reference order and definition set', () => { const content = { type: 'doc', diff --git a/packages/editor-ext/src/lib/footnote/footnote-canonicalize.ts b/packages/editor-ext/src/lib/footnote/footnote-canonicalize.ts index 3d52ea5f..21719eba 100644 --- a/packages/editor-ext/src/lib/footnote/footnote-canonicalize.ts +++ b/packages/editor-ext/src/lib/footnote/footnote-canonicalize.ts @@ -18,9 +18,11 @@ import { * `PageService` create/update (`parseProsemirrorContent` for the JSON/markdown/ * HTML REST write paths), and the client markdown PASTE path * (`markdown-clipboard.ts`). (The MCP package mirrors this canonicalizer in - * `packages/mcp/src/lib/footnote-canonicalize.ts` for its own write paths — - * `markdownToProseMirror`, `update_page_json`, `docmost_transform`, - * `insert_footnote` — see that file's header.) All of these are the root cause + * `packages/mcp/src/lib/footnote-canonicalize.ts` for its own FULL-document write + * paths — `markdownToProseMirrorCanonical` (the page markdown-import path; the + * plain `markdownToProseMirror` primitive used for COMMENT bodies does NOT + * canonicalize), `update_page_json`, `docmost_transform`, `insert_footnote`, + * `copy_page_content` — see that file's header.) All of these are the root cause * of the symptom in the issue: footnotes rendered out of order (`1, 4, 2, 3, …`), * a raw trailing `[^id]: …` block, and orphan definitions, all of which are * simply the result of content written PAST the canonicalizer. diff --git a/packages/mcp/build/client.js b/packages/mcp/build/client.js index 7b8fc936..422b0e6c 100644 --- a/packages/mcp/build/client.js +++ b/packages/mcp/build/client.js @@ -1118,13 +1118,16 @@ export class DocmostClient { result = { footnoteId: r.footnoteId, reused: r.reused }; return r.doc; }); + // The not-found path throws inside the transform (aborting mutatePage), so by + // here `result` is always set. + const r = result; return { success: true, modified: true, pageId, - footnoteId: result ? result.footnoteId : undefined, - reused: result ? result.reused : undefined, - message: result && result.reused + footnoteId: r.footnoteId, + reused: r.reused, + message: r.reused ? "Footnote inserted (reused an existing same-content definition)." : "Footnote inserted.", verify: mutation.verify, @@ -2534,14 +2537,18 @@ export class DocmostClient { !Array.isArray(raw.content)) { throw new Error('transform must return a ProseMirror doc node ({ type:"doc", content:[...] })'); } + // Validate the RAW transform output FIRST (structure — including the + // MAX_DEPTH guard — and URLs), mirroring updatePageJson. The canonicalizer + // recurses without a depth limiter, so validating after it would turn a + // too-deep doc into an opaque "Maximum call stack size exceeded" instead of + // the intended "nesting exceeds the maximum depth" error. + this.validateDocStructure(raw); + this.validateDocUrls(raw); // Auto-canonicalize footnotes after the transform (idempotent): no write // path can leave footnotes out of order / orphaned / in a raw `[^id]` // block. In a dryRun preview this may surface footnote edits the script // author did not write (the canonicalizer tidied them) — that is expected. const result = canonicalizeFootnotes(raw); - // Validate the returned doc before it can be written. - this.validateDocStructure(result); - this.validateDocUrls(result); newDoc = result; return result; }; diff --git a/packages/mcp/build/index.js b/packages/mcp/build/index.js index 58197d09..edcad9e6 100644 --- a/packages/mcp/build/index.js +++ b/packages/mcp/build/index.js @@ -640,7 +640,10 @@ export function createDocmostMcpServer(config) { "commentsToFootnotes(doc, comments, {notesHeading}) (turn inline " + "comments into numbered footnotes), canonicalizeFootnotes(doc) (derive " + "footnote numbering + the single bottom list from reference order, drop " + - "orphans/duplicates — runs automatically after every transform too), and " + + "orphans/duplicates — runs AUTOMATICALLY on the transform RESULT, so the " + + "applied (and dryRun-previewed) doc is always footnote-canonical; a dryRun " + + "diff may therefore show footnote tidy-ups your script did not make, and " + + "it is idempotent after the first run), and " + "insertInlineFootnote(doc, {anchorText, text}) (author-inline footnote: " + "marker + dedup'd definition, list derived). Footnote convention: markers are " + "plain '[N]' text in the body; the notes are an orderedList under a " + diff --git a/packages/mcp/build/lib/footnote-canonicalize.js b/packages/mcp/build/lib/footnote-canonicalize.js index b6673082..1df76154 100644 --- a/packages/mcp/build/lib/footnote-canonicalize.js +++ b/packages/mcp/build/lib/footnote-canonicalize.js @@ -23,6 +23,15 @@ * was never enforced. Running this at the end of every write path closes that * gap; because it is idempotent, it is a no-op when the footnotes are already * canonical (no spurious mutations / git-sync churn). + * + * ENFORCEMENT RULE (#228): any NEW FULL-document persist path MUST call + * `canonicalizeFootnotes(doc)` before writing — the current callers are + * `markdownToProseMirrorCanonical` (page markdown import/update; the plain + * `markdownToProseMirror` used for COMMENT bodies must NOT, or it would drop a + * reference-less definition), `update_page_json`, `docmost_transform`, + * `insert_footnote`, and `copy_page_content`. Append/prepend FRAGMENT writes MUST + * NOT canonicalize. This is deliberately per-call-site (the replace-vs-fragment + * and comment-vs-page nuances make a single naive wrapper unsafe). */ const FOOTNOTE_REFERENCE_NAME = "footnoteReference"; const FOOTNOTES_LIST_NAME = "footnotesList"; diff --git a/packages/mcp/build/lib/transforms.js b/packages/mcp/build/lib/transforms.js index 935ff33d..c1b822ba 100644 --- a/packages/mcp/build/lib/transforms.js +++ b/packages/mcp/build/lib/transforms.js @@ -76,6 +76,27 @@ export function getList(doc, predicate) { * footnote inserter excludes via `beforeBlock`.) */ const INLINE_ATOM_FORBIDDEN_BLOCKS = new Set(["codeBlock"]); +/** + * Footnote-notes subtrees the inline footnote inserter must never split into (at + * any depth): a `footnotesList` and the `footnoteDefinition`s it holds. Anchoring + * a reference inside one of these would later be dropped as an orphan by the + * canonicalizer, taking the existing definition's text with it. + */ +const FOOTNOTE_NOTES_SUBTREES = new Set([ + "footnotesList", + "footnoteDefinition", +]); +/** True if `node` IS, or contains at any depth, a footnotesList/footnoteDefinition. */ +function containsFootnoteNotes(node) { + if (!isObject(node)) + return false; + if (FOOTNOTE_NOTES_SUBTREES.has(node.type)) + return true; + if (Array.isArray(node.content)) { + return node.content.some((c) => containsFootnoteNotes(c)); + } + return false; +} /** * Insert `marker` as a PLAIN (unmarked) text run right after the first * occurrence of `anchor`. @@ -136,6 +157,13 @@ function insertNodesAfterAnchor(doc, anchor, makeMiddle, opts = {}) { if (inserted || !isObject(container) || !Array.isArray(container.content)) { return; } + // Skip a forbidden subtree entirely (e.g. footnotesList/footnoteDefinition): + // never split into it, but keep `offset` aligned for any sibling text after + // it within this block. + if (opts.skipSubtreeTypes && opts.skipSubtreeTypes.has(container.type)) { + offset += blockPlainText(container).length; + return; + } const inline = container.content; // Detect whether this array is an inline array (contains text nodes). const hasText = inline.some((n) => isObject(n) && n.type === "text"); @@ -553,18 +581,26 @@ export function insertInlineFootnote(doc, opts) { if (footnoteId == null) footnoteId = generateFootnoteId(); // Insert the footnoteReference node directly after the anchor (mark-safe - // split); it hugs the preceding word with no leading space. The search is - // bounded to the BODY (before the first footnotesList) and refuses codeBlocks, - // so the inline atom can never be spliced into a footnote definition or a code - // block — which would persist a schema-invalid doc (insert_footnote skips - // validateDocStructure). When the only match is in such a place the insert is - // refused and the write aborts cleanly (inserted:false). - const listIdx = Array.isArray(doc?.content) - ? doc.content.findIndex((n) => isObject(n) && n.type === "footnotesList") + // split); it hugs the preceding word with no leading space. Two guards keep the + // inline atom out of the notes section and out of blocks that cannot hold it: + // - beforeBlock bounds the search to the BODY, before the first top-level block + // that IS or CONTAINS (at any depth) a footnotesList/footnoteDefinition — so + // a NESTED list or a bare definition also bounds the search, not just a + // top-level list; + // - skipSubtreeTypes refuses to descend into any footnotesList/footnoteDefinition + // subtree, so a reference is never glued inside an existing definition (which + // the canonicalizer would then drop as an orphan, losing that definition's + // prose); and forbidBlockTypes refuses codeBlocks (an inline atom there is a + // schema-invalid doc; insert_footnote skips validateDocStructure). + // When the only anchor match is in such a place, the insert is refused and the + // write aborts cleanly (inserted:false) instead of destroying content. + const boundaryIdx = Array.isArray(doc?.content) + ? doc.content.findIndex((n) => containsFootnoteNotes(n)) : -1; const r = insertNodesAfterAnchor(doc, (opts.anchorText ?? "").trimEnd(), () => [{ type: "footnoteReference", attrs: { id: footnoteId } }], { - ...(listIdx >= 0 ? { beforeBlock: listIdx } : {}), + ...(boundaryIdx >= 0 ? { beforeBlock: boundaryIdx } : {}), forbidBlockTypes: INLINE_ATOM_FORBIDDEN_BLOCKS, + skipSubtreeTypes: FOOTNOTE_NOTES_SUBTREES, }); if (!r.inserted) { return { doc: clone(doc), inserted: false, footnoteId, reused }; diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index 2228b34e..85a5a3a4 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -1420,13 +1420,16 @@ export class DocmostClient { return r.doc; }, ); + // The not-found path throws inside the transform (aborting mutatePage), so by + // here `result` is always set. + const r = result!; return { success: true, modified: true, pageId, - footnoteId: result ? (result as any).footnoteId : undefined, - reused: result ? (result as any).reused : undefined, - message: result && (result as any).reused + footnoteId: r.footnoteId, + reused: r.reused, + message: r.reused ? "Footnote inserted (reused an existing same-content definition)." : "Footnote inserted.", verify: mutation.verify, @@ -3130,14 +3133,18 @@ export class DocmostClient { 'transform must return a ProseMirror doc node ({ type:"doc", content:[...] })', ); } + // Validate the RAW transform output FIRST (structure — including the + // MAX_DEPTH guard — and URLs), mirroring updatePageJson. The canonicalizer + // recurses without a depth limiter, so validating after it would turn a + // too-deep doc into an opaque "Maximum call stack size exceeded" instead of + // the intended "nesting exceeds the maximum depth" error. + this.validateDocStructure(raw); + this.validateDocUrls(raw); // Auto-canonicalize footnotes after the transform (idempotent): no write // path can leave footnotes out of order / orphaned / in a raw `[^id]` // block. In a dryRun preview this may surface footnote edits the script // author did not write (the canonicalizer tidied them) — that is expected. const result = canonicalizeFootnotes(raw); - // Validate the returned doc before it can be written. - this.validateDocStructure(result); - this.validateDocUrls(result); newDoc = result; return result; }; diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index d439229a..db29f143 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -895,7 +895,10 @@ server.registerTool( "commentsToFootnotes(doc, comments, {notesHeading}) (turn inline " + "comments into numbered footnotes), canonicalizeFootnotes(doc) (derive " + "footnote numbering + the single bottom list from reference order, drop " + - "orphans/duplicates — runs automatically after every transform too), and " + + "orphans/duplicates — runs AUTOMATICALLY on the transform RESULT, so the " + + "applied (and dryRun-previewed) doc is always footnote-canonical; a dryRun " + + "diff may therefore show footnote tidy-ups your script did not make, and " + + "it is idempotent after the first run), and " + "insertInlineFootnote(doc, {anchorText, text}) (author-inline footnote: " + "marker + dedup'd definition, list derived). Footnote convention: markers are " + "plain '[N]' text in the body; the notes are an orderedList under a " + diff --git a/packages/mcp/src/lib/footnote-canonicalize.ts b/packages/mcp/src/lib/footnote-canonicalize.ts index 1e544a92..b4ae6e03 100644 --- a/packages/mcp/src/lib/footnote-canonicalize.ts +++ b/packages/mcp/src/lib/footnote-canonicalize.ts @@ -23,6 +23,15 @@ * was never enforced. Running this at the end of every write path closes that * gap; because it is idempotent, it is a no-op when the footnotes are already * canonical (no spurious mutations / git-sync churn). + * + * ENFORCEMENT RULE (#228): any NEW FULL-document persist path MUST call + * `canonicalizeFootnotes(doc)` before writing — the current callers are + * `markdownToProseMirrorCanonical` (page markdown import/update; the plain + * `markdownToProseMirror` used for COMMENT bodies must NOT, or it would drop a + * reference-less definition), `update_page_json`, `docmost_transform`, + * `insert_footnote`, and `copy_page_content`. Append/prepend FRAGMENT writes MUST + * NOT canonicalize. This is deliberately per-call-site (the replace-vs-fragment + * and comment-vs-page nuances make a single naive wrapper unsafe). */ const FOOTNOTE_REFERENCE_NAME = "footnoteReference"; diff --git a/packages/mcp/src/lib/transforms.ts b/packages/mcp/src/lib/transforms.ts index 2bb96393..e3ab0cff 100644 --- a/packages/mcp/src/lib/transforms.ts +++ b/packages/mcp/src/lib/transforms.ts @@ -96,6 +96,15 @@ export interface InsertMarkerOptions { * markers leave this unset (text is valid inside a codeBlock). */ forbidBlockTypes?: ReadonlySet; + /** + * Node types whose ENTIRE subtree is skipped during the walk (never split into, + * at any depth). Used to keep the footnote inserter out of the notes section: + * splitting text inside an existing `footnoteDefinition` would glue a reference + * into a definition, which the canonicalizer then drops as an orphan together + * with the definition's prose — silent loss of an existing footnote. Skipped + * subtrees still advance the running offset so sibling text stays aligned. + */ + skipSubtreeTypes?: ReadonlySet; } /** @@ -108,6 +117,27 @@ export interface InsertMarkerOptions { */ const INLINE_ATOM_FORBIDDEN_BLOCKS: ReadonlySet = new Set(["codeBlock"]); +/** + * Footnote-notes subtrees the inline footnote inserter must never split into (at + * any depth): a `footnotesList` and the `footnoteDefinition`s it holds. Anchoring + * a reference inside one of these would later be dropped as an orphan by the + * canonicalizer, taking the existing definition's text with it. + */ +const FOOTNOTE_NOTES_SUBTREES: ReadonlySet = new Set([ + "footnotesList", + "footnoteDefinition", +]); + +/** True if `node` IS, or contains at any depth, a footnotesList/footnoteDefinition. */ +function containsFootnoteNotes(node: any): boolean { + if (!isObject(node)) return false; + if (FOOTNOTE_NOTES_SUBTREES.has(node.type)) return true; + if (Array.isArray(node.content)) { + return node.content.some((c: any) => containsFootnoteNotes(c)); + } + return false; +} + /** * Insert `marker` as a PLAIN (unmarked) text run right after the first * occurrence of `anchor`. @@ -187,6 +217,13 @@ function insertNodesAfterAnchor( if (inserted || !isObject(container) || !Array.isArray(container.content)) { return; } + // Skip a forbidden subtree entirely (e.g. footnotesList/footnoteDefinition): + // never split into it, but keep `offset` aligned for any sibling text after + // it within this block. + if (opts.skipSubtreeTypes && opts.skipSubtreeTypes.has(container.type)) { + offset += blockPlainText(container).length; + return; + } const inline = container.content; // Detect whether this array is an inline array (contains text nodes). const hasText = inline.some( @@ -692,24 +729,30 @@ export function insertInlineFootnote( if (footnoteId == null) footnoteId = generateFootnoteId(); // Insert the footnoteReference node directly after the anchor (mark-safe - // split); it hugs the preceding word with no leading space. The search is - // bounded to the BODY (before the first footnotesList) and refuses codeBlocks, - // so the inline atom can never be spliced into a footnote definition or a code - // block — which would persist a schema-invalid doc (insert_footnote skips - // validateDocStructure). When the only match is in such a place the insert is - // refused and the write aborts cleanly (inserted:false). - const listIdx = Array.isArray(doc?.content) - ? doc.content.findIndex( - (n: any) => isObject(n) && n.type === "footnotesList", - ) + // split); it hugs the preceding word with no leading space. Two guards keep the + // inline atom out of the notes section and out of blocks that cannot hold it: + // - beforeBlock bounds the search to the BODY, before the first top-level block + // that IS or CONTAINS (at any depth) a footnotesList/footnoteDefinition — so + // a NESTED list or a bare definition also bounds the search, not just a + // top-level list; + // - skipSubtreeTypes refuses to descend into any footnotesList/footnoteDefinition + // subtree, so a reference is never glued inside an existing definition (which + // the canonicalizer would then drop as an orphan, losing that definition's + // prose); and forbidBlockTypes refuses codeBlocks (an inline atom there is a + // schema-invalid doc; insert_footnote skips validateDocStructure). + // When the only anchor match is in such a place, the insert is refused and the + // write aborts cleanly (inserted:false) instead of destroying content. + const boundaryIdx = Array.isArray(doc?.content) + ? doc.content.findIndex((n: any) => containsFootnoteNotes(n)) : -1; const r = insertNodesAfterAnchor( doc, (opts.anchorText ?? "").trimEnd(), () => [{ type: "footnoteReference", attrs: { id: footnoteId } }], { - ...(listIdx >= 0 ? { beforeBlock: listIdx } : {}), + ...(boundaryIdx >= 0 ? { beforeBlock: boundaryIdx } : {}), forbidBlockTypes: INLINE_ATOM_FORBIDDEN_BLOCKS, + skipSubtreeTypes: FOOTNOTE_NOTES_SUBTREES, }, ); if (!r.inserted) { diff --git a/packages/mcp/test/unit/footnote-canonicalize.test.mjs b/packages/mcp/test/unit/footnote-canonicalize.test.mjs index 3052aad2..e626b316 100644 --- a/packages/mcp/test/unit/footnote-canonicalize.test.mjs +++ b/packages/mcp/test/unit/footnote-canonicalize.test.mjs @@ -2,7 +2,10 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { canonicalizeFootnotes } from "../../build/lib/footnote-canonicalize.js"; -import { footnoteContentKey } from "../../build/lib/footnote-authoring.js"; +import { + footnoteContentKey, + generateFootnoteId, +} from "../../build/lib/footnote-authoring.js"; import { insertInlineFootnote } from "../../build/lib/transforms.js"; import { markdownToProseMirrorCanonical } from "../../build/lib/collaboration.js"; @@ -190,6 +193,66 @@ test("insertInlineFootnote: codeBlock match is skipped, a later body paragraph s assert.equal(findAll(r.doc, "footnoteReference").length, 1); }); +test("insertInlineFootnote: anchor only inside a NESTED definition -> refused, definition preserved", () => { + // The footnotesList is nested in a callout (not top level) and the anchor text + // appears ONLY inside that definition. The search must be bounded past the + // notes subtree (recursive boundary) AND refuse to descend into the definition, + // so it aborts cleanly instead of gluing a reference into the definition (which + // canonicalize would then drop as an orphan, losing the definition's prose). + const doc = { + type: "doc", + content: [ + para({ type: "text", text: "Body text here." }, ref("a")), + { + type: "callout", + content: [list(def("a", "the unique anchor lives here"))], + }, + ], + }; + const r = insertInlineFootnote(doc, { + anchorText: "unique anchor", + text: "new note", + }); + assert.equal(r.inserted, false); + // The existing definition (and its text) is preserved untouched. + assert.equal(findAll(r.doc, "footnoteDefinition").length, 1); + assert.match(JSON.stringify(r.doc), /the unique anchor lives here/); + assert.equal(findAll(r.doc, "footnoteReference").length, 1); // only the original +}); + +test("insertInlineFootnote: anchor only inside a BARE definition (no list wrapper) -> refused", () => { + const doc = { + type: "doc", + content: [ + para({ type: "text", text: "Some body." }), + { + type: "footnoteDefinition", + attrs: { id: "a" }, + content: [{ type: "paragraph", content: [{ type: "text", text: "orphan anchor text" }] }], + }, + ], + }; + const r = insertInlineFootnote(doc, { anchorText: "orphan anchor", text: "x" }); + assert.equal(r.inserted, false); + assert.equal(findAll(r.doc, "footnoteDefinition").length, 1); + assert.match(JSON.stringify(r.doc), /orphan anchor text/); +}); + +test("insertInlineFootnote: anchor in body BEFORE a nested list still inserts", () => { + const doc = { + type: "doc", + content: [ + para({ type: "text", text: "The sky is blue." }, ref("a")), + { type: "callout", content: [list(def("a", "note a"))] }, + ], + }; + const r = insertInlineFootnote(doc, { anchorText: "blue", text: "Rayleigh." }); + assert.equal(r.inserted, true); + // The new reference plus the original = two references; a single canonical list. + assert.equal(findAll(r.doc, "footnoteReference").length, 2); + assert.equal(findAll(r.doc, "footnotesList").length, 1); +}); + test("markdown import (page path): out-of-order definitions render as a reference-ordered list", async () => { // References appear b, a, c in the body; definitions are written in a, b, c // order (the import order). The PAGE import path (markdownToProseMirrorCanonical) @@ -207,3 +270,17 @@ test("markdown import (page path): out-of-order definitions render as a referenc assert.deepEqual(defIds(json), ["b", "a", "c"]); assert.equal(findAll(json, "footnotesList").length, 1); }); + +test("generateFootnoteId: valid uuidv7 shape (version 7, variant 8..b) and unique", () => { + // version nibble = 7; variant nibble in [8,9,a,b]; otherwise lowercase hex. + const re = + /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + const ids = new Set(); + for (let i = 0; i < 50; i++) { + const id = generateFootnoteId(); + assert.match(id, re, `not a uuidv7: ${id}`); + ids.add(id); + } + // Distinct across calls (random component makes collisions astronomically rare). + assert.equal(ids.size, 50, "generated ids must be unique"); +}); From c4ed4a485561ec9ad5d9125dacbec7d6c98f8d6a Mon Sep 17 00:00:00 2001 From: a Date: Sun, 28 Jun 2026 01:39:25 +0300 Subject: [PATCH 7/7] fix(footnotes): strip bare definitions on rebuild; MCP full-doc + zip-import canonicalize tests (#228) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review #6 (approve-with-comments) follow-ups: 1. canonicalize step 7 now strips bare footnoteDefinitions at ANY depth (stripFootnoteDefinitionsDeep), not just footnotesList, in BOTH copies. A definition hand-authored outside a list (e.g. nested in a callout via a raw-JSON write path) was left in place while a copy was also added to the rebuilt list -> duplicate, idempotent, self-perpetuating. Runs only in the rebuild path (after the lists are stripped); the fast-path / placement-keep branch is untouched. Added a shared-corpus case (bare def nested in a callout) to pin it in both mirrors. 2. markdown-clipboard: removed the dead top-level footnoteReference check in canonicalizePastedFootnotes (an inline atom is never a top-level slice child; only the descendants scan can find it). Test coverage: 4. New MCP binding tests (full-doc-write-canonicalize.test.mjs): update_page_json and copy_page_content canonicalize the persisted full doc, asserted via a new `replacePage` seam (symmetric to the existing `mutatePage` seam) so no live collab socket is needed. Routed both writers through the seam. 5. New server spec (file-import-task.service.footnote-canonicalize.spec.ts): the zip-import path (processGenericImport) canonicalizes footnotes — real markdown->HTML->JSON via a real ImportService over a temp-dir .md file, DB trx stubbed to capture the persisted page content. FileImportTaskService had no spec before. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/extensions/markdown-clipboard.ts | 4 +- ...task.service.footnote-canonicalize.spec.ts | 150 ++++++++++++++++++ .../src/lib/footnote/footnote-canonicalize.ts | 32 +++- .../src/lib/footnote/footnote-corpus.ts | 98 +++++++++++- packages/mcp/build/client.js | 13 +- .../mcp/build/lib/footnote-canonicalize.js | 27 +++- packages/mcp/src/client.ts | 19 ++- packages/mcp/src/lib/footnote-canonicalize.ts | 28 +++- .../mock/full-doc-write-canonicalize.test.mjs | 78 +++++++++ packages/mcp/test/unit/footnote-corpus.mjs | 91 +++++++++++ 10 files changed, 515 insertions(+), 25 deletions(-) create mode 100644 apps/server/src/integrations/import/services/file-import-task.service.footnote-canonicalize.spec.ts create mode 100644 packages/mcp/test/mock/full-doc-write-canonicalize.test.mjs diff --git a/apps/client/src/features/editor/extensions/markdown-clipboard.ts b/apps/client/src/features/editor/extensions/markdown-clipboard.ts index c28d2690..c8e36a1b 100644 --- a/apps/client/src/features/editor/extensions/markdown-clipboard.ts +++ b/apps/client/src/features/editor/extensions/markdown-clipboard.ts @@ -179,7 +179,9 @@ export function canonicalizePastedFootnotes(slice: Slice, schema: Schema): Slice let hasReference = false; slice.content.forEach((node) => { if (node.type.name === FOOTNOTES_LIST_NAME) hasFootnotesList = true; - if (node.type.name === FOOTNOTE_REFERENCE_NAME) hasReference = true; + // footnoteReference is an inline atom, never a top-level slice child here + // (this function early-returns for open slices, so children are whole + // blocks), so it is only reachable by descending. node.descendants((child) => { if (child.type.name === FOOTNOTE_REFERENCE_NAME) hasReference = true; }); diff --git a/apps/server/src/integrations/import/services/file-import-task.service.footnote-canonicalize.spec.ts b/apps/server/src/integrations/import/services/file-import-task.service.footnote-canonicalize.spec.ts new file mode 100644 index 00000000..08ecce15 --- /dev/null +++ b/apps/server/src/integrations/import/services/file-import-task.service.footnote-canonicalize.spec.ts @@ -0,0 +1,150 @@ +// Importing FileImportTaskService transitively loads import-formatter.ts, which +// imports the ESM-only @sindresorhus/slugify package (not in jest's transform +// allowlist). slugify is irrelevant to the path under test, so it is mocked out +// to keep the module graph loadable under ts-jest (mirrors the import.service spec). +jest.mock('@sindresorhus/slugify', () => ({ + __esModule: true, + default: (input: string) => String(input), +})); +// import-attachment.service.ts (loaded transitively for DI typing) imports the +// ESM-only `p-limit` / `image-dimensions`; neither is exercised on the path under +// test, so stub them so the module graph loads under ts-jest. +jest.mock('p-limit', () => ({ + __esModule: true, + default: () => (fn: any) => fn(), +})); +jest.mock('image-dimensions', () => ({ + __esModule: true, + imageDimensionsFromData: () => undefined, +})); + +import { promises as fs } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { FileImportTaskService } from './file-import-task.service'; +import { ImportService } from './import.service'; + +/** + * Binding test for issue #228 / review #5: FileImportTaskService.processGenericImport + * is a NON-editor write path (markdownToHtml -> processHTML -> JSON, never runs + * footnoteSyncPlugin), so it canonicalizes footnotes before persisting. This pins + * that binding — the same one import.service has a spec for — which previously had + * NO spec at all. + * + * The markdown -> HTML -> ProseMirror conversion is REAL (a real ImportService, + * its createYdoc stubbed); the filesystem is a real temp dir with one .md file; + * the DB transaction is stubbed to capture the persisted page content. + */ + +// Out-of-order references (c, a, b), a REUSED reference ([^a] twice), and an +// ORPHAN definition ([^z], never referenced). +const MARKDOWN = [ + '# Title', + '', + 'Body refs [^c] and [^a] and [^b] and again [^a].', + '', + '[^a]: note A', + '[^b]: note B', + '[^c]: note C', + '[^z]: orphan note', +].join('\n'); + +function footnoteListIds(content: any): string[] { + const list = (content?.content ?? []).find( + (n: any) => n.type === 'footnotesList', + ); + return (list?.content ?? []) + .filter((n: any) => n.type === 'footnoteDefinition') + .map((n: any) => n.attrs?.id); +} + +// A permissive chainable stub for the spaces lookup (selectFrom(...).select(...) +// .where(...).executeTakeFirst()). +function chainable(result: any): any { + const proxy: any = new Proxy(function () {}, { + get: (_t, prop) => { + if (prop === 'executeTakeFirst') return async () => result; + if (prop === 'execute') return async () => []; + return () => proxy; + }, + }); + return proxy; +} + +describe('FileImportTaskService.processGenericImport — footnote canonicalization (#228)', () => { + it('orders footnotes by first reference, dedupes reuse, and drops orphans on zip import', async () => { + const extractDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fit-canon-')); + await fs.writeFile(path.join(extractDir, 'note.md'), MARKDOWN, 'utf-8'); + + // Real ImportService for the html -> JSON conversion; stub the yjs encode. + const importService = new ImportService( + {} as any, + {} as any, + {} as any, + {} as any, + ); + jest + .spyOn(importService as any, 'createYdoc') + .mockResolvedValue(Buffer.from([]) as any); + + let captured: any = null; + const trx = { + insertInto: (table: string) => ({ + values: (v: any) => { + if (table === 'pages') captured = v; + return { execute: async () => {} }; + }, + }), + }; + const db: any = { + selectFrom: () => chainable({ slug: 'space-slug' }), + transaction: () => ({ execute: (fn: any) => fn(trx) }), + }; + + const importAttachmentService = { + processAttachments: async ({ html }: any) => html, + }; + const backlinkRepo = { insertBacklink: jest.fn() }; + const eventEmitter = { emit: jest.fn() }; + const auditService = { logBatchWithContext: jest.fn() }; + + const pageService = { nextPagePosition: async () => 'a0' }; + + const service = new FileImportTaskService( + {} as any, // storageService + importService as any, + pageService as any, + backlinkRepo as any, + db, + importAttachmentService as any, + eventEmitter as any, + auditService as any, + ); + + const fileTask: any = { + id: 'task-1', + source: 'generic', + spaceId: 'space-1', + workspaceId: 'ws-1', + creatorId: 'user-1', + }; + + try { + await service.processGenericImport({ extractDir, fileTask }); + + expect(captured).toBeTruthy(); + const content = captured.content; + // Reference order is c, a, b (NOT the markdown definition order a, b, c). + expect(footnoteListIds(content)).toEqual(['c', 'a', 'b']); + // Orphan [^z] dropped; reused [^a] collapses to one definition; one list. + expect(footnoteListIds(content)).not.toContain('z'); + const lists = (content.content ?? []).filter( + (n: any) => n.type === 'footnotesList', + ); + expect(lists).toHaveLength(1); + expect(footnoteListIds(content).filter((id) => id === 'a')).toHaveLength(1); + } finally { + await fs.rm(extractDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/editor-ext/src/lib/footnote/footnote-canonicalize.ts b/packages/editor-ext/src/lib/footnote/footnote-canonicalize.ts index 21719eba..f7a05f94 100644 --- a/packages/editor-ext/src/lib/footnote/footnote-canonicalize.ts +++ b/packages/editor-ext/src/lib/footnote/footnote-canonicalize.ts @@ -147,14 +147,17 @@ export function canonicalizeFootnotes(doc: T): T { return out; } - // 7) Otherwise rebuild: strip every footnotesList at ANY depth (collectDefinitions - // gathers defs recursively, so a list nested in a callout/blockquote would - // otherwise have its defs copied into the new list while the original - // survives — duplicates) and re-insert exactly one after the last meaningful - // (non-empty paragraph) top-level block, so it coexists with a trailing-node - // empty paragraph. This both repairs a non-canonical doc and (in the import - // case) physically reorders the list into reference order. + // 7) Otherwise rebuild: strip every footnotesList AND every bare + // footnoteDefinition at ANY depth (collectDefinitions gathers defs + // recursively, so a list nested in a callout/blockquote — or a bare + // definition outside any list — would otherwise have its defs copied into the + // rebuilt list while the original survives in place → duplicates) and + // re-insert exactly one list after the last meaningful (non-empty paragraph) + // top-level block, so it coexists with a trailing-node empty paragraph. This + // both repairs a non-canonical doc and (in the import case) physically + // reorders the list into reference order. stripFootnotesListsDeep(out); + stripFootnoteDefinitionsDeep(out); const top: any[] = out.content; let insertAt = top.length; while (insertAt > 0 && isEmptyParagraph(top[insertAt - 1])) insertAt--; @@ -172,6 +175,21 @@ function stripFootnotesListsDeep(node: any): void { for (const child of node.content) stripFootnotesListsDeep(child); } +/** + * Remove every BARE `footnoteDefinition` node at ANY depth (mutates the given + * clone). Runs only in the rebuild path AFTER the lists are stripped, so it + * targets definitions that were sitting outside a list (e.g. hand-authored via a + * raw-JSON write path and nested in a callout); their content was already copied + * into the rebuilt list, so leaving the originals would duplicate them. + */ +function stripFootnoteDefinitionsDeep(node: any): void { + if (!node || typeof node !== 'object' || !Array.isArray(node.content)) return; + node.content = node.content.filter( + (c: any) => !(c && c.type === FOOTNOTE_DEFINITION_NAME), + ); + for (const child of node.content) stripFootnoteDefinitionsDeep(child); +} + /** * Deep equality over plain JSON: arrays are compared POSITIONALLY * (order-SENSITIVE), object keys order-insensitively. The array order-sensitivity diff --git a/packages/editor-ext/src/lib/footnote/footnote-corpus.ts b/packages/editor-ext/src/lib/footnote/footnote-corpus.ts index e8521b74..dd5f41d0 100644 --- a/packages/editor-ext/src/lib/footnote/footnote-corpus.ts +++ b/packages/editor-ext/src/lib/footnote/footnote-corpus.ts @@ -7,12 +7,13 @@ * Both the editor-ext copy and the MCP mirror of `canonicalizeFootnotes` are run * against this corpus by their respective test suites, which turns "the two * pure copies behave identically" into a checkable property without coupling the - * packages at build time. When you change one corpus, change the other. + * packages. When you change one corpus, change the other. * * Coverage includes (besides ordering/orphan/reuse/dedup/synth/merge): a single * canonical list with NON-EMPTY content after it (must NOT be repositioned — - * plugin placement parity, must-fix #2) and a reference nested inside a callout - * (the recursive collection, test-coverage #14). + * plugin placement parity, must-fix #2), a reference nested inside a callout + * (the recursive collection, test-coverage #14), and a BARE footnoteDefinition + * nested in a callout (rebuild must strip the original so it is not duplicated). */ export interface FootnoteCorpusCase { name: string; @@ -1145,6 +1146,97 @@ export const FOOTNOTE_CORPUS: FootnoteCorpusCase[] = [ ] } }, + { + "name": "bare footnoteDefinition nested in a callout is collected, NOT duplicated", + "input": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "see " + }, + { + "type": "footnoteReference", + "attrs": { + "id": "a" + } + } + ] + }, + { + "type": "callout", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "a" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "note A" + } + ] + } + ] + } + ] + } + ] + }, + "expected": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "see " + }, + { + "type": "footnoteReference", + "attrs": { + "id": "a" + } + } + ] + }, + { + "type": "callout", + "content": [] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "a" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "note A" + } + ] + } + ] + } + ] + } + ] + } + }, { "name": "no footnotes at all is unchanged", "input": { diff --git a/packages/mcp/build/client.js b/packages/mcp/build/client.js index 422b0e6c..082f8e68 100644 --- a/packages/mcp/build/client.js +++ b/packages/mcp/build/client.js @@ -1071,7 +1071,7 @@ export class DocmostClient { // Write the BODY first, then the title (#159 split-brain): a failed body // write (e.g. persist timeout) must not leave a new title over the old body. const collabToken = await this.getCollabTokenWithReauth(); - const mutation = await replacePageContent(pageId, doc, collabToken, this.apiUrl); + const mutation = await this.replacePage(pageId, doc, collabToken, this.apiUrl); // Body persisted successfully — now it is safe to set the title. if (title) { await this.client.post("/pages/update", { pageId, title }); @@ -1142,6 +1142,15 @@ export class DocmostClient { mutatePage(pageId, collabToken, apiUrl, transform) { return mutatePageContent(pageId, collabToken, apiUrl, transform); } + /** + * Full-document write seam over collaboration.replacePageContent. Production + * just delegates; it exists as an overridable method so the full-doc write + * tools (update_page_json, copy_page_content) can have their footnote- + * canonicalization binding unit-tested without a live Hocuspocus collab socket. + */ + replacePage(pageId, doc, collabToken, apiUrl) { + return replacePageContent(pageId, doc, collabToken, apiUrl); + } /** * Export a page to a single self-contained Docmost-flavoured markdown file: * meta block + body (with inline comment anchors + diagrams) + comment @@ -1270,7 +1279,7 @@ export class DocmostClient { // to the target (parity with the other full-doc write paths). const canonical = canonicalizeFootnotes(content); const collabToken = await this.getCollabTokenWithReauth(); - const mutation = await replacePageContent(targetPageId, canonical, collabToken, this.apiUrl); + const mutation = await this.replacePage(targetPageId, canonical, collabToken, this.apiUrl); return { success: true, sourcePageId, diff --git a/packages/mcp/build/lib/footnote-canonicalize.js b/packages/mcp/build/lib/footnote-canonicalize.js index 1df76154..d2d91400 100644 --- a/packages/mcp/build/lib/footnote-canonicalize.js +++ b/packages/mcp/build/lib/footnote-canonicalize.js @@ -174,12 +174,15 @@ export function canonicalizeFootnotes(doc) { deepEqualJson(topLevelLists[0].content, orderedDefs)) { return out; } - // 7) Otherwise rebuild: strip every footnotesList at ANY depth (collectDefinitions - // gathers defs recursively, so a list nested in a callout/blockquote would - // otherwise have its defs copied into the new list while the original - // survives — duplicates) and re-insert exactly one after the last meaningful - // (non-empty paragraph) top-level block. + // 7) Otherwise rebuild: strip every footnotesList AND every bare + // footnoteDefinition at ANY depth (collectDefinitions gathers defs + // recursively, so a list nested in a callout/blockquote — or a bare + // definition outside any list — would otherwise have its defs copied into the + // rebuilt list while the original survives in place → duplicates) and + // re-insert exactly one list after the last meaningful (non-empty paragraph) + // top-level block. stripFootnotesListsDeep(out); + stripFootnoteDefinitionsDeep(out); const top = out.content; let insertAt = top.length; while (insertAt > 0 && isEmptyParagraph(top[insertAt - 1])) @@ -196,3 +199,17 @@ function stripFootnotesListsDeep(node) { for (const child of node.content) stripFootnotesListsDeep(child); } +/** + * Remove every BARE `footnoteDefinition` node at ANY depth (mutates the given + * clone). Runs only in the rebuild path AFTER the lists are stripped, so it + * targets definitions that were sitting outside a list (e.g. hand-authored via a + * raw-JSON write path and nested in a callout); their content was already copied + * into the rebuilt list, so leaving the originals would duplicate them. + */ +function stripFootnoteDefinitionsDeep(node) { + if (!node || typeof node !== "object" || !Array.isArray(node.content)) + return; + node.content = node.content.filter((c) => !(c && c.type === FOOTNOTE_DEFINITION_NAME)); + for (const child of node.content) + stripFootnoteDefinitionsDeep(child); +} diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index 85a5a3a4..181c7e79 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -1356,7 +1356,7 @@ export class DocmostClient { // Write the BODY first, then the title (#159 split-brain): a failed body // write (e.g. persist timeout) must not leave a new title over the old body. const collabToken = await this.getCollabTokenWithReauth(); - const mutation = await replacePageContent( + const mutation = await this.replacePage( pageId, doc, collabToken, @@ -1451,6 +1451,21 @@ export class DocmostClient { return mutatePageContent(pageId, collabToken, apiUrl, transform); } + /** + * Full-document write seam over collaboration.replacePageContent. Production + * just delegates; it exists as an overridable method so the full-doc write + * tools (update_page_json, copy_page_content) can have their footnote- + * canonicalization binding unit-tested without a live Hocuspocus collab socket. + */ + protected replacePage( + pageId: string, + doc: any, + collabToken: string, + apiUrl: string, + ): Promise<{ doc?: any; verify?: any }> { + return replacePageContent(pageId, doc, collabToken, apiUrl); + } + /** * Export a page to a single self-contained Docmost-flavoured markdown file: * meta block + body (with inline comment anchors + diagrams) + comment @@ -1594,7 +1609,7 @@ export class DocmostClient { const canonical = canonicalizeFootnotes(content); const collabToken = await this.getCollabTokenWithReauth(); - const mutation = await replacePageContent( + const mutation = await this.replacePage( targetPageId, canonical, collabToken, diff --git a/packages/mcp/src/lib/footnote-canonicalize.ts b/packages/mcp/src/lib/footnote-canonicalize.ts index b4ae6e03..c83d41e4 100644 --- a/packages/mcp/src/lib/footnote-canonicalize.ts +++ b/packages/mcp/src/lib/footnote-canonicalize.ts @@ -183,12 +183,15 @@ export function canonicalizeFootnotes(doc: T): T { return out; } - // 7) Otherwise rebuild: strip every footnotesList at ANY depth (collectDefinitions - // gathers defs recursively, so a list nested in a callout/blockquote would - // otherwise have its defs copied into the new list while the original - // survives — duplicates) and re-insert exactly one after the last meaningful - // (non-empty paragraph) top-level block. + // 7) Otherwise rebuild: strip every footnotesList AND every bare + // footnoteDefinition at ANY depth (collectDefinitions gathers defs + // recursively, so a list nested in a callout/blockquote — or a bare + // definition outside any list — would otherwise have its defs copied into the + // rebuilt list while the original survives in place → duplicates) and + // re-insert exactly one list after the last meaningful (non-empty paragraph) + // top-level block. stripFootnotesListsDeep(out); + stripFootnoteDefinitionsDeep(out); const top: any[] = out.content; let insertAt = top.length; while (insertAt > 0 && isEmptyParagraph(top[insertAt - 1])) insertAt--; @@ -205,3 +208,18 @@ function stripFootnotesListsDeep(node: any): void { ); for (const child of node.content) stripFootnotesListsDeep(child); } + +/** + * Remove every BARE `footnoteDefinition` node at ANY depth (mutates the given + * clone). Runs only in the rebuild path AFTER the lists are stripped, so it + * targets definitions that were sitting outside a list (e.g. hand-authored via a + * raw-JSON write path and nested in a callout); their content was already copied + * into the rebuilt list, so leaving the originals would duplicate them. + */ +function stripFootnoteDefinitionsDeep(node: any): void { + if (!node || typeof node !== "object" || !Array.isArray(node.content)) return; + node.content = node.content.filter( + (c: any) => !(c && c.type === FOOTNOTE_DEFINITION_NAME), + ); + for (const child of node.content) stripFootnoteDefinitionsDeep(child); +} diff --git a/packages/mcp/test/mock/full-doc-write-canonicalize.test.mjs b/packages/mcp/test/mock/full-doc-write-canonicalize.test.mjs new file mode 100644 index 00000000..8fcdf4a2 --- /dev/null +++ b/packages/mcp/test/mock/full-doc-write-canonicalize.test.mjs @@ -0,0 +1,78 @@ +// Footnote-canonicalization binding tests for the MCP FULL-document write tools +// (issue #228, review #4): update_page_json and copy_page_content must persist a +// footnote-canonical doc. These override the `replacePage` seam (symmetric to the +// `mutatePage` seam used by the insert-footnote-wrapper test) to capture the +// persisted doc WITHOUT a live Hocuspocus collab socket. Symmetric to the +// server-side focus specs for createPage / updatePageContent('replace'). +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { DocmostClient } from "../../build/client.js"; + +const para = (...c) => ({ type: "paragraph", content: c }); +const ref = (id) => ({ type: "footnoteReference", attrs: { id } }); +const def = (id, text) => ({ + type: "footnoteDefinition", + attrs: { id }, + content: [{ type: "paragraph", content: [{ type: "text", text }] }], +}); +const list = (...d) => ({ type: "footnotesList", content: d }); + +function findAll(node, type, acc = []) { + if (!node || typeof node !== "object") return acc; + if (node.type === type) acc.push(node); + if (Array.isArray(node.content)) for (const c of node.content) findAll(c, type, acc); + return acc; +} +const defIds = (doc) => findAll(doc, "footnoteDefinition").map((d) => d.attrs.id); + +function makeClient(sourceDoc) { + const calls = { replaced: [] }; + class TestClient extends DocmostClient { + async ensureAuthenticated() {} + async getCollabTokenWithReauth() { + return "collab-token"; + } + async getPageRaw(pageId) { + return { id: pageId, slugId: "s", title: "P", spaceId: "sp", content: sourceDoc }; + } + async replacePage(pageId, doc, token, apiUrl) { + calls.replaced.push({ pageId, doc }); + return { doc, verify: { ok: true } }; + } + } + const client = new TestClient("http://127.0.0.1:1/api", "e@x.com", "pw"); + return { client, calls }; +} + +test("update_page_json canonicalizes the persisted full doc (out-of-order -> reference order)", async () => { + const { client, calls } = makeClient(); + const outOfOrder = { + type: "doc", + content: [ + para({ type: "text", text: "x" }, ref("b"), ref("a")), + list(def("a", "A"), def("b", "B")), + ], + }; + await client.updatePageJson("p1", outOfOrder); + assert.equal(calls.replaced.length, 1); + // Definitions reordered to reference order [b, a] before persisting. + assert.deepEqual(defIds(calls.replaced[0].doc), ["b", "a"]); + assert.equal(findAll(calls.replaced[0].doc, "footnotesList").length, 1); +}); + +test("copy_page_content canonicalizes the persisted copy (orphan definition dropped)", async () => { + const sourceDoc = { + type: "doc", + content: [ + para({ type: "text", text: "x" }, ref("a")), + list(def("a", "A"), def("orphan", "O")), + ], + }; + const { client, calls } = makeClient(sourceDoc); + const res = await client.copyPageContent("src", "dst"); + assert.equal(calls.replaced.length, 1); + assert.equal(calls.replaced[0].pageId, "dst"); + // The orphan definition is dropped by canonicalization before the copy lands. + assert.deepEqual(defIds(calls.replaced[0].doc), ["a"]); + assert.equal(res.success, true); +}); diff --git a/packages/mcp/test/unit/footnote-corpus.mjs b/packages/mcp/test/unit/footnote-corpus.mjs index 3a213491..648281f4 100644 --- a/packages/mcp/test/unit/footnote-corpus.mjs +++ b/packages/mcp/test/unit/footnote-corpus.mjs @@ -1130,6 +1130,97 @@ export const FOOTNOTE_CORPUS = [ ] } }, + { + "name": "bare footnoteDefinition nested in a callout is collected, NOT duplicated", + "input": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "see " + }, + { + "type": "footnoteReference", + "attrs": { + "id": "a" + } + } + ] + }, + { + "type": "callout", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "a" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "note A" + } + ] + } + ] + } + ] + } + ] + }, + "expected": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "see " + }, + { + "type": "footnoteReference", + "attrs": { + "id": "a" + } + } + ] + }, + { + "type": "callout", + "content": [] + }, + { + "type": "footnotesList", + "content": [ + { + "type": "footnoteDefinition", + "attrs": { + "id": "a" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "note A" + } + ] + } + ] + } + ] + } + ] + } + }, { "name": "no footnotes at all is unchanged", "input": {