@ansonlai/docx-redline-js 0.9.0 → 0.10.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.
@@ -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
+ }
@@ -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 existingPolicy = operation.existingRevisions
278
- || options.existingRevisions
279
- || 'merge-same-author';
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
- let restorationParagraphs = operation.targetEndRef
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 && authors.every(a => a.trim().toLowerCase() === opAuthor);
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.length ? authors.join(', ') : 'unattributed'}). Use existingRevisions: "slice-cross-author" for a surgical edit that preserves reviewer history; accepting or rejecting revisions requires separate authorization.`,
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([^>]*)>([\s\S]*?)<\/(?:\w+:)?abstractNum>/g)) {
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[^>]*?ilvl="([^"]*)"/g)) levels.add(lvl[1]);
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([^>]*)>([\s\S]*?)<\/(?:\w+:)?num>/g)) {
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, beforeViews = null, afterViews = null }) {
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 text = (numberingXmls || []).filter(Boolean).join('\n');
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;