openwriter 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/assets/index-B3iORmCT.css +1 -0
- package/dist/client/assets/index-B5MXw2pg.js +212 -0
- package/dist/client/index.html +2 -2
- package/dist/server/comments.js +256 -0
- package/dist/server/documents.js +60 -18
- package/dist/server/helpers.js +63 -8
- package/dist/server/index.js +94 -40
- package/dist/server/logger.js +246 -0
- package/dist/server/markdown-serialize.js +122 -25
- package/dist/server/mcp.js +289 -77
- package/dist/server/node-blocks.js +22 -4
- package/dist/server/node-matcher.js +57 -5
- package/dist/server/pending-overlay.js +845 -0
- package/dist/server/state.js +981 -78
- package/dist/server/versions.js +18 -0
- package/dist/server/workspaces.js +15 -0
- package/dist/server/ws.js +184 -37
- package/package.json +1 -1
- package/skill/SKILL.md +30 -19
- package/dist/client/assets/index-BxI3DazW.js +0 -212
- package/dist/client/assets/index-OV13QtgQ.css +0 -1
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending overlay — the in-memory + sidecar layer that holds proposed
|
|
3
|
+
* changes to a document. The disk `.md` file is canonical only; the
|
|
4
|
+
* overlay is the agent's proposed mutations that the user hasn't
|
|
5
|
+
* accepted or rejected yet.
|
|
6
|
+
*
|
|
7
|
+
* Architectural model:
|
|
8
|
+
* - Disk = canonical content. Clean markdown. External editors see
|
|
9
|
+
* what the user sees as "the doc," no pending decoration metadata.
|
|
10
|
+
* - Overlay = in-memory `Map<docId, PendingEntry[]>` mirrored to a
|
|
11
|
+
* sidecar JSON at `_pending/{docId}.json` so pending state survives
|
|
12
|
+
* graceful restarts. Sidecar is keyed by docId because filenames
|
|
13
|
+
* can change (renames) but docIds are stable.
|
|
14
|
+
* - Live state = canonical merged with overlay. Browser and MCP see
|
|
15
|
+
* this merged view — TipTap nodes with `pendingStatus` attrs.
|
|
16
|
+
*
|
|
17
|
+
* Pending entries are keyed by `nodeId` (the stable per-block ID the
|
|
18
|
+
* matcher maintains). This is the load-bearing decision — position
|
|
19
|
+
* keying fails when canonical content shifts (external edit,
|
|
20
|
+
* restore_version), node IDs survive content changes by design.
|
|
21
|
+
*
|
|
22
|
+
* Reload flow:
|
|
23
|
+
* 1. Canonical reloaded from disk.
|
|
24
|
+
* 2. Matcher pairs old in-memory nodes to new canonical nodes via
|
|
25
|
+
* fingerprint, returning nodeId continuity.
|
|
26
|
+
* 3. Overlay re-applied to new canonical by nodeId. Entries whose
|
|
27
|
+
* anchor nodeId no longer exists become "orphan" (rewrite→insert,
|
|
28
|
+
* delete→discarded). Entries whose target node exists but the
|
|
29
|
+
* content drifted from `originalBaseline` become "staleBaseline"
|
|
30
|
+
* (surfaced for review).
|
|
31
|
+
*
|
|
32
|
+
* adr: adr/pending-overlay-model.md
|
|
33
|
+
*/
|
|
34
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, readdirSync, rmSync } from 'fs';
|
|
35
|
+
import { join } from 'path';
|
|
36
|
+
import { getDataDir, atomicWriteFileSync } from './helpers.js';
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// DIAGNOSTIC HELPERS — node-text preview + entry summary for log readability
|
|
39
|
+
// ============================================================================
|
|
40
|
+
// The diagLog function moved to `./logger.ts` (structured logger). Imported
|
|
41
|
+
// + re-exported here as a shim so existing import sites keep working; new
|
|
42
|
+
// code should call `logger.{level}(category, event, ...)` directly.
|
|
43
|
+
// adr: adr/logging-system.md
|
|
44
|
+
import { diagLog, redactText } from './logger.js';
|
|
45
|
+
export { diagLog };
|
|
46
|
+
/** Extract a short text preview from a TipTap node for log readability.
|
|
47
|
+
* Returns the first ~60 chars of concatenated text content, or the node type
|
|
48
|
+
* if there's no text. Routes through `redactText` so document content is
|
|
49
|
+
* redacted when the log config has `includeText: false` (default for public
|
|
50
|
+
* installs — privacy by default). adr: adr/logging-system.md */
|
|
51
|
+
export function nodeTextPreview(node, limit = 60) {
|
|
52
|
+
if (!node)
|
|
53
|
+
return '<null>';
|
|
54
|
+
let text = '';
|
|
55
|
+
function walk(n) {
|
|
56
|
+
if (!n)
|
|
57
|
+
return;
|
|
58
|
+
if (typeof n.text === 'string') {
|
|
59
|
+
text += n.text;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (Array.isArray(n.content))
|
|
63
|
+
n.content.forEach(walk);
|
|
64
|
+
}
|
|
65
|
+
walk(node);
|
|
66
|
+
if (!text)
|
|
67
|
+
return `<${node.type || 'unknown'}>`;
|
|
68
|
+
const collapsed = text.replace(/\s+/g, ' ').trim();
|
|
69
|
+
const truncated = collapsed.length > limit ? collapsed.slice(0, limit) + '…' : collapsed;
|
|
70
|
+
return redactText(truncated);
|
|
71
|
+
}
|
|
72
|
+
/** One-line summary of a pending entry for log readability. */
|
|
73
|
+
export function entrySummary(e) {
|
|
74
|
+
const newPrev = nodeTextPreview(e.newContent);
|
|
75
|
+
const origPrev = e.originalBaseline ? nodeTextPreview(e.originalBaseline) : '<none>';
|
|
76
|
+
const identity = (e.status === 'rewrite' && e.newContent && e.originalBaseline && newPrev === origPrev)
|
|
77
|
+
? ' IDENTITY-WARNING(new===orig)'
|
|
78
|
+
: '';
|
|
79
|
+
return `${e.nodeId}/${e.status} new="${newPrev}" orig="${origPrev}"${identity}`;
|
|
80
|
+
}
|
|
81
|
+
// ============================================================================
|
|
82
|
+
// SIDECAR I/O
|
|
83
|
+
// ============================================================================
|
|
84
|
+
function getPendingDir() { return join(getDataDir(), '_pending'); }
|
|
85
|
+
function getSidecarPath(docId) {
|
|
86
|
+
return join(getPendingDir(), `${docId}.json`);
|
|
87
|
+
}
|
|
88
|
+
function ensurePendingDir() {
|
|
89
|
+
const dir = getPendingDir();
|
|
90
|
+
if (!existsSync(dir))
|
|
91
|
+
mkdirSync(dir, { recursive: true });
|
|
92
|
+
}
|
|
93
|
+
export function loadOverlay(docId) {
|
|
94
|
+
if (!docId)
|
|
95
|
+
return [];
|
|
96
|
+
const path = getSidecarPath(docId);
|
|
97
|
+
if (!existsSync(path))
|
|
98
|
+
return [];
|
|
99
|
+
try {
|
|
100
|
+
const raw = readFileSync(path, 'utf-8');
|
|
101
|
+
const data = JSON.parse(raw);
|
|
102
|
+
if (Array.isArray(data?.entries))
|
|
103
|
+
return data.entries;
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
export function saveOverlay(docId, entries) {
|
|
111
|
+
if (!docId)
|
|
112
|
+
return;
|
|
113
|
+
// Read previous on-disk sidecar BEFORE we overwrite, so we can log the diff.
|
|
114
|
+
// adr: adr/pending-overlay-model.md
|
|
115
|
+
const prevEntries = loadOverlay(docId);
|
|
116
|
+
const prevById = new Map(prevEntries.map((e) => [e.nodeId, e]));
|
|
117
|
+
// Deduplicate incoming entries by nodeId. The Map collapse enforces the
|
|
118
|
+
// invariant "each nodeId appears at most once in the sidecar."
|
|
119
|
+
//
|
|
120
|
+
// TRIPWIRE — post-fix expectation: this dedup should never drop anything.
|
|
121
|
+
// The historical generator (non-idempotent applyOverlay) is gone; the
|
|
122
|
+
// splitMergedDoc path is now symmetric with the serializer; the read-side
|
|
123
|
+
// paths route through applyOverlayPure which is idempotent by construction.
|
|
124
|
+
// If `droppedDuplicates > 0` after those fixes landed, something
|
|
125
|
+
// regressed — file a bug. The dedup itself stays as cheap defense, but
|
|
126
|
+
// any non-zero drop count is a signal, not normal operation.
|
|
127
|
+
// adr: adr/pending-overlay-model.md
|
|
128
|
+
const dedupedMap = new Map();
|
|
129
|
+
let droppedDuplicates = 0;
|
|
130
|
+
for (const e of entries) {
|
|
131
|
+
if (dedupedMap.has(e.nodeId)) {
|
|
132
|
+
droppedDuplicates++;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
dedupedMap.set(e.nodeId, e);
|
|
136
|
+
}
|
|
137
|
+
if (droppedDuplicates > 0) {
|
|
138
|
+
diagLog(`[Overlay] TRIPWIRE docId=${docId} dropped ${droppedDuplicates} duplicate entries by nodeId — generator regressed, investigate`);
|
|
139
|
+
}
|
|
140
|
+
const dedupedEntries = Array.from(dedupedMap.values());
|
|
141
|
+
const newById = dedupedMap;
|
|
142
|
+
const changes = [];
|
|
143
|
+
for (const e of dedupedEntries) {
|
|
144
|
+
const prev = prevById.get(e.nodeId);
|
|
145
|
+
if (!prev) {
|
|
146
|
+
changes.push(`+${entrySummary(e)}`);
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
const prevSig = entrySummary(prev);
|
|
150
|
+
const newSig = entrySummary(e);
|
|
151
|
+
if (prevSig !== newSig)
|
|
152
|
+
changes.push(`~${e.nodeId} BEFORE="${nodeTextPreview(prev.newContent)}" AFTER="${nodeTextPreview(e.newContent)}"`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
for (const e of prevEntries) {
|
|
156
|
+
if (!newById.has(e.nodeId))
|
|
157
|
+
changes.push(`-${entrySummary(e)}`);
|
|
158
|
+
}
|
|
159
|
+
if (dedupedEntries.length === 0) {
|
|
160
|
+
if (prevEntries.length > 0) {
|
|
161
|
+
diagLog(`[Overlay] SAVE docId=${docId} → DELETE (was ${prevEntries.length} entries)`);
|
|
162
|
+
}
|
|
163
|
+
deleteOverlay(docId);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
ensurePendingDir();
|
|
167
|
+
const path = getSidecarPath(docId);
|
|
168
|
+
atomicWriteFileSync(path, JSON.stringify({ version: 1, entries: dedupedEntries }, null, 2));
|
|
169
|
+
if (changes.length > 0) {
|
|
170
|
+
diagLog(`[Overlay] SAVE docId=${docId} entries=${dedupedEntries.length} changes=[${changes.join(' | ')}]`);
|
|
171
|
+
}
|
|
172
|
+
// TRIPWIRE — identity-rewrite detection. A rewrite where new===orig is a
|
|
173
|
+
// degenerate state: nothing to review, the entry shouldn't exist.
|
|
174
|
+
//
|
|
175
|
+
// Post-fix expectation: unreachable. The two known generators are closed:
|
|
176
|
+
// - preview-swap echo (ReviewPanel.tsx togglePreview order flip, ADR
|
|
177
|
+
// entry 2026-05-17)
|
|
178
|
+
// - splitMergedDoc canonical-drift (stripPendingFromDoc restoration,
|
|
179
|
+
// ADR entry 2026-05-18)
|
|
180
|
+
//
|
|
181
|
+
// If this log fires post-fix, a third generator path exists — investigate
|
|
182
|
+
// and shut it at the source rather than papering over via auto-resolve.
|
|
183
|
+
// The earlier "Open follow-up" to refuse-to-persist these at saveOverlay
|
|
184
|
+
// was deliberately NOT shipped: legitimate rewrites can converge to
|
|
185
|
+
// identity via canonical evolution and should clear naturally at next
|
|
186
|
+
// save, not get silently rejected here.
|
|
187
|
+
// adr: adr/pending-overlay-model.md
|
|
188
|
+
for (const e of dedupedEntries) {
|
|
189
|
+
if (e.status === 'rewrite' && e.newContent && e.originalBaseline) {
|
|
190
|
+
const newPrev = nodeTextPreview(e.newContent);
|
|
191
|
+
const origPrev = nodeTextPreview(e.originalBaseline);
|
|
192
|
+
if (newPrev === origPrev) {
|
|
193
|
+
diagLog(`[Overlay] TRIPWIRE docId=${docId} nodeId=${e.nodeId} IDENTITY-REWRITE new===orig text="${newPrev}" — generator regressed, investigate`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
export function deleteOverlay(docId) {
|
|
199
|
+
if (!docId)
|
|
200
|
+
return;
|
|
201
|
+
const path = getSidecarPath(docId);
|
|
202
|
+
if (existsSync(path)) {
|
|
203
|
+
try {
|
|
204
|
+
unlinkSync(path);
|
|
205
|
+
}
|
|
206
|
+
catch { /* best-effort */ }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** Clear every sidecar file. Called on profile switch / clearAllCaches. */
|
|
210
|
+
export function clearAllOverlays() {
|
|
211
|
+
const dir = getPendingDir();
|
|
212
|
+
if (!existsSync(dir))
|
|
213
|
+
return;
|
|
214
|
+
try {
|
|
215
|
+
rmSync(dir, { recursive: true, force: true });
|
|
216
|
+
}
|
|
217
|
+
catch { /* best-effort */ }
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* One-time repair pass: walk every sidecar in _pending/, dedupe entries by
|
|
221
|
+
* nodeId (keep first occurrence so the original anchor wins over the
|
|
222
|
+
* self-referential corrupt anchors that the non-idempotent applyOverlay
|
|
223
|
+
* bug produced), and rewrite the file. Runs once at startup as defense
|
|
224
|
+
* against historical corruption.
|
|
225
|
+
*
|
|
226
|
+
* BACKWARD-COMPAT ONLY post-fix. The generators that produced duplicate
|
|
227
|
+
* entries are architecturally closed (idempotent applyOverlayPure + correct
|
|
228
|
+
* splitMergedDoc). This pass exists to clean up sidecars that were corrupted
|
|
229
|
+
* BEFORE those fixes landed. If `[Overlay] STARTUP-REPAIR` log lines fire
|
|
230
|
+
* on a profile that has been running on post-fix builds, that's a tripwire:
|
|
231
|
+
* a new corruption path leaked through, find and fix it at the source.
|
|
232
|
+
* Once enough time has passed that all production sidecars have been
|
|
233
|
+
* rewritten cleanly through the new save paths, this can be removed.
|
|
234
|
+
* adr: adr/pending-overlay-model.md
|
|
235
|
+
*/
|
|
236
|
+
export function repairOverlaysOnStartup() {
|
|
237
|
+
const dir = getPendingDir();
|
|
238
|
+
if (!existsSync(dir))
|
|
239
|
+
return;
|
|
240
|
+
let files;
|
|
241
|
+
try {
|
|
242
|
+
files = readdirSync(dir).filter((f) => f.endsWith('.json'));
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
let repaired = 0;
|
|
248
|
+
let totalDropped = 0;
|
|
249
|
+
for (const f of files) {
|
|
250
|
+
const path = join(dir, f);
|
|
251
|
+
try {
|
|
252
|
+
const raw = readFileSync(path, 'utf-8');
|
|
253
|
+
const parsed = JSON.parse(raw);
|
|
254
|
+
if (!Array.isArray(parsed?.entries))
|
|
255
|
+
continue;
|
|
256
|
+
const dedup = new Map();
|
|
257
|
+
let dropped = 0;
|
|
258
|
+
for (const e of parsed.entries) {
|
|
259
|
+
if (!e?.nodeId)
|
|
260
|
+
continue;
|
|
261
|
+
if (dedup.has(e.nodeId)) {
|
|
262
|
+
dropped++;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
dedup.set(e.nodeId, e);
|
|
266
|
+
}
|
|
267
|
+
if (dropped > 0) {
|
|
268
|
+
const cleaned = Array.from(dedup.values());
|
|
269
|
+
atomicWriteFileSync(path, JSON.stringify({ version: 1, entries: cleaned }, null, 2));
|
|
270
|
+
repaired++;
|
|
271
|
+
totalDropped += dropped;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
catch { /* skip unreadable sidecar */ }
|
|
275
|
+
}
|
|
276
|
+
if (repaired > 0) {
|
|
277
|
+
diagLog(`[Overlay] TRIPWIRE STARTUP-REPAIR repaired=${repaired} files, dropped=${totalDropped} duplicate entries — should be unreachable post-fix, investigate`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// ============================================================================
|
|
281
|
+
// EXTRACT — walk a doc-with-pending-attrs and produce an overlay
|
|
282
|
+
// ============================================================================
|
|
283
|
+
/**
|
|
284
|
+
* Walk a TipTap doc that has pending attrs on its nodes and extract those
|
|
285
|
+
* attrs into a PendingEntry[] keyed by nodeId. Does NOT mutate the doc.
|
|
286
|
+
*
|
|
287
|
+
* For inserts: captures the previous-sibling nodeId (or null) and the
|
|
288
|
+
* parent nodeId so the entry can be re-anchored on reload even if the
|
|
289
|
+
* doc tree has changed around it.
|
|
290
|
+
*
|
|
291
|
+
* For rewrites: captures the node's `pendingOriginalContent` (if
|
|
292
|
+
* present) as the `originalBaseline` so future reloads can detect
|
|
293
|
+
* baseline drift.
|
|
294
|
+
*
|
|
295
|
+
* Insert-pending nodes are extracted with the `newContent` carrying
|
|
296
|
+
* the node minus the pending attrs (i.e. what to insert).
|
|
297
|
+
*/
|
|
298
|
+
export function extractOverlay(doc) {
|
|
299
|
+
const entries = [];
|
|
300
|
+
function walk(nodes, parentNodeId) {
|
|
301
|
+
if (!Array.isArray(nodes))
|
|
302
|
+
return;
|
|
303
|
+
let prevSiblingId = null;
|
|
304
|
+
for (const node of nodes) {
|
|
305
|
+
const nodeId = node?.attrs?.id;
|
|
306
|
+
const status = node?.attrs?.pendingStatus;
|
|
307
|
+
if (nodeId && status) {
|
|
308
|
+
const entry = {
|
|
309
|
+
nodeId,
|
|
310
|
+
status,
|
|
311
|
+
};
|
|
312
|
+
if (status === 'insert') {
|
|
313
|
+
entry.afterNodeId = prevSiblingId;
|
|
314
|
+
entry.parentNodeId = parentNodeId;
|
|
315
|
+
// For inserts, the node itself IS the content. Capture it as
|
|
316
|
+
// newContent with pending attrs stripped (the overlay carries
|
|
317
|
+
// them separately).
|
|
318
|
+
entry.newContent = stripPendingAttrs(node);
|
|
319
|
+
}
|
|
320
|
+
else if (status === 'rewrite') {
|
|
321
|
+
// The node's current content is the agent's proposed prose.
|
|
322
|
+
// pendingOriginalContent (if present) is the snapshot baseline.
|
|
323
|
+
entry.newContent = stripPendingAttrs(node);
|
|
324
|
+
if (node.attrs?.pendingOriginalContent) {
|
|
325
|
+
entry.originalBaseline = node.attrs.pendingOriginalContent;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// status === 'delete': no newContent, no anchor (node still in canonical)
|
|
329
|
+
// Sub-paragraph enhance fields
|
|
330
|
+
if (node.attrs?.pendingGroupId)
|
|
331
|
+
entry.pendingGroupId = node.attrs.pendingGroupId;
|
|
332
|
+
if (node.attrs?.pendingTextEdits)
|
|
333
|
+
entry.pendingTextEdits = node.attrs.pendingTextEdits;
|
|
334
|
+
if (node.attrs?.pendingSelectionFrom != null)
|
|
335
|
+
entry.pendingSelectionFrom = node.attrs.pendingSelectionFrom;
|
|
336
|
+
if (node.attrs?.pendingSelectionTo != null)
|
|
337
|
+
entry.pendingSelectionTo = node.attrs.pendingSelectionTo;
|
|
338
|
+
if (node.attrs?.pendingOriginalFrom != null)
|
|
339
|
+
entry.pendingOriginalFrom = node.attrs.pendingOriginalFrom;
|
|
340
|
+
if (node.attrs?.pendingOriginalTo != null)
|
|
341
|
+
entry.pendingOriginalTo = node.attrs.pendingOriginalTo;
|
|
342
|
+
entries.push(entry);
|
|
343
|
+
}
|
|
344
|
+
// Track previous sibling for inserts (only at this level; recursion
|
|
345
|
+
// resets prevSiblingId for child arrays).
|
|
346
|
+
if (nodeId)
|
|
347
|
+
prevSiblingId = nodeId;
|
|
348
|
+
if (node?.content)
|
|
349
|
+
walk(node.content, nodeId || parentNodeId);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
walk(doc?.content || [], null);
|
|
353
|
+
return entries;
|
|
354
|
+
}
|
|
355
|
+
const PENDING_ATTR_KEYS = [
|
|
356
|
+
'pendingStatus', 'pendingOriginalContent', 'pendingGroupId',
|
|
357
|
+
'pendingTextEdits', 'pendingSelectionFrom', 'pendingSelectionTo',
|
|
358
|
+
'pendingOriginalFrom', 'pendingOriginalTo', 'pendingOrphan', 'pendingStaleBaseline',
|
|
359
|
+
];
|
|
360
|
+
function stripPendingAttrs(node) {
|
|
361
|
+
const cloned = JSON.parse(JSON.stringify(node));
|
|
362
|
+
if (cloned.attrs) {
|
|
363
|
+
for (const k of PENDING_ATTR_KEYS)
|
|
364
|
+
delete cloned.attrs[k];
|
|
365
|
+
}
|
|
366
|
+
if (cloned.content)
|
|
367
|
+
cloned.content = cloned.content.map((c) => stripPendingAttrs(c));
|
|
368
|
+
return cloned;
|
|
369
|
+
}
|
|
370
|
+
// ============================================================================
|
|
371
|
+
// APPLY — merge overlay onto a canonical doc, with orphan + stale-baseline
|
|
372
|
+
// classification
|
|
373
|
+
// ============================================================================
|
|
374
|
+
/**
|
|
375
|
+
* Mutate `canonical` to layer in the overlay's pending decorations.
|
|
376
|
+
* Returns classification: which entries became orphan (anchor gone) and
|
|
377
|
+
* which became stale-baseline (anchor present but content drifted).
|
|
378
|
+
*
|
|
379
|
+
* Orphan handling:
|
|
380
|
+
* - `rewrite` orphan → converted to `insert` with `pendingOrphan: true`,
|
|
381
|
+
* placed at the position of its last-known parent's tail (or end of
|
|
382
|
+
* doc if parent also gone). The agent's creative content is preserved.
|
|
383
|
+
* - `insert` orphan → kept as `insert` with `pendingOrphan: true`,
|
|
384
|
+
* placed via the same fallback positioning.
|
|
385
|
+
* - `delete` orphan → discarded (the target was already gone, the
|
|
386
|
+
* delete intent is moot).
|
|
387
|
+
*
|
|
388
|
+
* Stale-baseline detection (rewrites only): compare canonical node's
|
|
389
|
+
* current content to `entry.originalBaseline`. If different, set
|
|
390
|
+
* `pendingStaleBaseline: true` on the merged node.
|
|
391
|
+
*/
|
|
392
|
+
export function applyOverlay(canonical, entries) {
|
|
393
|
+
const orphans = [];
|
|
394
|
+
const staleBaseline = [];
|
|
395
|
+
if (entries.length > 0) {
|
|
396
|
+
diagLog(`[Overlay] APPLY entries=${entries.length}: ${entries.map(entrySummary).join(' | ')}`);
|
|
397
|
+
}
|
|
398
|
+
// Build a nodeId → node map for the canonical doc (read-side lookup).
|
|
399
|
+
const nodeById = new Map();
|
|
400
|
+
function indexNodes(nodes) {
|
|
401
|
+
if (!Array.isArray(nodes))
|
|
402
|
+
return;
|
|
403
|
+
for (const node of nodes) {
|
|
404
|
+
const id = node?.attrs?.id;
|
|
405
|
+
if (id)
|
|
406
|
+
nodeById.set(id, node);
|
|
407
|
+
if (node?.content)
|
|
408
|
+
indexNodes(node.content);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
indexNodes(canonical?.content || []);
|
|
412
|
+
// Helper: find the parent array and index for a given nodeId.
|
|
413
|
+
function findNodeWithParent(targetId) {
|
|
414
|
+
function search(nodes) {
|
|
415
|
+
if (!Array.isArray(nodes))
|
|
416
|
+
return null;
|
|
417
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
418
|
+
if (nodes[i]?.attrs?.id === targetId)
|
|
419
|
+
return { parent: nodes, index: i };
|
|
420
|
+
if (nodes[i]?.content) {
|
|
421
|
+
const r = search(nodes[i].content);
|
|
422
|
+
if (r)
|
|
423
|
+
return r;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
return search(canonical?.content || []);
|
|
429
|
+
}
|
|
430
|
+
// Process deletes and rewrites first (in-place mutations on existing nodes).
|
|
431
|
+
// Then process inserts (which add new nodes that wouldn't affect lookups).
|
|
432
|
+
for (const entry of entries) {
|
|
433
|
+
if (entry.status === 'insert')
|
|
434
|
+
continue;
|
|
435
|
+
const target = nodeById.get(entry.nodeId);
|
|
436
|
+
if (!target) {
|
|
437
|
+
// Anchor gone. Delete-orphans are discarded. Rewrite-orphans get
|
|
438
|
+
// converted to inserts (handled below).
|
|
439
|
+
if (entry.status === 'delete') {
|
|
440
|
+
// discard silently
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
orphans.push(entry);
|
|
444
|
+
}
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
target.attrs = target.attrs || {};
|
|
448
|
+
target.attrs.pendingStatus = entry.status;
|
|
449
|
+
if (entry.status === 'rewrite') {
|
|
450
|
+
// Stale-baseline check: compare canonical content to baseline.
|
|
451
|
+
if (entry.originalBaseline && !sameContent(target, entry.originalBaseline)) {
|
|
452
|
+
target.attrs.pendingStaleBaseline = true;
|
|
453
|
+
staleBaseline.push(entry);
|
|
454
|
+
}
|
|
455
|
+
// Stash original baseline so reject can restore it.
|
|
456
|
+
if (entry.originalBaseline) {
|
|
457
|
+
target.attrs.pendingOriginalContent = entry.originalBaseline;
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
// No baseline recorded — synthesize from current canonical content
|
|
461
|
+
// (best-effort; reject will restore to "current state").
|
|
462
|
+
target.attrs.pendingOriginalContent = sanitizeNodeForBaseline(target);
|
|
463
|
+
}
|
|
464
|
+
// Replace target's content with the proposed new content.
|
|
465
|
+
if (entry.newContent?.content) {
|
|
466
|
+
target.content = entry.newContent.content;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// status === 'delete': just the pendingStatus marker; content unchanged.
|
|
470
|
+
// Carry sub-paragraph enhance fields.
|
|
471
|
+
if (entry.pendingGroupId)
|
|
472
|
+
target.attrs.pendingGroupId = entry.pendingGroupId;
|
|
473
|
+
if (entry.pendingTextEdits)
|
|
474
|
+
target.attrs.pendingTextEdits = entry.pendingTextEdits;
|
|
475
|
+
if (entry.pendingSelectionFrom != null)
|
|
476
|
+
target.attrs.pendingSelectionFrom = entry.pendingSelectionFrom;
|
|
477
|
+
if (entry.pendingSelectionTo != null)
|
|
478
|
+
target.attrs.pendingSelectionTo = entry.pendingSelectionTo;
|
|
479
|
+
if (entry.pendingOriginalFrom != null)
|
|
480
|
+
target.attrs.pendingOriginalFrom = entry.pendingOriginalFrom;
|
|
481
|
+
if (entry.pendingOriginalTo != null)
|
|
482
|
+
target.attrs.pendingOriginalTo = entry.pendingOriginalTo;
|
|
483
|
+
}
|
|
484
|
+
// Inserts: find anchor and splice the new node in.
|
|
485
|
+
for (const entry of entries) {
|
|
486
|
+
if (entry.status !== 'insert')
|
|
487
|
+
continue;
|
|
488
|
+
if (!entry.newContent)
|
|
489
|
+
continue;
|
|
490
|
+
const newNode = JSON.parse(JSON.stringify(entry.newContent));
|
|
491
|
+
newNode.attrs = newNode.attrs || {};
|
|
492
|
+
newNode.attrs.id = entry.nodeId;
|
|
493
|
+
newNode.attrs.pendingStatus = 'insert';
|
|
494
|
+
if (entry.pendingGroupId)
|
|
495
|
+
newNode.attrs.pendingGroupId = entry.pendingGroupId;
|
|
496
|
+
// Try anchor: afterNodeId first, then parentNodeId.
|
|
497
|
+
let placed = false;
|
|
498
|
+
if (entry.afterNodeId) {
|
|
499
|
+
const loc = findNodeWithParent(entry.afterNodeId);
|
|
500
|
+
if (loc) {
|
|
501
|
+
loc.parent.splice(loc.index + 1, 0, newNode);
|
|
502
|
+
placed = true;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (!placed && entry.parentNodeId) {
|
|
506
|
+
const parentLoc = findNodeWithParent(entry.parentNodeId);
|
|
507
|
+
if (parentLoc) {
|
|
508
|
+
const parent = parentLoc.parent[parentLoc.index];
|
|
509
|
+
parent.content = parent.content || [];
|
|
510
|
+
parent.content.unshift(newNode);
|
|
511
|
+
placed = true;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (!placed && entry.afterNodeId === null && entry.parentNodeId === null) {
|
|
515
|
+
// Originally at doc root, no previous sibling — insert at start.
|
|
516
|
+
canonical.content = canonical.content || [];
|
|
517
|
+
canonical.content.unshift(newNode);
|
|
518
|
+
placed = true;
|
|
519
|
+
}
|
|
520
|
+
if (!placed) {
|
|
521
|
+
// Both anchors gone. Mark as orphan and append at end of doc with
|
|
522
|
+
// pendingOrphan: true so the user can see and decide.
|
|
523
|
+
newNode.attrs.pendingOrphan = true;
|
|
524
|
+
canonical.content = canonical.content || [];
|
|
525
|
+
canonical.content.push(newNode);
|
|
526
|
+
orphans.push(entry);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
// Convert rewrite-orphans to orphan-inserts at the end of the doc, with
|
|
530
|
+
// pendingOrphan: true. Preserves the agent's proposed content.
|
|
531
|
+
for (const entry of orphans) {
|
|
532
|
+
if (entry.status === 'rewrite' && entry.newContent) {
|
|
533
|
+
const newNode = JSON.parse(JSON.stringify(entry.newContent));
|
|
534
|
+
newNode.attrs = newNode.attrs || {};
|
|
535
|
+
newNode.attrs.id = entry.nodeId;
|
|
536
|
+
newNode.attrs.pendingStatus = 'insert';
|
|
537
|
+
newNode.attrs.pendingOrphan = true;
|
|
538
|
+
if (entry.pendingGroupId)
|
|
539
|
+
newNode.attrs.pendingGroupId = entry.pendingGroupId;
|
|
540
|
+
canonical.content = canonical.content || [];
|
|
541
|
+
canonical.content.push(newNode);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return { orphans, staleBaseline };
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Pure version of applyOverlay. Returns a new merged doc; does NOT mutate
|
|
548
|
+
* the canonical input. Idempotent by construction: if `canonical` already
|
|
549
|
+
* contains a node with an insert entry's nodeId, the entry is skipped
|
|
550
|
+
* (treated as already applied). This is the safe-to-call-anywhere version.
|
|
551
|
+
*
|
|
552
|
+
* The architectural invariant this enforces: applying the same overlay
|
|
553
|
+
* to the same canonical twice produces the same result as applying it
|
|
554
|
+
* once. The pre-existing mutating applyOverlay violated this when fed
|
|
555
|
+
* its own output (the cache-restore + re-apply path), causing the
|
|
556
|
+
* unbounded duplicate-insert bug.
|
|
557
|
+
*
|
|
558
|
+
* adr: adr/pending-overlay-model.md
|
|
559
|
+
*/
|
|
560
|
+
export function applyOverlayPure(canonical, entries) {
|
|
561
|
+
const merged = canonical ? JSON.parse(JSON.stringify(canonical)) : { type: 'doc', content: [] };
|
|
562
|
+
if (entries.length === 0)
|
|
563
|
+
return merged;
|
|
564
|
+
// Build a nodeId → node map for the merged doc (read-side lookup).
|
|
565
|
+
const nodeById = new Map();
|
|
566
|
+
function indexNodes(nodes) {
|
|
567
|
+
if (!Array.isArray(nodes))
|
|
568
|
+
return;
|
|
569
|
+
for (const node of nodes) {
|
|
570
|
+
const id = node?.attrs?.id;
|
|
571
|
+
if (id)
|
|
572
|
+
nodeById.set(id, node);
|
|
573
|
+
if (node?.content)
|
|
574
|
+
indexNodes(node.content);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
indexNodes(merged?.content || []);
|
|
578
|
+
function findNodeWithParent(targetId) {
|
|
579
|
+
function search(nodes) {
|
|
580
|
+
if (!Array.isArray(nodes))
|
|
581
|
+
return null;
|
|
582
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
583
|
+
if (nodes[i]?.attrs?.id === targetId)
|
|
584
|
+
return { parent: nodes, index: i };
|
|
585
|
+
if (nodes[i]?.content) {
|
|
586
|
+
const r = search(nodes[i].content);
|
|
587
|
+
if (r)
|
|
588
|
+
return r;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
return search(merged?.content || []);
|
|
594
|
+
}
|
|
595
|
+
// Process deletes and rewrites first.
|
|
596
|
+
for (const entry of entries) {
|
|
597
|
+
if (entry.status === 'insert')
|
|
598
|
+
continue;
|
|
599
|
+
const target = nodeById.get(entry.nodeId);
|
|
600
|
+
if (!target)
|
|
601
|
+
continue; // Orphan — skipped in pure version. Caller handles classification separately.
|
|
602
|
+
target.attrs = target.attrs || {};
|
|
603
|
+
target.attrs.pendingStatus = entry.status;
|
|
604
|
+
if (entry.status === 'rewrite') {
|
|
605
|
+
if (entry.originalBaseline && !sameContent(target, entry.originalBaseline)) {
|
|
606
|
+
target.attrs.pendingStaleBaseline = true;
|
|
607
|
+
}
|
|
608
|
+
if (entry.originalBaseline) {
|
|
609
|
+
target.attrs.pendingOriginalContent = entry.originalBaseline;
|
|
610
|
+
}
|
|
611
|
+
else {
|
|
612
|
+
target.attrs.pendingOriginalContent = sanitizeNodeForBaseline(target);
|
|
613
|
+
}
|
|
614
|
+
if (entry.newContent?.content) {
|
|
615
|
+
target.content = entry.newContent.content;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
if (entry.pendingGroupId)
|
|
619
|
+
target.attrs.pendingGroupId = entry.pendingGroupId;
|
|
620
|
+
if (entry.pendingTextEdits)
|
|
621
|
+
target.attrs.pendingTextEdits = entry.pendingTextEdits;
|
|
622
|
+
if (entry.pendingSelectionFrom != null)
|
|
623
|
+
target.attrs.pendingSelectionFrom = entry.pendingSelectionFrom;
|
|
624
|
+
if (entry.pendingSelectionTo != null)
|
|
625
|
+
target.attrs.pendingSelectionTo = entry.pendingSelectionTo;
|
|
626
|
+
if (entry.pendingOriginalFrom != null)
|
|
627
|
+
target.attrs.pendingOriginalFrom = entry.pendingOriginalFrom;
|
|
628
|
+
if (entry.pendingOriginalTo != null)
|
|
629
|
+
target.attrs.pendingOriginalTo = entry.pendingOriginalTo;
|
|
630
|
+
}
|
|
631
|
+
// Inserts: idempotency check FIRST. If a node with this ID already exists,
|
|
632
|
+
// refresh its pending marker but do NOT splice another copy.
|
|
633
|
+
for (const entry of entries) {
|
|
634
|
+
if (entry.status !== 'insert')
|
|
635
|
+
continue;
|
|
636
|
+
if (!entry.newContent)
|
|
637
|
+
continue;
|
|
638
|
+
const existing = nodeById.get(entry.nodeId);
|
|
639
|
+
if (existing) {
|
|
640
|
+
existing.attrs = existing.attrs || {};
|
|
641
|
+
existing.attrs.pendingStatus = 'insert';
|
|
642
|
+
if (entry.pendingGroupId)
|
|
643
|
+
existing.attrs.pendingGroupId = entry.pendingGroupId;
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
const newNode = JSON.parse(JSON.stringify(entry.newContent));
|
|
647
|
+
newNode.attrs = newNode.attrs || {};
|
|
648
|
+
newNode.attrs.id = entry.nodeId;
|
|
649
|
+
newNode.attrs.pendingStatus = 'insert';
|
|
650
|
+
if (entry.pendingGroupId)
|
|
651
|
+
newNode.attrs.pendingGroupId = entry.pendingGroupId;
|
|
652
|
+
let placed = false;
|
|
653
|
+
if (entry.afterNodeId) {
|
|
654
|
+
const loc = findNodeWithParent(entry.afterNodeId);
|
|
655
|
+
if (loc) {
|
|
656
|
+
loc.parent.splice(loc.index + 1, 0, newNode);
|
|
657
|
+
nodeById.set(entry.nodeId, newNode);
|
|
658
|
+
placed = true;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
if (!placed && entry.parentNodeId) {
|
|
662
|
+
const parentLoc = findNodeWithParent(entry.parentNodeId);
|
|
663
|
+
if (parentLoc) {
|
|
664
|
+
const parent = parentLoc.parent[parentLoc.index];
|
|
665
|
+
parent.content = parent.content || [];
|
|
666
|
+
parent.content.unshift(newNode);
|
|
667
|
+
nodeById.set(entry.nodeId, newNode);
|
|
668
|
+
placed = true;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (!placed && entry.afterNodeId === null && entry.parentNodeId === null) {
|
|
672
|
+
merged.content = merged.content || [];
|
|
673
|
+
merged.content.unshift(newNode);
|
|
674
|
+
nodeById.set(entry.nodeId, newNode);
|
|
675
|
+
placed = true;
|
|
676
|
+
}
|
|
677
|
+
if (!placed) {
|
|
678
|
+
newNode.attrs.pendingOrphan = true;
|
|
679
|
+
merged.content = merged.content || [];
|
|
680
|
+
merged.content.push(newNode);
|
|
681
|
+
nodeById.set(entry.nodeId, newNode);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return merged;
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Split a merged document (one that has pending decorations baked into
|
|
688
|
+
* its node tree) into a clean canonical + structured overlay. The inverse
|
|
689
|
+
* of applyOverlayPure. Used to process inputs that arrive in merged form
|
|
690
|
+
* (browser doc-update messages, legacy on-disk docs with frontmatter pending)
|
|
691
|
+
* and turn them into the canonical + overlay representation the system
|
|
692
|
+
* now operates on.
|
|
693
|
+
*
|
|
694
|
+
* Returns:
|
|
695
|
+
* canonical — deep clone of merged with:
|
|
696
|
+
* - All `pendingStatus: 'insert'` nodes removed (they only live in overlay).
|
|
697
|
+
* - All pending attrs cleared from remaining nodes.
|
|
698
|
+
* overlayEntries — PendingEntry[] extracted from the original merged tree.
|
|
699
|
+
*/
|
|
700
|
+
export function splitMergedDoc(merged) {
|
|
701
|
+
const overlayEntries = extractOverlay(merged);
|
|
702
|
+
const canonical = stripPendingFromDoc(merged);
|
|
703
|
+
return { canonical, overlayEntries };
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Deep clone a doc with all pending content reverted to canonical:
|
|
707
|
+
* - status='insert' → drop the node (lives only in overlay)
|
|
708
|
+
* - status='rewrite' → restore node.content from pendingOriginalContent
|
|
709
|
+
* (the baseline captured when the rewrite was proposed)
|
|
710
|
+
* - status='delete' → keep the node, just strip pending attrs
|
|
711
|
+
* - no status → keep, strip stray pending attrs
|
|
712
|
+
*
|
|
713
|
+
* The rewrite-restoration step is load-bearing. Without it, the rewrite TEXT
|
|
714
|
+
* stays in canonical after splitMergedDoc. The next applyOverlayPure compares
|
|
715
|
+
* canonical-as-rewrite to baseline-as-original, sees they differ, and falsely
|
|
716
|
+
* flags `pendingStaleBaseline` — surfacing the dotted-underline indicator
|
|
717
|
+
* even though nothing actually drifted. Mirrors the on-disk serializer's
|
|
718
|
+
* `revertPendingForSerialization` in markdown-serialize.ts; the two were
|
|
719
|
+
* silently divergent and the split path produced corrupt canonical that
|
|
720
|
+
* survived in state.canonical and the cache.
|
|
721
|
+
*
|
|
722
|
+
* adr: adr/pending-overlay-model.md
|
|
723
|
+
*/
|
|
724
|
+
export function stripPendingFromDoc(doc) {
|
|
725
|
+
if (!doc)
|
|
726
|
+
return doc;
|
|
727
|
+
const cloned = JSON.parse(JSON.stringify(doc));
|
|
728
|
+
function walk(parent) {
|
|
729
|
+
if (!Array.isArray(parent?.content))
|
|
730
|
+
return;
|
|
731
|
+
// Filter out pending-insert nodes (they only live in overlay).
|
|
732
|
+
parent.content = parent.content.filter((n) => n?.attrs?.pendingStatus !== 'insert');
|
|
733
|
+
// For surviving nodes: restore canonical content (rewrites only) then
|
|
734
|
+
// strip pending markers. Type and id are preserved from the current node;
|
|
735
|
+
// we only swap node.content back to the baseline. If the baseline is
|
|
736
|
+
// missing (legacy entries pre-baseline-capture), leave content alone —
|
|
737
|
+
// best-effort, the user sees the rewrite as canonical.
|
|
738
|
+
for (const node of parent.content) {
|
|
739
|
+
if (node?.attrs) {
|
|
740
|
+
if (node.attrs.pendingStatus === 'rewrite') {
|
|
741
|
+
const baseline = node.attrs.pendingOriginalContent;
|
|
742
|
+
if (baseline && Array.isArray(baseline.content)) {
|
|
743
|
+
node.content = JSON.parse(JSON.stringify(baseline.content));
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
for (const k of PENDING_ATTR_KEYS)
|
|
747
|
+
delete node.attrs[k];
|
|
748
|
+
}
|
|
749
|
+
walk(node);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
walk(cloned);
|
|
753
|
+
return cloned;
|
|
754
|
+
}
|
|
755
|
+
function sameContent(a, b) {
|
|
756
|
+
// Cheap structural equality on the relevant subtree. Stringify is
|
|
757
|
+
// fine for sub-paragraph TipTap nodes; they're typically small.
|
|
758
|
+
const aClean = sanitizeNodeForBaseline(a);
|
|
759
|
+
const bClean = sanitizeNodeForBaseline(b);
|
|
760
|
+
return JSON.stringify(aClean) === JSON.stringify(bClean);
|
|
761
|
+
}
|
|
762
|
+
function sanitizeNodeForBaseline(node) {
|
|
763
|
+
// Strip volatile fields (ids, pending attrs) for content comparison.
|
|
764
|
+
const cloned = JSON.parse(JSON.stringify(node));
|
|
765
|
+
function strip(n) {
|
|
766
|
+
if (n?.attrs) {
|
|
767
|
+
const a = { ...n.attrs };
|
|
768
|
+
delete a.id;
|
|
769
|
+
for (const k of PENDING_ATTR_KEYS)
|
|
770
|
+
delete a[k];
|
|
771
|
+
n.attrs = a;
|
|
772
|
+
}
|
|
773
|
+
if (n?.content)
|
|
774
|
+
n.content.forEach(strip);
|
|
775
|
+
}
|
|
776
|
+
strip(cloned);
|
|
777
|
+
return cloned;
|
|
778
|
+
}
|
|
779
|
+
// ============================================================================
|
|
780
|
+
// LEGACY MIGRATION — convert frontmatter `pending` (position-keyed) to
|
|
781
|
+
// overlay format (nodeId-keyed) on first load
|
|
782
|
+
// ============================================================================
|
|
783
|
+
/**
|
|
784
|
+
* Convert legacy position-keyed pending data (from `meta.pending`) into
|
|
785
|
+
* nodeId-keyed PendingEntry[]. Walks the doc in the same pre-order the
|
|
786
|
+
* legacy serializer used (LEAF_BLOCK_TYPES), reads each leaf block's
|
|
787
|
+
* current nodeId, and pairs it with the legacy pending entry at the
|
|
788
|
+
* same position.
|
|
789
|
+
*
|
|
790
|
+
* This runs once when an old file is loaded; the next save writes
|
|
791
|
+
* canonical-only to disk and the sidecar takes over.
|
|
792
|
+
*/
|
|
793
|
+
const LEAF_BLOCK_TYPES = new Set(['paragraph', 'heading', 'codeBlock', 'horizontalRule']);
|
|
794
|
+
export function migrateLegacyPending(doc, legacyPending) {
|
|
795
|
+
const entries = [];
|
|
796
|
+
let index = 0;
|
|
797
|
+
function walk(nodes, parentId) {
|
|
798
|
+
if (!Array.isArray(nodes))
|
|
799
|
+
return;
|
|
800
|
+
let prevSiblingId = null;
|
|
801
|
+
for (const node of nodes) {
|
|
802
|
+
if (LEAF_BLOCK_TYPES.has(node.type)) {
|
|
803
|
+
const legacyEntry = legacyPending[String(index)];
|
|
804
|
+
if (legacyEntry && node.attrs?.id) {
|
|
805
|
+
const entry = {
|
|
806
|
+
nodeId: node.attrs.id,
|
|
807
|
+
status: legacyEntry.s,
|
|
808
|
+
};
|
|
809
|
+
if (legacyEntry.o) {
|
|
810
|
+
// Legacy stored original as full node — use as originalBaseline.
|
|
811
|
+
entry.originalBaseline = legacyEntry.o;
|
|
812
|
+
}
|
|
813
|
+
if (legacyEntry.g)
|
|
814
|
+
entry.pendingGroupId = legacyEntry.g;
|
|
815
|
+
if (legacyEntry.sf != null)
|
|
816
|
+
entry.pendingSelectionFrom = legacyEntry.sf;
|
|
817
|
+
if (legacyEntry.st != null)
|
|
818
|
+
entry.pendingSelectionTo = legacyEntry.st;
|
|
819
|
+
if (legacyEntry.of != null)
|
|
820
|
+
entry.pendingOriginalFrom = legacyEntry.of;
|
|
821
|
+
if (legacyEntry.ot != null)
|
|
822
|
+
entry.pendingOriginalTo = legacyEntry.ot;
|
|
823
|
+
// For rewrites: the legacy doc body already contains the new prose
|
|
824
|
+
// (since the body is post-rewrite). We capture it as newContent.
|
|
825
|
+
if (legacyEntry.s === 'rewrite' || legacyEntry.s === 'insert') {
|
|
826
|
+
entry.newContent = stripPendingAttrs(node);
|
|
827
|
+
}
|
|
828
|
+
if (legacyEntry.s === 'insert') {
|
|
829
|
+
entry.afterNodeId = prevSiblingId;
|
|
830
|
+
entry.parentNodeId = parentId;
|
|
831
|
+
}
|
|
832
|
+
entries.push(entry);
|
|
833
|
+
}
|
|
834
|
+
index++;
|
|
835
|
+
}
|
|
836
|
+
else if (node.content) {
|
|
837
|
+
walk(node.content, node.attrs?.id || parentId);
|
|
838
|
+
}
|
|
839
|
+
if (node.attrs?.id)
|
|
840
|
+
prevSiblingId = node.attrs.id;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
walk(doc?.content || [], null);
|
|
844
|
+
return entries;
|
|
845
|
+
}
|