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) <noreply@anthropic.com>
92 lines
3.1 KiB
TypeScript
92 lines
3.1 KiB
TypeScript
/**
|
|
* 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<T>(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)
|
|
);
|
|
}
|