@ansonlai/docx-redline-js 0.5.2 → 0.5.3
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/AGENTS.md +24 -3
- package/CHANGELOG.md +13 -1
- package/README.md +16 -3
- package/core/paragraph-revision-safety.js +213 -0
- package/core/paragraph-targeting.js +19 -0
- package/core/redline-validation.js +13 -0
- package/dist/docx-redline-js.esm.js +333 -89
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +79 -79
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/plans/2026-09-08-cross-author-revision-slicing.md +282 -1
- package/docs/schemas/document-operations.schema.json +3 -0
- package/engine/oxml-engine.js +24 -3
- package/engine/surgical-diff-application.js +35 -1
- package/engine/surgical-mode.js +55 -1
- package/index.d.ts +12 -0
- package/package.json +1 -1
- package/services/document-operation-applier.js +24 -5
- package/services/document-operation-contract.js +37 -2
- package/services/document-operation-mutations.js +509 -29
- package/services/operation-preflight.js +72 -8
- package/services/standalone-operation-runner.d.ts +8 -0
package/AGENTS.md
CHANGED
|
@@ -144,12 +144,30 @@ const result = await applyOperationsToDocumentXml(documentXml, operations, 'Agen
|
|
|
144
144
|
The operation runner uses these field names:
|
|
145
145
|
|
|
146
146
|
```js
|
|
147
|
-
const operations = [
|
|
147
|
+
const operations = [
|
|
148
148
|
{ type: 'redline', target: 'Old paragraph text', modified: 'New paragraph text', targetRef: 12 },
|
|
149
149
|
{ type: 'comment', target: 'Paragraph text', textToComment: 'anchor text', commentContent: 'Comment body', targetRef: 18 },
|
|
150
150
|
{ type: 'highlight', target: 'Paragraph text', textToHighlight: 'anchor text', color: 'yellow', targetRef: 24 }
|
|
151
|
-
];
|
|
152
|
-
```
|
|
151
|
+
];
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
To counterpropose text for a paragraph wholly deleted by another reviewer,
|
|
155
|
+
use explicit restoration intent. A normal `redline` remains fail-closed with
|
|
156
|
+
`FOREIGN_PARAGRAPH_MARK_DELETION`:
|
|
157
|
+
|
|
158
|
+
```js
|
|
159
|
+
const restoration = {
|
|
160
|
+
type: 'restore',
|
|
161
|
+
target: { paragraphId: '1A2B3C4D' },
|
|
162
|
+
modified: 'Restored or adjusted paragraph text.',
|
|
163
|
+
author: 'Editor'
|
|
164
|
+
};
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
For a contiguous range, provide `targetEnd`/`targetEndRef` and one string per
|
|
168
|
+
source paragraph in `modified`. Restoration always uses tracked changes,
|
|
169
|
+
preserves the deleted source paragraph, and inserts the counterproposal before
|
|
170
|
+
it with a fresh paragraph ID.
|
|
153
171
|
|
|
154
172
|
`targetRef` is an optional 1-based paragraph reference used to disambiguate
|
|
155
173
|
duplicate text. An operation-level `author` overrides the batch author; batch
|
|
@@ -398,6 +416,9 @@ When the CLI or runner returns an error code, follow these specific recovery act
|
|
|
398
416
|
| `OVERLAPPING_TEXT_EDITS` | Multiple operations target the same paragraph concurrently. | Consolidate all changes to the same paragraph into a single `redline` or `replace` operation. |
|
|
399
417
|
| `EXISTING_REVISIONS` | Target paragraph contains tracked changes from another author. | Fails closed to protect third-party review marks. If editing inside that reviewer's pending insertion is intended, pass `--existing-revisions slice-cross-author` (or `existingRevisions: 'slice-cross-author'`). Do not pass `accept-all-first` without explicit user authorization. |
|
|
400
418
|
| `PATCH_ROUNDTRIP_MISMATCH` | A cross-author surgical edit did not reconstruct the requested modified text exactly. | Treat the operation as unapplied. Re-extract the exact paragraph text and split the edit into a narrower operation that does not cross the reported structural boundary. |
|
|
419
|
+
| `FOREIGN_PARAGRAPH_MARK_DELETION` | A normal edit attempted to write into a paragraph wholly deleted by another reviewer. | Use an explicit `restore` operation if the user intends to counterpropose that paragraph; otherwise leave the deletion unresolved. |
|
|
420
|
+
| `RESTORATION_STATE_REQUIRED` / `RESTORATION_COUNT_MISMATCH` | A `restore` target is not a wholly foreign-deleted paragraph, or its replacement count does not match the paragraph range. | Re-inspect the document and target the deleted paragraph by stable descriptor; provide exactly one replacement string per source paragraph. |
|
|
421
|
+
| `UNSAFE_DELETED_TABLE_ROW` / `UNSUPPORTED_MOVE_REVISION` / `SECTION_BREAK_PARAGRAPH` / `UNSAFE_PARAGRAPH_PLACEMENT` | Paragraph restoration cannot preserve the source structural boundary safely. | Do not retry as an ordinary redline. Resolve the row/move/section/placement condition manually or narrow the restoration to a safe paragraph. |
|
|
401
422
|
| `COMMENTED_CONTENT_MERGE` / `COMMENTED_CONTENT_DELETE` | Operation would overwrite, revert, or delete content with comments. | Fails closed to prevent orphaned comment threads. Report the comment author and text to the user; resolve the comment before re-editing. |
|
|
402
423
|
| `INVALID_OPERATION` | Operation object violates schema or has incompatible fields. | Validate the JSON structure against [`document-operations.schema.json`](file:///c:/Users/Phara/Desktop/Projects/Docx%20Redline%20JS/docs/schemas/document-operations.schema.json) before targeting is attempted. |
|
|
403
424
|
| `STRUCTURED_CONTENT_INVALID` | Malformed Markdown table or structure in replacement text. | Ensure tables include a separator row (`\| --- \| --- \|`) and consistent column counts; do not downgrade to raw text. |
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
### Safety Fixes
|
|
6
|
+
|
|
7
|
+
- **Foreign deleted-paragraph resurrection guard**: Refuses non-empty same-paragraph edits when another author owns the paragraph-mark deletion and all existing paragraph content is deleted. The operation now returns `FOREIGN_PARAGRAPH_MARK_DELETION` with the owning author instead of emitting lifecycle-unsafe OOXML; atomic document operations roll back byte-for-byte. `validateRedlineOoxml` reports already-authored instances as warnings.
|
|
8
|
+
|
|
9
|
+
### New Features
|
|
10
|
+
|
|
11
|
+
- **Explicit paragraph restoration (`type: 'restore'`)**: Restores or counterproposes another reviewer's pending whole-paragraph deletion as a separately tracked sibling paragraph. The source deletion remains untouched; the restored paragraph receives its own inserted paragraph mark, content insertion, sanitized paragraph properties, and fresh `w14:paraId`. Single paragraphs and contiguous ranges are supported, with full Accept/Reject lifecycle verification and structured refusals at unsafe table-row, move, section-break, and terminal-paragraph boundaries.
|
|
12
|
+
|
|
3
13
|
## 0.5.1
|
|
4
14
|
|
|
5
15
|
### Highlights & New Features
|
|
@@ -19,9 +29,11 @@
|
|
|
19
29
|
|
|
20
30
|
- **Validation Update (`validateRedlineOoxml`)**: Refined `NESTED_REVISION` checks to permit direct `<w:ins><w:del>...</w:del></w:ins>` nesting (standard ECMA-376 and Word Desktop behavior), while continuing to strictly reject `ins/ins`, `del/del`, and `del/ins` nesting.
|
|
21
31
|
- **Slicing Round-Trip Guard**: Cross-author surgical edits now preserve whitespace-only insertions (including ordinary-space replacements for NBSP characters beside hyperlinks) and verify the exact accepted-view text before reporting success. A mismatch fails closed with `PATCH_ROUNDTRIP_MISMATCH` and returns the original OOXML unchanged.
|
|
32
|
+
- **Hyperlink-Adjacent Replacement Anchoring**: Paired replacements immediately before or after a hyperlink now retain a stable insertion point after the deletion run is split, preventing qualifiers from being relocated past the hyperlink or following formatted runs.
|
|
33
|
+
- **Multiple Same-Run Insertions**: Insertion-only slicing operations with multiple edit points now apply from right to left against a refreshed live span index, preventing an earlier run split from relocating later insertions.
|
|
22
34
|
- **Insertion Stress Hardening**: Slicing now detects edge whitespace changes exactly, uses a character-local insertion-only diff when the original text is an exact subsequence of the modified text, and coalesces new text into an existing same-author carrier when foreign revisions are also present. This prevents repeated phrases from relocating insertions and prevents invalid `w:ins/w:ins` nesting in mixed-author paragraphs.
|
|
23
35
|
- **Preflight Inspection**: `preflightOperations` now inspects and validates `slice-cross-author` batches, reporting pending foreign-author carrier targets as `ready` instead of `EXISTING_REVISIONS`.
|
|
24
|
-
- **Test Suite Expansion**: Added
|
|
36
|
+
- **Test Suite Expansion**: Added 7 new test suites covering 36 Word Desktop COM golden fixtures, carrier splitting invariants, the SYN-01..12d synthetic test matrix, PKG-01..06 strict package differential replay, the repeated-text/hyperlink whitespace regression, 76 deterministic insertion stress scenarios, and 12 replacement-anchor lifecycle scenarios (expanding the suite from 88 to 95 passing suites).
|
|
25
37
|
|
|
26
38
|
## 0.5.0
|
|
27
39
|
|
package/README.md
CHANGED
|
@@ -520,14 +520,27 @@ import { applyOperationToDocumentXml } from '@ansonlai/docx-redline-js/standalon
|
|
|
520
520
|
const zip = await JSZip.loadAsync(docxBuffer);
|
|
521
521
|
const documentXml = await zip.file('word/document.xml').async('string');
|
|
522
522
|
|
|
523
|
-
const opResult = await applyOperationToDocumentXml(
|
|
523
|
+
const opResult = await applyOperationToDocumentXml(
|
|
524
524
|
documentXml,
|
|
525
525
|
{ type: 'redline', target: 'old text', modified: 'new text' },
|
|
526
526
|
'Editor'
|
|
527
|
-
);
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
// Restoring another reviewer's pending whole-paragraph deletion requires
|
|
530
|
+
// explicit intent. The restored counterproposal becomes a separately tracked
|
|
531
|
+
// sibling paragraph; a normal redline operation remains fail-closed.
|
|
532
|
+
const restoration = await applyOperationToDocumentXml(
|
|
533
|
+
documentXml,
|
|
534
|
+
{
|
|
535
|
+
type: 'restore',
|
|
536
|
+
target: { paragraphId: '1A2B3C4D' },
|
|
537
|
+
modified: 'Restored or adjusted paragraph text.'
|
|
538
|
+
},
|
|
539
|
+
'Editor'
|
|
540
|
+
);
|
|
528
541
|
|
|
529
542
|
// applyOperationToDocumentXml(...) returns a full w:document payload.
|
|
530
|
-
zip.file('word/document.xml', opResult.documentXml);
|
|
543
|
+
zip.file('word/document.xml', opResult.documentXml);
|
|
531
544
|
|
|
532
545
|
const fragmentResult = await applyRedlineToOxml(
|
|
533
546
|
paragraphOoxml,
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
const NON_CONTENT_CHILDREN = new Set([
|
|
2
|
+
'pPr',
|
|
3
|
+
'bookmarkStart', 'bookmarkEnd',
|
|
4
|
+
'commentRangeStart', 'commentRangeEnd', 'commentReference',
|
|
5
|
+
'customXmlInsRangeStart', 'customXmlInsRangeEnd',
|
|
6
|
+
'customXmlDelRangeStart', 'customXmlDelRangeEnd',
|
|
7
|
+
'moveFromRangeStart', 'moveFromRangeEnd',
|
|
8
|
+
'moveToRangeStart', 'moveToRangeEnd',
|
|
9
|
+
'permStart', 'permEnd', 'proofErr'
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
function localNameOf(node) {
|
|
13
|
+
return String(node?.localName || node?.nodeName || '').replace(/^.*:/, '');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function directElementChildren(node) {
|
|
17
|
+
return Array.from(node?.childNodes || []).filter(child => child.nodeType === 1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function directChild(node, localName) {
|
|
21
|
+
return directElementChildren(node).find(child => localNameOf(child) === localName) || null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function wordAttribute(node, localName) {
|
|
25
|
+
return node?.getAttribute?.(`w:${localName}`)
|
|
26
|
+
|| node?.getAttribute?.(localName)
|
|
27
|
+
|| '';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizedAuthor(author) {
|
|
31
|
+
return String(author || '').trim().toLowerCase();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isAnchorOnlyRun(node) {
|
|
35
|
+
if (localNameOf(node) !== 'r') return false;
|
|
36
|
+
return directElementChildren(node).every(child => [
|
|
37
|
+
'rPr', 'commentReference',
|
|
38
|
+
'bookmarkStart', 'bookmarkEnd',
|
|
39
|
+
'commentRangeStart', 'commentRangeEnd',
|
|
40
|
+
'proofErr'
|
|
41
|
+
].includes(localNameOf(child)));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isNonContentChild(node) {
|
|
45
|
+
return NON_CONTENT_CHILDREN.has(localNameOf(node)) || isAnchorOnlyRun(node);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isWhollyDeletedContentNode(node) {
|
|
49
|
+
if (localNameOf(node) === 'del') return true;
|
|
50
|
+
if (!['customXml', 'smartTag', 'sdt', 'sdtContent'].includes(localNameOf(node))) return false;
|
|
51
|
+
const contentChildren = directElementChildren(node).filter(child => (
|
|
52
|
+
!isNonContentChild(child) && localNameOf(child) !== 'sdtPr'
|
|
53
|
+
));
|
|
54
|
+
return contentChildren.every(isWhollyDeletedContentNode);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function paragraphFallsWithinMoveFromRange(paragraph) {
|
|
58
|
+
const root = paragraph?.ownerDocument?.documentElement || null;
|
|
59
|
+
if (!root) return false;
|
|
60
|
+
const openIds = new Set();
|
|
61
|
+
for (const node of [root, ...Array.from(root.getElementsByTagName?.('*') || [])]) {
|
|
62
|
+
if (node === paragraph && openIds.size > 0) return true;
|
|
63
|
+
const name = localNameOf(node);
|
|
64
|
+
const id = wordAttribute(node, 'id');
|
|
65
|
+
if (name === 'moveFromRangeStart' && id !== '') openIds.add(id);
|
|
66
|
+
if (name === 'moveFromRangeEnd' && id !== '') openIds.delete(id);
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function paragraphMarkDeletion(paragraph) {
|
|
72
|
+
const pPr = directChild(paragraph, 'pPr');
|
|
73
|
+
const rPr = directChild(pPr, 'rPr');
|
|
74
|
+
return directChild(rPr, 'del');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function hasVisibleInsertionContent(insertion) {
|
|
78
|
+
for (const node of Array.from(insertion?.getElementsByTagName?.('*') || [])) {
|
|
79
|
+
const localName = localNameOf(node);
|
|
80
|
+
if (!['t', 'tab', 'br', 'cr', 'noBreakHyphen', 'softHyphen'].includes(localName)) continue;
|
|
81
|
+
let ancestor = node.parentNode;
|
|
82
|
+
let hidden = false;
|
|
83
|
+
while (ancestor && ancestor !== insertion) {
|
|
84
|
+
const ancestorName = localNameOf(ancestor);
|
|
85
|
+
if (ancestorName === 'del' || ancestorName === 'moveFrom') {
|
|
86
|
+
hidden = true;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
ancestor = ancestor.parentNode;
|
|
90
|
+
}
|
|
91
|
+
if (hidden) continue;
|
|
92
|
+
if (localName !== 't' || (node.textContent || '').length > 0) return true;
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Detects the pre-mutation resurrection target defined by WP08: a paragraph
|
|
99
|
+
* mark deleted by another author with no surviving content in that paragraph.
|
|
100
|
+
*/
|
|
101
|
+
export function inspectForeignDeletedParagraphTarget(paragraph, mutationAuthor) {
|
|
102
|
+
const markDeletion = paragraphMarkDeletion(paragraph);
|
|
103
|
+
if (!markDeletion) {
|
|
104
|
+
return {
|
|
105
|
+
matches: false,
|
|
106
|
+
hasParagraphMarkDeletion: false,
|
|
107
|
+
foreignParagraphMarkDeletion: false,
|
|
108
|
+
allContentDeleted: false,
|
|
109
|
+
ownerAuthor: null,
|
|
110
|
+
markDeletion: null
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const ownerAuthor = wordAttribute(markDeletion, 'author') || null;
|
|
115
|
+
const foreignParagraphMarkDeletion = !ownerAuthor
|
|
116
|
+
|| normalizedAuthor(ownerAuthor) !== normalizedAuthor(mutationAuthor);
|
|
117
|
+
|
|
118
|
+
const contentChildren = directElementChildren(paragraph)
|
|
119
|
+
.filter(child => !isNonContentChild(child));
|
|
120
|
+
const allContentDeleted = contentChildren.every(isWhollyDeletedContentNode);
|
|
121
|
+
return {
|
|
122
|
+
matches: foreignParagraphMarkDeletion && allContentDeleted,
|
|
123
|
+
hasParagraphMarkDeletion: true,
|
|
124
|
+
foreignParagraphMarkDeletion,
|
|
125
|
+
allContentDeleted,
|
|
126
|
+
ownerAuthor,
|
|
127
|
+
markDeletion
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function getParagraphRestorationRefusal(paragraph) {
|
|
132
|
+
const pPr = directChild(paragraph, 'pPr');
|
|
133
|
+
if (directChild(pPr, 'sectPr')) {
|
|
134
|
+
return {
|
|
135
|
+
code: 'SECTION_BREAK_PARAGRAPH',
|
|
136
|
+
message: 'Refusing to restore a deleted paragraph whose paragraph properties contain a section break.'
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let ancestor = paragraph?.parentNode || null;
|
|
141
|
+
while (ancestor) {
|
|
142
|
+
if (localNameOf(ancestor) === 'moveFrom') {
|
|
143
|
+
return {
|
|
144
|
+
code: 'UNSUPPORTED_MOVE_REVISION',
|
|
145
|
+
message: 'Refusing to restore a paragraph that is part of a pending move-from revision.'
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
ancestor = ancestor.parentNode;
|
|
149
|
+
}
|
|
150
|
+
if (
|
|
151
|
+
paragraphFallsWithinMoveFromRange(paragraph)
|
|
152
|
+
||
|
|
153
|
+
paragraph?.getElementsByTagName?.('*')
|
|
154
|
+
&& Array.from(paragraph.getElementsByTagName('*')).some(node => ['moveFrom', 'moveFromRangeStart', 'moveFromRangeEnd'].includes(localNameOf(node)))
|
|
155
|
+
) {
|
|
156
|
+
return {
|
|
157
|
+
code: 'UNSUPPORTED_MOVE_REVISION',
|
|
158
|
+
message: 'Refusing to restore a paragraph that is part of a pending move-from revision.'
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let row = paragraph?.parentNode || null;
|
|
163
|
+
while (row && localNameOf(row) !== 'tr') row = row.parentNode;
|
|
164
|
+
const rowProperties = directChild(row, 'trPr');
|
|
165
|
+
if (rowProperties && directChild(rowProperties, 'del')) {
|
|
166
|
+
return {
|
|
167
|
+
code: 'UNSAFE_DELETED_TABLE_ROW',
|
|
168
|
+
message: 'Refusing to restore a paragraph inside a table row with a pending row deletion.'
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let sibling = paragraph?.nextSibling || null;
|
|
173
|
+
while (sibling && (sibling.nodeType !== 1 || localNameOf(sibling) !== 'p')) sibling = sibling.nextSibling;
|
|
174
|
+
if (!sibling) {
|
|
175
|
+
return {
|
|
176
|
+
code: 'UNSAFE_PARAGRAPH_PLACEMENT',
|
|
177
|
+
message: 'Refusing to restore a deleted paragraph without a following paragraph in the same structural container.'
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Finds already-authored same-paragraph resurrection shapes. This is a
|
|
186
|
+
* warning-only validation predicate because standalone validation has no
|
|
187
|
+
* mutation baseline with which to prove when a foreign insertion was added.
|
|
188
|
+
*/
|
|
189
|
+
export function findForeignDeletedParagraphResurrections(root) {
|
|
190
|
+
const paragraphs = localNameOf(root) === 'p'
|
|
191
|
+
? [root]
|
|
192
|
+
: Array.from(root?.getElementsByTagName?.('*') || []).filter(node => localNameOf(node) === 'p');
|
|
193
|
+
const matches = [];
|
|
194
|
+
|
|
195
|
+
for (const paragraph of paragraphs) {
|
|
196
|
+
const markDeletion = paragraphMarkDeletion(paragraph);
|
|
197
|
+
if (!markDeletion) continue;
|
|
198
|
+
const ownerAuthor = wordAttribute(markDeletion, 'author') || null;
|
|
199
|
+
const contentChildren = directElementChildren(paragraph)
|
|
200
|
+
.filter(child => !isNonContentChild(child));
|
|
201
|
+
const foreignInsertions = contentChildren.filter(child => {
|
|
202
|
+
if (localNameOf(child) !== 'ins' || !hasVisibleInsertionContent(child)) return false;
|
|
203
|
+
return normalizedAuthor(wordAttribute(child, 'author')) !== normalizedAuthor(ownerAuthor);
|
|
204
|
+
});
|
|
205
|
+
const onlyDeletedContentAndForeignInsertions = contentChildren.every(child => {
|
|
206
|
+
return isWhollyDeletedContentNode(child) || foreignInsertions.includes(child);
|
|
207
|
+
});
|
|
208
|
+
if (foreignInsertions.length > 0 && onlyDeletedContentAndForeignInsertions) {
|
|
209
|
+
matches.push({ paragraph, markDeletion, ownerAuthor, foreignInsertions });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return matches;
|
|
213
|
+
}
|
|
@@ -459,6 +459,25 @@ export function resolveTargetParagraph(xmlDoc, options = {}) {
|
|
|
459
459
|
return { paragraph: byId, resolvedBy: 'paragraph_id' };
|
|
460
460
|
}
|
|
461
461
|
|
|
462
|
+
if (descriptor?.fingerprint && !cleanTargetText && !parsedRef) {
|
|
463
|
+
let fingerprintCandidates = (paragraphMetadataIndex?.entries || [])
|
|
464
|
+
.filter(candidate => candidate.fingerprint === descriptor.fingerprint);
|
|
465
|
+
if (typeof descriptor.inTable === 'boolean') {
|
|
466
|
+
fingerprintCandidates = fingerprintCandidates.filter(candidate => candidate.inTable === descriptor.inTable);
|
|
467
|
+
}
|
|
468
|
+
if (fingerprintCandidates.length === 1) {
|
|
469
|
+
return { paragraph: fingerprintCandidates[0].paragraph, resolvedBy: 'fingerprint' };
|
|
470
|
+
}
|
|
471
|
+
if (fingerprintCandidates.length > 1) {
|
|
472
|
+
throw createTargetError(
|
|
473
|
+
'AMBIGUOUS_TARGET',
|
|
474
|
+
'Target fingerprint matched multiple paragraphs; provide paragraphId or index.',
|
|
475
|
+
fingerprintCandidates.map(serializeTargetCandidate)
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
throw createTargetError('TARGET_NOT_FOUND', `Target fingerprint not found: "${descriptor.fingerprint}".`);
|
|
479
|
+
}
|
|
480
|
+
|
|
462
481
|
let candidates = [];
|
|
463
482
|
if (cleanTargetText) {
|
|
464
483
|
const unfilteredCandidates = findStrictTargetCandidates(xmlDoc, cleanTargetText, paragraphMetadataIndex);
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { parseXml } from '../adapters/xml-adapter.js';
|
|
12
12
|
import { NS_W } from './types.js';
|
|
13
|
+
import { findForeignDeletedParagraphResurrections } from './paragraph-revision-safety.js';
|
|
13
14
|
|
|
14
15
|
const REVISION_ID_ELEMENTS = new Set(['ins', 'del', 'rPrChange', 'pPrChange']);
|
|
15
16
|
const REVISION_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}T/;
|
|
@@ -180,5 +181,17 @@ export function validateRedlineOoxml(oxml) {
|
|
|
180
181
|
}
|
|
181
182
|
}
|
|
182
183
|
|
|
184
|
+
// Structurally valid but lifecycle-unsafe: accepting the paragraph-mark
|
|
185
|
+
// deletion can merge or discard a foreign insertion placed into a
|
|
186
|
+
// paragraph whose pre-existing content is otherwise wholly deleted.
|
|
187
|
+
for (const resurrection of findForeignDeletedParagraphResurrections(doc)) {
|
|
188
|
+
const ownerAuthor = resurrection.ownerAuthor || 'unattributed';
|
|
189
|
+
addIssue(
|
|
190
|
+
'FOREIGN_PARAGRAPH_MARK_DELETION',
|
|
191
|
+
'warning',
|
|
192
|
+
`Paragraph deleted by ${ownerAuthor} also contains non-empty insertion content from another author; Accept/Reject lifecycle may not preserve the apparent restoration.`
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
183
196
|
return { valid: !issues.some(issue => issue.severity === 'error'), issues };
|
|
184
197
|
}
|