fix(footnotes): survive duplicate-id definitions without collab divergence
Release-cycle red-team found two same-id footnoteDefinition nodes (trivially produced by markdown import [^d]: first / [^d]: second, or paste/duplicate) caused silent data loss: scan() used a last-wins Map and the sync rebuild (addToHistory:false, propagated via Yjs, un-undoable) dropped all but the last. Fix resolves collisions so BOTH survive, with a DETERMINISTIC id scheme so collaborators converge: - deriveFootnoteId(originalId, occurrence, taken): the k-th (k>=2) occurrence of id X becomes X__k, bumped with a deterministic alpha suffix only against the doc's own id set — a pure function of document state. No Math.random/Date.now on the sync or import paths (random uuid stays only in setFootnote, where a single user originates a brand-new id). - footnote-sync.resolveCollisions walks refs+defs in document order, re-ids duplicate references via setNodeMarkup and pairs them 1:1 with definitions; single SYNC_META-tagged transaction, returns null when canonical (terminates). - Markdown import (footnote.marked) + MCP mirror (collaboration.ts) dedup with the same deterministic scheme + marker rewrite; packages/mcp/build regenerated. - Paste plugin remaps colliding pasted ids against the current doc. Tests: two independent editors resolving the same duplicate-id doc produce IDENTICAL ids (the cross-client determinism guard that the random version would fail); both definitions survive the first edit; import dedup is deterministic. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -271,6 +271,44 @@ const FOOTNOTE_REF_RE = /\[\^([^\]\s]+)\]/;
|
||||
function escapeFootnoteAttr(value) {
|
||||
return String(value).replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
function escapeFootnoteRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
/**
|
||||
* Derive a DETERMINISTIC unique footnote id for the k-th (k >= 2) occurrence of
|
||||
* an original id `X` during definition dedup.
|
||||
*
|
||||
* EXACT MIRROR of editor-ext `deriveFootnoteId`
|
||||
* (packages/editor-ext/src/lib/footnote/footnote-util.ts). These two copies MUST
|
||||
* STAY IN SYNC: the same markdown imported through the editor and through this
|
||||
* MCP path has to produce identical ids, and the sync plugin (which re-ids on
|
||||
* every collaborating client) relies on the same scheme to converge. NEVER use
|
||||
* Math.random()/Date.now()/uuid here — a random id would diverge across clients.
|
||||
*
|
||||
* Scheme: base candidate `${originalId}__${occurrence}` (e.g. `X__2`), bumped
|
||||
* with a stable alphabetic suffix (`X__2b`, `X__2c`, ...) until it is not in
|
||||
* `taken` (the set of ids already present / already minted — pure doc state).
|
||||
*/
|
||||
function deriveFootnoteId(originalId, occurrence, taken) {
|
||||
let candidate = `${originalId}__${occurrence}`;
|
||||
let n = 0;
|
||||
while (taken.has(candidate)) {
|
||||
n += 1;
|
||||
candidate = `${originalId}__${occurrence}${footnoteSuffix(n)}`;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
/** Map 1 -> "b", 2 -> "c", ... (mirror of editor-ext `suffix`). */
|
||||
function footnoteSuffix(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;
|
||||
}
|
||||
const footnoteRefMarkedExtension = {
|
||||
name: "footnoteRef",
|
||||
level: "inline",
|
||||
@@ -319,11 +357,43 @@ function extractFootnotes(markdown) {
|
||||
}
|
||||
if (defs.length === 0)
|
||||
return { body: markdown, section: "" };
|
||||
// De-duplicate colliding definition ids (mirror of editor-ext
|
||||
// extractFootnoteDefinitions). Two definitions sharing an id would otherwise
|
||||
// collapse into one footnote downstream; rename each colliding id to a
|
||||
// DETERMINISTIC derived one (NOT random) and rewrite the corresponding `[^id]`
|
||||
// marker so the (reference, definition) pairing stays 1:1. Determinism lets
|
||||
// the same markdown imported here and via the editor produce identical ids.
|
||||
let dedupedBody = bodyLines.join("\n");
|
||||
const taken = new Set(defs.map((d) => d.id));
|
||||
const seenDefIds = new Map();
|
||||
for (const def of defs) {
|
||||
const originalId = def.id;
|
||||
const count = seenDefIds.get(originalId) ?? 0;
|
||||
seenDefIds.set(originalId, count + 1);
|
||||
if (count === 0)
|
||||
continue; // first definition keeps its id
|
||||
const newId = deriveFootnoteId(originalId, count + 1, taken);
|
||||
taken.add(newId);
|
||||
def.id = newId;
|
||||
// Remaining `[^originalId]` matches: index 0 = keeper's marker (left alone),
|
||||
// index 1 = this duplicate's marker. Rewrite index 1.
|
||||
let occurrence = 0;
|
||||
let rewritten = false;
|
||||
const re = new RegExp(`\\[\\^${escapeFootnoteRegExp(originalId)}\\]`, "g");
|
||||
dedupedBody = dedupedBody.replace(re, (match) => {
|
||||
const idx = occurrence++;
|
||||
if (!rewritten && idx === 1) {
|
||||
rewritten = true;
|
||||
return `[^${newId}]`;
|
||||
}
|
||||
return match;
|
||||
});
|
||||
}
|
||||
const inner = defs
|
||||
.map((d) => `<div data-footnote-def data-id="${escapeFootnoteAttr(d.id)}"><p>${marked.parseInline(d.text || "")}</p></div>`)
|
||||
.join("");
|
||||
return {
|
||||
body: bodyLines.join("\n"),
|
||||
body: dedupedBody,
|
||||
section: `<section data-footnotes>${inner}</section>`,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user