@ansonlai/docx-redline-js 0.9.0 → 0.11.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/ARCHITECTURE.md +691 -669
- package/CHANGELOG.md +67 -1
- package/README.md +39 -8
- package/core/word-xml.js +160 -118
- package/dist/docx-redline-js.esm.js +528 -312
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +94 -94
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +528 -312
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +528 -312
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_KNOWLEDGE_BASE.md +64 -5
- package/docs/SKILL_AUTHORING.md +5 -1
- package/docs/TESTING.md +10 -1
- package/docs/schemas/document-operations.schema.json +1 -1
- package/engine/oxml-engine.js +748 -736
- package/engine/table-mode.js +7 -4
- package/index.d.ts +29 -2
- package/index.js +3 -0
- package/node/cli.js +993 -992
- package/package.json +1 -1
- package/pipeline/content-analysis.js +2 -1
- package/pipeline/markdown-processor.js +5 -2
- package/pipeline/structured-content.js +2 -1
- package/services/comment-parts-merge.js +124 -0
- package/services/comment-replies.js +1 -1
- package/services/document-operation-applier.js +1 -0
- package/services/document-operation-contract.js +1 -0
- package/services/document-operation-mutations.js +3362 -3346
- package/services/inspection-comparison.js +82 -0
- package/services/operation-heuristics.js +1 -1
- package/services/operation-preflight.js +42 -33
- package/services/revision-fidelity-verifier.js +5 -19
- package/services/standalone-docx-plumbing.js +38 -22
- package/services/table-reconciliation.js +11 -6
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compares two `inspectDocumentParts` results by what a reader and reviewer see:
|
|
3
|
+
* paragraph text, table position, list label, review segments and comments.
|
|
4
|
+
*
|
|
5
|
+
* A host that writes a prepared package uses this to verify what it committed
|
|
6
|
+
* against what was prepared. Revision ids, dates, rsids, paragraph ids and
|
|
7
|
+
* run boundaries are ignored, because hosts regenerate them on write.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const commentIdentity = comment => [comment.author ?? null, comment.text ?? '', comment.anchoredText || ''];
|
|
11
|
+
|
|
12
|
+
// Adjacent review segments of one kind and author are one change, however the
|
|
13
|
+
// host split the runs.
|
|
14
|
+
function reviewSegments(segments = []) {
|
|
15
|
+
return segments.filter(segment => segment.kind !== 'baseline').reduce((result, segment) => {
|
|
16
|
+
const next = {
|
|
17
|
+
kind: segment.kind, author: segment.author || null, text: segment.text,
|
|
18
|
+
acceptedStart: segment.acceptedStart ?? null, rejectedStart: segment.rejectedStart ?? null
|
|
19
|
+
};
|
|
20
|
+
const previous = result.at(-1);
|
|
21
|
+
const adjacent = key => (previous[key] == null && next[key] == null)
|
|
22
|
+
|| (previous[key] != null && previous[key] + previous.text.length === next[key]);
|
|
23
|
+
if (previous && previous.kind === next.kind && previous.author === next.author
|
|
24
|
+
&& adjacent('acceptedStart') && adjacent('rejectedStart')) previous.text += next.text;
|
|
25
|
+
else result.push(next);
|
|
26
|
+
return result;
|
|
27
|
+
}, []);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The host-independent shape of one inspection.
|
|
32
|
+
* @param {{ paragraphs: Array<object>, comments?: Array<object> }} inspection
|
|
33
|
+
*/
|
|
34
|
+
export function inspectionReviewShape(inspection) {
|
|
35
|
+
const comments = inspection?.comments || [];
|
|
36
|
+
return {
|
|
37
|
+
paragraphs: (inspection?.paragraphs || []).map(paragraph => ({
|
|
38
|
+
text: paragraph.exactText,
|
|
39
|
+
inTable: paragraph.inTable === true,
|
|
40
|
+
table: paragraph.table ? [paragraph.table.rowIndex, paragraph.table.cellIndex] : null,
|
|
41
|
+
list: paragraph.list ? [paragraph.list.format, paragraph.list.level, paragraph.list.label] : null,
|
|
42
|
+
review: {
|
|
43
|
+
present: paragraph.hasRevisions === true,
|
|
44
|
+
authors: [...(paragraph.revisionAuthors || [])].sort(),
|
|
45
|
+
segments: reviewSegments(paragraph.segments)
|
|
46
|
+
}
|
|
47
|
+
})),
|
|
48
|
+
comments: comments.map(comment => {
|
|
49
|
+
const parent = comment.parentCommentId != null
|
|
50
|
+
? comments.find(item => String(item.id) === String(comment.parentCommentId))
|
|
51
|
+
: null;
|
|
52
|
+
return [...commentIdentity(comment), comment.done === true,
|
|
53
|
+
parent ? commentIdentity(parent)
|
|
54
|
+
: (comment.parentCommentId != null || comment.parentParaId ? 'unresolved parent' : null)];
|
|
55
|
+
}).map(item => JSON.stringify(item)).sort()
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {{ paragraphs: Array<object>, comments?: Array<object> }} expected - e.g. inspection of the prepared package
|
|
61
|
+
* @param {{ paragraphs: Array<object>, comments?: Array<object> }} actual - e.g. inspection of what the host committed
|
|
62
|
+
* @returns {{ matches: boolean, differences: Array<{ path: string, expected: any, actual: any }> }}
|
|
63
|
+
* `differences` lists at most the first ten mismatches.
|
|
64
|
+
*/
|
|
65
|
+
export function compareDocumentInspections(expected, actual) {
|
|
66
|
+
const left = inspectionReviewShape(expected);
|
|
67
|
+
const right = inspectionReviewShape(actual);
|
|
68
|
+
const differences = [];
|
|
69
|
+
const note = (path, a, b) => {
|
|
70
|
+
if (differences.length < 10 && JSON.stringify(a) !== JSON.stringify(b)) differences.push({ path, expected: a, actual: b });
|
|
71
|
+
};
|
|
72
|
+
note('paragraphs.length', left.paragraphs.length, right.paragraphs.length);
|
|
73
|
+
const count = Math.max(left.paragraphs.length, right.paragraphs.length);
|
|
74
|
+
for (let index = 0; index < count; index++) {
|
|
75
|
+
const a = left.paragraphs[index] ?? null;
|
|
76
|
+
const b = right.paragraphs[index] ?? null;
|
|
77
|
+
if (!a || !b) { note(`paragraphs[${index}]`, a, b); continue; }
|
|
78
|
+
for (const key of ['text', 'inTable', 'table', 'list', 'review']) note(`paragraphs[${index}].${key}`, a[key], b[key]);
|
|
79
|
+
}
|
|
80
|
+
note('comments', left.comments, right.comments);
|
|
81
|
+
return { matches: differences.length === 0, differences };
|
|
82
|
+
}
|
|
@@ -192,6 +192,6 @@ export function deriveParagraphThenTableAppend(modifiedText) {
|
|
|
192
192
|
const tableLines = lines.slice(1);
|
|
193
193
|
if (!tableLines.every(line => line.trim().startsWith('|'))) return null;
|
|
194
194
|
const separator = tableLines[1].replace(/\s+/g, '');
|
|
195
|
-
if (!/^\|:?-{3,}:?(\|:?-{3,}:?)
|
|
195
|
+
if (!/^\|:?-{3,}:?(?:(\|:?-{3,}:?)+\|?|\|)$/.test(separator)) return null;
|
|
196
196
|
return { paragraphMarkdown: lines[0].trim(), tableText: tableLines.join('\n') };
|
|
197
197
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
6
6
|
import { getDefaultAuthor } from '../adapters/config.js';
|
|
7
|
-
import { containsTrackedChanges, getTrackedChangeAuthors } from '../core/word-xml.js';
|
|
7
|
+
import { containsTrackedChanges, getTrackedChangeAuthors, hasUnattributedTrackedChange, resolveExistingRevisionsPolicy } from '../core/word-xml.js';
|
|
8
8
|
import { NS_W } from '../core/types.js';
|
|
9
9
|
import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
|
|
10
10
|
import {
|
|
@@ -273,42 +273,49 @@ export function preflightOperations(documentXml, operations, author, options = {
|
|
|
273
273
|
: null;
|
|
274
274
|
const anchorFound = anchor == null
|
|
275
275
|
|| (anchorResolution ? anchorResolution.found : paragraphText.includes(anchor));
|
|
276
|
+
// The paragraphs from the target through targetEnd (just the target without one); null when the
|
|
277
|
+
// end does not resolve after the start.
|
|
278
|
+
const rangeParagraphs = opType => {
|
|
279
|
+
if (operation.targetEndRef) {
|
|
280
|
+
return resolveParagraphRangeByRefs(xmlDoc, operation.targetRef, operation.targetEndRef, {
|
|
281
|
+
opType,
|
|
282
|
+
targetRefSnapshot: options.targetRefSnapshot || null,
|
|
283
|
+
onInfo: options.onInfo,
|
|
284
|
+
onWarn: options.onWarn
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
if (!operation.targetEndDescriptor) return [paragraph];
|
|
288
|
+
const endResolved = resolveTargetParagraph(xmlDoc, {
|
|
289
|
+
targetText: operation.targetEndDescriptor.text,
|
|
290
|
+
targetRef: operation.targetEndDescriptor.index,
|
|
291
|
+
targetDescriptor: operation.targetEndDescriptor,
|
|
292
|
+
opType,
|
|
293
|
+
strictAmbiguity: strictTargets,
|
|
294
|
+
paragraphMetadataIndex: currentMetadataIndex,
|
|
295
|
+
metadataIndices,
|
|
296
|
+
onInfo: options.onInfo,
|
|
297
|
+
onWarn: options.onWarn
|
|
298
|
+
});
|
|
299
|
+
const allParagraphs = getDocumentParagraphNodes(xmlDoc);
|
|
300
|
+
const startIndex = allParagraphs.indexOf(paragraph);
|
|
301
|
+
const endIndex = allParagraphs.indexOf(endResolved?.paragraph);
|
|
302
|
+
return startIndex >= 0 && endIndex >= startIndex
|
|
303
|
+
? allParagraphs.slice(startIndex, endIndex + 1)
|
|
304
|
+
: null;
|
|
305
|
+
};
|
|
276
306
|
const hasRevisions = containsTrackedChanges(paragraph);
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
307
|
+
const requestedPolicy = operation.existingRevisions || options.existingRevisions || 'merge-same-author';
|
|
308
|
+
const existingPolicy = resolveExistingRevisionsPolicy(requestedPolicy,
|
|
309
|
+
requestedPolicy === 'auto' && operation.operationKind === 'redline' && (operation.targetEndRef || operation.targetEndDescriptor)
|
|
310
|
+
? (rangeParagraphs('redline') || paragraph)
|
|
311
|
+
: paragraph,
|
|
312
|
+
authorUsed);
|
|
280
313
|
const deletingWholeParagraph = operation.operationKind === 'redline' && operation.modified === '';
|
|
281
314
|
const commentIds = deletingWholeParagraph ? getCommentIdsInParagraph(paragraph) : [];
|
|
282
315
|
|
|
283
316
|
let error = null;
|
|
284
317
|
if (operation.operationKind === 'restore') {
|
|
285
|
-
|
|
286
|
-
? resolveParagraphRangeByRefs(xmlDoc, operation.targetRef, operation.targetEndRef, {
|
|
287
|
-
opType: 'restore',
|
|
288
|
-
targetRefSnapshot: options.targetRefSnapshot || null,
|
|
289
|
-
onInfo: options.onInfo,
|
|
290
|
-
onWarn: options.onWarn
|
|
291
|
-
})
|
|
292
|
-
: [paragraph];
|
|
293
|
-
if (!operation.targetEndRef && operation.targetEndDescriptor) {
|
|
294
|
-
const endResolved = resolveTargetParagraph(xmlDoc, {
|
|
295
|
-
targetText: operation.targetEndDescriptor.text,
|
|
296
|
-
targetRef: operation.targetEndDescriptor.index,
|
|
297
|
-
targetDescriptor: operation.targetEndDescriptor,
|
|
298
|
-
opType: 'restore',
|
|
299
|
-
strictAmbiguity: strictTargets,
|
|
300
|
-
paragraphMetadataIndex: currentMetadataIndex,
|
|
301
|
-
metadataIndices,
|
|
302
|
-
onInfo: options.onInfo,
|
|
303
|
-
onWarn: options.onWarn
|
|
304
|
-
});
|
|
305
|
-
const allParagraphs = getDocumentParagraphNodes(xmlDoc);
|
|
306
|
-
const startIndex = allParagraphs.indexOf(paragraph);
|
|
307
|
-
const endIndex = allParagraphs.indexOf(endResolved?.paragraph);
|
|
308
|
-
restorationParagraphs = startIndex >= 0 && endIndex >= startIndex
|
|
309
|
-
? allParagraphs.slice(startIndex, endIndex + 1)
|
|
310
|
-
: null;
|
|
311
|
-
}
|
|
318
|
+
const restorationParagraphs = rangeParagraphs('restore');
|
|
312
319
|
const replacements = Array.isArray(operation.modified) ? operation.modified : [operation.modified];
|
|
313
320
|
if (!restorationParagraphs || restorationParagraphs[0] !== paragraph || replacements.length !== restorationParagraphs.length) {
|
|
314
321
|
error = {
|
|
@@ -364,11 +371,13 @@ export function preflightOperations(documentXml, operations, author, options = {
|
|
|
364
371
|
if (existingPolicy === 'merge-same-author' || existingPolicy === 'slice-cross-author') {
|
|
365
372
|
const authors = getTrackedChangeAuthors(paragraph);
|
|
366
373
|
const opAuthor = String(authorUsed || '').trim().toLowerCase();
|
|
367
|
-
const allSame = authors.length > 0
|
|
374
|
+
const allSame = authors.length > 0
|
|
375
|
+
&& !hasUnattributedTrackedChange(paragraph)
|
|
376
|
+
&& authors.every(a => a.trim().toLowerCase() === opAuthor);
|
|
368
377
|
if (!allSame && existingPolicy === 'merge-same-author') {
|
|
369
378
|
error = {
|
|
370
379
|
code: 'EXISTING_REVISIONS',
|
|
371
|
-
message: `Target paragraph contains tracked changes from another author (${authors
|
|
380
|
+
message: `Target paragraph contains tracked changes from another author (${[...authors, ...(hasUnattributedTrackedChange(paragraph) ? ['unattributed'] : [])].join(', ')}). Use existingRevisions: "slice-cross-author" for a surgical edit that preserves reviewer history; accepting or rejecting revisions requires separate authorization.`,
|
|
372
381
|
revisionAuthors: authors,
|
|
373
382
|
currentPolicy: existingPolicy
|
|
374
383
|
};
|
|
@@ -200,15 +200,15 @@ function parseNumberingDefinitions(text) {
|
|
|
200
200
|
const cached = numberingDefinitionCache.get(text);
|
|
201
201
|
if (cached) return cached;
|
|
202
202
|
const abstractLevels = new Map();
|
|
203
|
-
for (const match of text.matchAll(/<(?:\w+:)?abstractNum
|
|
203
|
+
for (const match of text.matchAll(/<(?:\w+:)?abstractNum\b([^>]*)>([\s\S]*?)<\/(?:\w+:)?abstractNum>/g)) {
|
|
204
204
|
const id = /abstractNumId="([^"]*)"/.exec(match[1])?.[1];
|
|
205
205
|
if (id == null) continue;
|
|
206
206
|
const levels = abstractLevels.get(id) || new Set();
|
|
207
|
-
for (const lvl of match[2].matchAll(/<(?:\w+:)?lvl
|
|
207
|
+
for (const lvl of match[2].matchAll(/<(?:\w+:)?lvl\b[^>]*?ilvl="([^"]*)"/g)) levels.add(lvl[1]);
|
|
208
208
|
abstractLevels.set(id, levels);
|
|
209
209
|
}
|
|
210
210
|
const numToAbstract = new Map();
|
|
211
|
-
for (const match of text.matchAll(/<(?:\w+:)?num
|
|
211
|
+
for (const match of text.matchAll(/<(?:\w+:)?num\b([^>]*)>([\s\S]*?)<\/(?:\w+:)?num>/g)) {
|
|
212
212
|
const id = /numId="([^"]*)"/.exec(match[1])?.[1];
|
|
213
213
|
const abstractId = /abstractNumId[^>]*?val="([^"]*)"/.exec(match[2])?.[1];
|
|
214
214
|
if (id != null && abstractId != null) numToAbstract.set(id, abstractId);
|
|
@@ -231,7 +231,7 @@ function parseNumberingDefinitions(text) {
|
|
|
231
231
|
* @param {Document} input.after - post-mutation DOM
|
|
232
232
|
* @param {Array<string|null|undefined>} input.numberingXmls - numbering part texts available at the seam
|
|
233
233
|
*/
|
|
234
|
-
export function verifyNumberingReferences({ before, after, numberingXmls
|
|
234
|
+
export function verifyNumberingReferences({ before, after, numberingXmls }) {
|
|
235
235
|
const references = documentNode => {
|
|
236
236
|
const found = new Map();
|
|
237
237
|
for (const numIdNode of getAllReferencedNumIdElements(documentNode)) {
|
|
@@ -247,21 +247,7 @@ export function verifyNumberingReferences({ before, after, numberingXmls, before
|
|
|
247
247
|
const used = references(after);
|
|
248
248
|
if (used.size === 0) return { ok: true };
|
|
249
249
|
const known = references(before);
|
|
250
|
-
const
|
|
251
|
-
const abstractLevels = new Map();
|
|
252
|
-
for (const match of text.matchAll(/<(?:\w+:)?abstractNum\b([^>]*)>([\s\S]*?)<\/(?:\w+:)?abstractNum>/g)) {
|
|
253
|
-
const id = /abstractNumId="([^"]*)"/.exec(match[1])?.[1];
|
|
254
|
-
if (id == null) continue;
|
|
255
|
-
const levels = abstractLevels.get(id) || new Set();
|
|
256
|
-
for (const lvl of match[2].matchAll(/<(?:\w+:)?lvl\b[^>]*?ilvl="([^"]*)"/g)) levels.add(lvl[1]);
|
|
257
|
-
abstractLevels.set(id, levels);
|
|
258
|
-
}
|
|
259
|
-
const numToAbstract = new Map();
|
|
260
|
-
for (const match of text.matchAll(/<(?:\w+:)?num\b([^>]*)>([\s\S]*?)<\/(?:\w+:)?num>/g)) {
|
|
261
|
-
const id = /numId="([^"]*)"/.exec(match[1])?.[1];
|
|
262
|
-
const abstractId = /abstractNumId[^>]*?val="([^"]*)"/.exec(match[2])?.[1];
|
|
263
|
-
if (id != null && abstractId != null) numToAbstract.set(id, abstractId);
|
|
264
|
-
}
|
|
250
|
+
const { abstractLevels, numToAbstract } = parseNumberingDefinitions((numberingXmls || []).filter(Boolean).join('\n'));
|
|
265
251
|
const violations = [];
|
|
266
252
|
for (const [numId, levels] of used) {
|
|
267
253
|
if (known.has(numId)) continue;
|
|
@@ -310,12 +310,47 @@ export async function ensureNumberingArtifactsInZip(zip, numberingXmlList, optio
|
|
|
310
310
|
}
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
+
/**
|
|
314
|
+
* Merges incoming comment definitions into an existing comments.xml string. With no
|
|
315
|
+
* existing part, or `replaceExisting`, the incoming part is returned as is (unparsed).
|
|
316
|
+
* Otherwise throws a `PARSE_ERROR` for malformed XML and a `DUPLICATE_COMMENT_ID` for an
|
|
317
|
+
* incoming id the existing part (or an earlier incoming comment) already uses. An incoming
|
|
318
|
+
* part with no comments leaves the existing string as is.
|
|
319
|
+
*
|
|
320
|
+
* @param {string|null|undefined} existingXml
|
|
321
|
+
* @param {string} commentsXml
|
|
322
|
+
* @param {{ replaceExisting?: boolean }} [options]
|
|
323
|
+
* @returns {string}
|
|
324
|
+
*/
|
|
325
|
+
export function mergeCommentsPartXml(existingXml, commentsXml, options = {}) {
|
|
326
|
+
if (!existingXml || options.replaceExisting === true) return commentsXml;
|
|
327
|
+
const existingDoc = parseXmlStrictStandalone(existingXml, 'word/comments.xml (existing)');
|
|
328
|
+
const incomingDoc = parseXmlStrictStandalone(commentsXml, 'word/comments.xml (incoming)');
|
|
329
|
+
const incomingComments = Array.from(incomingDoc.documentElement.getElementsByTagNameNS(NS_W, 'comment'));
|
|
330
|
+
if (incomingComments.length === 0) return existingXml;
|
|
331
|
+
const existingRoot = existingDoc.documentElement;
|
|
332
|
+
const usedIds = new Set(
|
|
333
|
+
Array.from(existingRoot.getElementsByTagNameNS(NS_W, 'comment'))
|
|
334
|
+
.map(comment => comment.getAttribute('w:id') || comment.getAttribute('id'))
|
|
335
|
+
.filter(Boolean)
|
|
336
|
+
);
|
|
337
|
+
for (const incomingComment of incomingComments) {
|
|
338
|
+
const id = incomingComment.getAttribute('w:id') || incomingComment.getAttribute('id');
|
|
339
|
+
if (id && usedIds.has(id)) {
|
|
340
|
+
throw Object.assign(new Error(`Duplicate comment id: ${id}`), { code: 'DUPLICATE_COMMENT_ID', commentId: id });
|
|
341
|
+
}
|
|
342
|
+
if (id) usedIds.add(id);
|
|
343
|
+
existingRoot.appendChild(existingDoc.importNode(incomingComment, true));
|
|
344
|
+
}
|
|
345
|
+
return createSerializer().serializeToString(existingDoc);
|
|
346
|
+
}
|
|
347
|
+
|
|
313
348
|
/**
|
|
314
349
|
* Ensures comments part + package metadata exist and merges incoming comments.
|
|
315
350
|
*
|
|
316
351
|
* @param {any} zip
|
|
317
352
|
* @param {string|null|undefined} commentsXml
|
|
318
|
-
* @param {{ onInfo?: (message: string) => void }} [options]
|
|
353
|
+
* @param {{ onInfo?: (message: string) => void, replaceExisting?: boolean }} [options]
|
|
319
354
|
*/
|
|
320
355
|
export async function ensureCommentsArtifactsInZip(zip, commentsXml, options = {}) {
|
|
321
356
|
const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => {};
|
|
@@ -323,27 +358,8 @@ export async function ensureCommentsArtifactsInZip(zip, commentsXml, options = {
|
|
|
323
358
|
|
|
324
359
|
const serializer = createSerializer();
|
|
325
360
|
const existingText = await readZipText(zip, COMMENTS_PATH);
|
|
326
|
-
if (!existingText || options.replaceExisting === true)
|
|
327
|
-
|
|
328
|
-
zip.file(COMMENTS_PATH, commentsXml);
|
|
329
|
-
} else {
|
|
330
|
-
const existingDoc = parseXmlStrictStandalone(existingText, 'word/comments.xml (existing)');
|
|
331
|
-
const incomingDoc = parseXmlStrictStandalone(commentsXml, 'word/comments.xml (incoming)');
|
|
332
|
-
const existingRoot = existingDoc.documentElement;
|
|
333
|
-
const existingIds = new Set(
|
|
334
|
-
Array.from(existingRoot.getElementsByTagNameNS(NS_W, 'comment'))
|
|
335
|
-
.map(comment => comment.getAttribute('w:id') || comment.getAttribute('id'))
|
|
336
|
-
.filter(Boolean)
|
|
337
|
-
);
|
|
338
|
-
for (const incomingComment of Array.from(incomingDoc.documentElement.getElementsByTagNameNS(NS_W, 'comment'))) {
|
|
339
|
-
const id = incomingComment.getAttribute('w:id') || incomingComment.getAttribute('id');
|
|
340
|
-
if (id && existingIds.has(id)) {
|
|
341
|
-
throw new Error(`Duplicate comment id: ${id}`);
|
|
342
|
-
}
|
|
343
|
-
existingRoot.appendChild(existingDoc.importNode(incomingComment, true));
|
|
344
|
-
}
|
|
345
|
-
zip.file(COMMENTS_PATH, serializer.serializeToString(existingDoc));
|
|
346
|
-
}
|
|
361
|
+
if (!existingText || options.replaceExisting === true) onInfo('[Demo] Adding comments.xml');
|
|
362
|
+
zip.file(COMMENTS_PATH, mergeCommentsPartXml(existingText, commentsXml, options));
|
|
347
363
|
|
|
348
364
|
const ctText = await readZipText(zip, CONTENT_TYPES_PATH);
|
|
349
365
|
if (ctText) {
|
|
@@ -8,7 +8,12 @@ import { computeWordLevelDiffOps } from '../pipeline/diff-engine.js';
|
|
|
8
8
|
import { splitRunsAtDiffBoundaries, applyPatches } from '../pipeline/patching.js';
|
|
9
9
|
import { serializeToOoxml } from '../pipeline/serialization.js';
|
|
10
10
|
import { createRevisionMetadata, escapeXml, RunKind } from '../core/types.js';
|
|
11
|
-
import { preprocessMarkdown } from '../pipeline/markdown-processor.js';
|
|
11
|
+
import { preprocessMarkdown } from '../pipeline/markdown-processor.js';
|
|
12
|
+
|
|
13
|
+
// A caller's structuredContent: false (options.plainTextContent) keeps cell text literal: no inline Markdown.
|
|
14
|
+
const cellMarkdown = (text, options) => (options?.plainTextContent === true
|
|
15
|
+
? { cleanText: String(text ?? ''), formatHints: [] }
|
|
16
|
+
: preprocessMarkdown(text));
|
|
12
17
|
import { paragraphMarkPPrXml } from '../engine/paragraph-mark-revisions.js';
|
|
13
18
|
|
|
14
19
|
/**
|
|
@@ -64,7 +69,7 @@ export function generateTableOoxml(tableData, options = {}) {
|
|
|
64
69
|
|
|
65
70
|
for (let c = 0; c < numCols; c++) {
|
|
66
71
|
const cellText = row[c] || '';
|
|
67
|
-
const { cleanText, formatHints } =
|
|
72
|
+
const { cleanText, formatHints } = cellMarkdown(cellText, options);
|
|
68
73
|
|
|
69
74
|
// Build run model for the cell
|
|
70
75
|
const runModel = [{
|
|
@@ -239,7 +244,7 @@ export function serializeVirtualGridToOoxml(grid, operations, options) {
|
|
|
239
244
|
const rowInsertMeta = generateRedlines ? createRevisionMetadata(author, revisionIdAllocator) : null;
|
|
240
245
|
|
|
241
246
|
for (const cellText of op.cells) {
|
|
242
|
-
const { cleanText, formatHints } =
|
|
247
|
+
const { cleanText, formatHints } = cellMarkdown(cellText, options);
|
|
243
248
|
// Simple run model for new cell
|
|
244
249
|
const runModel = [{
|
|
245
250
|
kind: generateRedlines ? RunKind.INSERTION : RunKind.TEXT,
|
|
@@ -340,7 +345,7 @@ function reconcileMultiBlockCellContent(cell, newText, options) {
|
|
|
340
345
|
}
|
|
341
346
|
return;
|
|
342
347
|
}
|
|
343
|
-
const { cleanText, formatHints } =
|
|
348
|
+
const { cleanText, formatHints } = cellMarkdown(line, options);
|
|
344
349
|
const diffOps = computeWordLevelDiffOps(block.acceptedText, cleanText);
|
|
345
350
|
const model = applyPatches(splitRunsAtDiffBoundaries(block.runModel, diffOps), diffOps, {
|
|
346
351
|
generateRedlines, author, formatHints
|
|
@@ -349,7 +354,7 @@ function reconcileMultiBlockCellContent(cell, newText, options) {
|
|
|
349
354
|
});
|
|
350
355
|
|
|
351
356
|
for (let i = cell.blocks.length; i < lines.length; i++) {
|
|
352
|
-
const { cleanText, formatHints } =
|
|
357
|
+
const { cleanText, formatHints } = cellMarkdown(lines[i], options);
|
|
353
358
|
const runModel = [{
|
|
354
359
|
kind: generateRedlines ? RunKind.INSERTION : RunKind.TEXT,
|
|
355
360
|
text: cleanText, rPrXml: '', author, startOffset: 0, endOffset: cleanText.length
|
|
@@ -365,7 +370,7 @@ function reconcileCellContent(cell, newText, options) {
|
|
|
365
370
|
return reconcileMultiBlockCellContent(cell, newText, options);
|
|
366
371
|
}
|
|
367
372
|
const { generateRedlines, author, revisionIdAllocator = null } = options;
|
|
368
|
-
const { cleanText, formatHints } =
|
|
373
|
+
const { cleanText, formatHints } = cellMarkdown(newText, options);
|
|
369
374
|
|
|
370
375
|
// For now, satisfy with single block or join blocks
|
|
371
376
|
// In a full implementation, we'd diff paragraphs within the cell
|