@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.
- package/ARCHITECTURE.md +679 -669
- package/CHANGELOG.md +32 -1
- package/README.md +29 -6
- package/core/word-xml.js +160 -118
- package/dist/docx-redline-js.esm.js +171 -51
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +93 -93
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +171 -51
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +171 -51
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_KNOWLEDGE_BASE.md +30 -4
- package/docs/SKILL_AUTHORING.md +5 -1
- package/docs/TESTING.md +4 -0
- package/docs/schemas/document-operations.schema.json +1 -1
- package/engine/oxml-engine.js +748 -736
- package/index.d.ts +6 -1
- package/index.js +2 -0
- package/node/cli.js +993 -992
- package/package.json +1 -1
- package/pipeline/markdown-processor.js +5 -2
- 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 +3358 -3346
- package/services/inspection-comparison.js +82 -0
- package/services/operation-preflight.js +42 -33
- package/services/revision-fidelity-verifier.js +5 -19
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`existingRevisions: 'auto'`:** per target, `merge-same-author` when the target has no tracked changes or only the operation
|
|
8
|
+
author's, otherwise `slice-cross-author`. Unattributed changes count as another author's, and a `targetEnd` range resolves
|
|
9
|
+
over every paragraph in it. Accepted per operation, per batch, by `applyRedlineToOxml` and as `--existing-revisions auto`;
|
|
10
|
+
preflight reports the resolved policy. `resolveExistingRevisionsPolicy` is exported and accepts a node or an array of
|
|
11
|
+
paragraphs. CLI capability `existing-revisions-auto-v1`; the contract version stays 9.
|
|
12
|
+
- **`compareDocumentInspections(expected, actual)`** (and `inspectionReviewShape`): compare two inspections by text, table position,
|
|
13
|
+
list label, review segments and comments, ignoring ids, dates and run boundaries. For verifying what a host committed.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **Literal underscore runs are no longer Markdown emphasis:** `__x__` and `_x_` follow CommonMark flanking rules (not intraword,
|
|
18
|
+
not inside a longer underscore run, not around whitespace). Signature lines such as `_________________________` survive a
|
|
19
|
+
table reconciliation and a paragraph rewrite; `snake_case` stays literal.
|
|
20
|
+
- **`structuredContent: false` is plain text:** a caller's plain-text `redline`/`replace` keeps inline Markdown markers literally
|
|
21
|
+
instead of turning them into formatting. Internal plain-structure calls are unchanged.
|
|
22
|
+
- **Comment reply paraIds no longer collide with body paragraphs:** `applyCommentReplyToParts` (and the `comment_reply`
|
|
23
|
+
operation) now collects body `w14:paraId` values before allocating a reply's id. The scan's regex contained a literal
|
|
24
|
+
backspace instead of `\b`, so body ids were never seen.
|
|
25
|
+
- **Internal:** the Reject All verifier's numbering check uses its memoized definition parser (it was never wired in), and the
|
|
26
|
+
reject-roundtrip matrix's revision-identity cross-check and `RECEIPT_RECONCILIATION_FAILED` gate now actually run (the
|
|
27
|
+
same backspace-for-`\b` defect had made them no-ops).
|
|
28
|
+
- **Same-author checks count unattributed tracked changes as another author's:** a target holding the operation author's
|
|
29
|
+
revisions plus a change with no or a blank `w:author` passed as all same-author. `merge-same-author` then refused with
|
|
30
|
+
`UNSAFE_REVISION_NESTING` (`manual_resolution`) instead of `EXISTING_REVISIONS`, and `slice-cross-author` refused instead of
|
|
31
|
+
slicing; no revision was lost. Merge now refuses with `EXISTING_REVISIONS` (`revisionAuthors` lists named authors only; the
|
|
32
|
+
message adds `unattributed`), and slicing applies while Reject All still restores the unattributed change.
|
|
33
|
+
|
|
3
34
|
## 0.9.0
|
|
4
35
|
|
|
5
36
|
### Changed
|
|
@@ -247,7 +278,7 @@
|
|
|
247
278
|
### Known limitations
|
|
248
279
|
|
|
249
280
|
- Plain paragraph to list conversion and list format changes are not yet supported canonically; see
|
|
250
|
-
`docs/plans/2026-09-30-canonical-list-operations.md`.
|
|
281
|
+
`docs/plans/completed/2026-09-30-canonical-list-operations.md`.
|
|
251
282
|
- `1. Header` to `1. Header` on a bare `document.xml` (not a packaged `.docx`) can fail with `RECEIPT_RECONCILIATION_FAILED`.
|
|
252
283
|
|
|
253
284
|
## 0.8.2
|
package/README.md
CHANGED
|
@@ -21,7 +21,13 @@ Converts AI-generated or programmatic text/markdown edits into valid Office Open
|
|
|
21
21
|
- Package plumbing helpers for numbering.xml, comments.xml, content types, and relationships
|
|
22
22
|
- Zero host dependencies: works in Node.js, browsers, Deno, and similar JS runtimes with DOM parsing support
|
|
23
23
|
|
|
24
|
-
## What's New in 0.9.0 and 0.8.x
|
|
24
|
+
## What's New in 0.10.0, 0.9.0 and 0.8.x
|
|
25
|
+
|
|
26
|
+
0.10.0 adds `existingRevisions: 'auto'` (capability `existing-revisions-auto-v1`, contract version stays 9), which
|
|
27
|
+
merges a target's own-author history and slices around another reviewer's, per target. It also adds
|
|
28
|
+
`compareDocumentInspections` for checking a committed document against a prepared one, and fixes underscore runs such as
|
|
29
|
+
signature lines (`_____`) collapsing to one `_`. A caller's `structuredContent: false` now keeps inline Markdown markers
|
|
30
|
+
literal. See the [0.10.0 release notes](./docs/releases/0.10.0.md).
|
|
25
31
|
|
|
26
32
|
0.9.0 moves the CLI contract to version 9 and adds the `list-format` operation (`convert-plain`, `change-format`, `join`),
|
|
27
33
|
`paragraph-format` numbering binding, list-conversion diagnostics, a single numbering allocator and merge, and a Reject All
|
|
@@ -292,7 +298,7 @@ docx-redline preflight contract.docx --operations operations.json --author "Edit
|
|
|
292
298
|
All commands emit JSON on stdout. `apply` defaults:
|
|
293
299
|
- **Author**: Defaults to `'AI Redliner'` (or `DOCX_REDLINE_AUTHOR` environment variable).
|
|
294
300
|
- **Output overwrite**: Destination files provided via `--output` overwrite by default. Pass `--no-overwrite` or `--no-clobber` to safeguard existing destination files. The source input is never overwritten unless `--in-place` is specified.
|
|
295
|
-
- **Existing revisions**: Defaults to `'merge-same-author'`. Pass `--existing-revisions slice-cross-author` to edit inside another reviewer's pending insertions with native carrier slicing.
|
|
301
|
+
- **Existing revisions**: Defaults to `'merge-same-author'`. Pass `--existing-revisions slice-cross-author` to edit inside another reviewer's pending insertions with native carrier slicing, or `--existing-revisions auto` to choose per target (slicing only where another reviewer's changes are present).
|
|
296
302
|
- **Transactionality**: Defaults to `atomic: false` (applies valid operations and reports any failures). Pass `--atomic` for all-or-nothing rollback on any operation error.
|
|
297
303
|
- **Complete-success exit**: Pass `--require-complete` when a progressive `partial` result must exit with code `3`; errors exit with code `2`. Without the flag, partial results retain the legacy zero exit code, so always inspect `completion`.
|
|
298
304
|
- **Agent profile**: `--profile agent` enables complete-success exit behavior while retaining the ordinary progressive transaction and existing-revision defaults. Compose it with `--atomic` or an explicit `--existing-revisions` policy when intended, and verify the resolved `effectiveOptions`.
|
|
@@ -329,7 +335,8 @@ capabilities `revision-fidelity-v1`, `list-conversion-diagnostics-v1`, `paragrap
|
|
|
329
335
|
`part` targeting, the `comment_resolve` operation and `delete-comments --comment-id` are advertised as
|
|
330
336
|
`header-footer-parts-v1`, `comment-resolve-operation-v1` and `delete-comments-by-id-v1`. The remaining advertised
|
|
331
337
|
capabilities are `atomic-batch-results-on-package-failure`, `baseline-aware-validation`, `compact-mutation-results`,
|
|
332
|
-
`cross-author-revision-slicing`, `document-scoped-list-revision-ids` and `format-text-occurrence-v1`.
|
|
338
|
+
`cross-author-revision-slicing`, `document-scoped-list-revision-ids` and `format-text-occurrence-v1`. 0.10.0 adds
|
|
339
|
+
`existing-revisions-auto-v1` (`existingRevisions: 'auto'`) without a contract bump.
|
|
333
340
|
Wrappers should negotiate only the
|
|
334
341
|
capabilities they use. Run `docx-redline <command> --help` for that command's
|
|
335
342
|
machine-readable options, behavior, exit codes, canonical GitHub documentation links, and compact examples.
|
|
@@ -375,10 +382,10 @@ See the [compact agent fast start](./docs/AGENT_FAST_START.md), the
|
|
|
375
382
|
| `generateRedlines` | `boolean` | `true` | When `true`, emit Word-native tracked changes (`w:ins`/`w:del`). When `false`, apply clean direct edits without revision markup. **Note: Redlines are not always the preferred method** — pass `generateRedlines: false` (or `--no-redlines` via CLI) when producing clean execution drafts, restructuring documents, or when revision clutter is unwanted. |
|
|
376
383
|
| `author` | `string` | `'AI Redliner'` | Reviewer/author name stamped on generated tracked changes and comments. Overridable via `DOCX_REDLINE_AUTHOR` env variable. |
|
|
377
384
|
| `atomic` | `boolean` | `false` | Batch transaction mode. By default (`false`), valid edits are applied and failing operations report errors. When `true`, any operation failure rolls back the entire batch to the original document state (`rolledBack: true`, `hasChanges: false`). Feature prominently in high-assurance workflows. |
|
|
378
|
-
| `structuredContent` | `boolean` | `true` | Auto-detects Markdown tables, headings (`#`), and lists in replacement text and renders them as native Word elements (`w:tbl`, `w:pStyle`, `w:numPr`). Pass `false` to treat replacement text strictly as plain text. |
|
|
385
|
+
| `structuredContent` | `boolean` | `true` | Auto-detects Markdown tables, headings (`#`), and lists in replacement text and renders them as native Word elements (`w:tbl`, `w:pStyle`, `w:numPr`). Pass `false` to treat replacement text strictly as plain text: inline Markdown markers (`**`, `__`, `_`, `~~`, `++`) are kept literally. With `true`, underscores follow CommonMark flanking rules, so signature lines (`_____`) and `snake_case` stay literal. |
|
|
379
386
|
| `pairReplacements` | `boolean` | `true` | Links adjacent `<w:del>` and `<w:ins>` revisions with matching timestamps so Word groups them as a single replacement in the Reviewing Pane. |
|
|
380
387
|
| `strictTargets` | `boolean` | `true` (CLI/facade) | Requires exact target descriptors (`exactText`, `paragraphId`, `index`, `occurrence`, `fingerprint`) and forbids ambiguous matching. Defaults to `false` in low-level runner for backwards compatibility. |
|
|
381
|
-
| `existingRevisions` | `string` | `'merge-same-author'` | How to handle paragraphs with existing tracked changes. `'merge-same-author'` merges revisions from the same author and protects other authors with `EXISTING_REVISIONS`. `'slice-cross-author'` keeps that same-author merge behavior while allowing Word-native edits inside another author's pending insertion. Pass `'accept-all-first'` to normalize prior revisions or `'reject-input'` to refuse editing revised paragraphs. |
|
|
388
|
+
| `existingRevisions` | `string` | `'merge-same-author'` | How to handle paragraphs with existing tracked changes. `'merge-same-author'` merges revisions from the same author and protects other authors with `EXISTING_REVISIONS`. `'slice-cross-author'` keeps that same-author merge behavior while allowing Word-native edits inside another author's pending insertion. Pass `'accept-all-first'` to normalize prior revisions or `'reject-input'` to refuse editing revised paragraphs. `'auto'` chooses per target: `'merge-same-author'` when the target has no tracked changes or only the operation author's, otherwise `'slice-cross-author'` (preflight reports the resolved policy). |
|
|
382
389
|
| `removeFormatting` | `boolean` | `false` | When `true` and the text is unchanged with no Markdown hints, strips existing bold/italic/underline/strikethrough formatting. |
|
|
383
390
|
| `verifyRevisionFidelity` | `'enforce' \| 'warn' \| 'off'` | `'enforce'` | Post-apply Reject All check described under [Reject All verification](#reject-all-verification). CLI: `--revision-fidelity`; the agent profile rejects `off`. |
|
|
384
391
|
| `listMarkers` | `'literal'` | unset | Per operation. Accepts list-marker text as literal text and suppresses `LIST_MARKER_NOT_BOUND`, `UNSUPPORTED_LIST_CONVERSION`, `LIST_FORMAT_NOT_APPLIED` and `UNSUPPORTED_LIST_FORMAT_CHANGE` for that operation. |
|
|
@@ -422,6 +429,19 @@ The engine applies Microsoft Word Desktop-native tracked change structures:
|
|
|
422
429
|
- **Straddle deletions**: Deletions spanning between baseline text and pending insertions cleanly partition across their respective container contexts without invalid coalescing.
|
|
423
430
|
- **Lifecycle parity**: Accepting or rejecting either reviewer independently produces identical results to Microsoft Word Desktop's native review pane.
|
|
424
431
|
|
|
432
|
+
### Comparing inspections
|
|
433
|
+
|
|
434
|
+
`compareDocumentInspections(expected, actual)` checks that two inspections describe the same reviewable document. It compares paragraph text, table position, list labels, review segments (adjacent segments of one kind and author are merged) and comments, and ignores ids, dates, rsids, paragraph ids and run boundaries. A host that writes a prepared package through another API can inspect what it prepared and what it committed, then compare them:
|
|
435
|
+
|
|
436
|
+
```js
|
|
437
|
+
import { inspectDocumentParts, compareDocumentInspections } from '@ansonlai/docx-redline-js';
|
|
438
|
+
|
|
439
|
+
const check = compareDocumentInspections(inspectDocumentParts(preparedParts), inspectDocumentParts(committedParts));
|
|
440
|
+
if (!check.matches) console.warn(check.differences); // at most the first 10 differences
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
`inspectionReviewShape(inspection)` returns the normalized shape the comparison uses.
|
|
444
|
+
|
|
425
445
|
### Replacing a heading with a tracked list
|
|
426
446
|
|
|
427
447
|
The list route treats a one-paragraph heading expanded into multiple markdown
|
|
@@ -560,6 +580,7 @@ an earlier block has already replaced.
|
|
|
560
580
|
| `analyzeStructuredContent(markdown)` | Decompose mixed Markdown into typed blocks and report malformed table syntax without creating an operation. |
|
|
561
581
|
| `planStructuredReplacement(target, markdown, options)` | Validate mixed content and return one atomic `structuredContent` replacement operation, or `operation: null` with issues. |
|
|
562
582
|
| `containsTrackedChanges(xmlDoc)` | Detect `w:ins`, `w:del`, move revisions, property changes, and paragraph-mark revision markup in a parsed OOXML document/fragment. |
|
|
583
|
+
| `resolveExistingRevisionsPolicy(policy, node, author)` | Resolve `existingRevisions: 'auto'` for a parsed paragraph, scope element, or an array of range paragraphs: `'slice-cross-author'` when any tracked change is by another or an unattributed author, otherwise `'merge-same-author'`. Other policies are returned unchanged. |
|
|
563
584
|
| `validateRedlineOoxml(oxml)` | Validate generated redline OOXML against the package's structural invariants (no nested revisions, `w:delText` inside `w:del`, complete metadata, unique revision ids, preserved boundary whitespace). Returns `{ valid, issues }`; run it before writing output into a package. |
|
|
564
585
|
|
|
565
586
|
### Services
|
|
@@ -567,7 +588,9 @@ an earlier block has already replaced.
|
|
|
567
588
|
| Function | Purpose |
|
|
568
589
|
|----------|---------|
|
|
569
590
|
| `injectCommentsIntoOoxml(oxml, comments, options)` | Add comments anchored to text ranges. |
|
|
570
|
-
| `applyCommentReplyToParts(options)` | Build a threaded reply in `comments.xml` and `commentsExtended.xml` without adding a document-body anchor. |
|
|
591
|
+
| `applyCommentReplyToParts(options)` | Build a threaded reply in `comments.xml` and `commentsExtended.xml` without adding a document-body anchor. Pass `documentXml` so the reply's `w14:paraId` also avoids body paragraph ids. |
|
|
592
|
+
| `compareDocumentInspections(expected, actual)` | Compare two `inspectDocumentParts` / `inspect()` results by text, table position, list label, review segments and comments, ignoring ids, dates and run boundaries. Returns `{ matches, differences }` (at most 10). See [Comparing inspections](#comparing-inspections). |
|
|
593
|
+
| `inspectionReviewShape(inspection)` | The normalized, host-independent shape `compareDocumentInspections` compares. |
|
|
571
594
|
| `acceptTrackedChangesInOoxml(oxml, { author?, allAuthors? })` | Accept `w:ins` / `w:del` / `w:moveFrom` / `w:moveTo` / `*PrChange` revisions for one author or all authors. |
|
|
572
595
|
| `rejectTrackedChangesInOoxml(oxml, { author?, allAuthors? })` | Reject `w:ins` / `w:del` / `w:moveFrom` / `w:moveTo` / `*PrChange` revisions for one author or all authors. |
|
|
573
596
|
| `deleteCommentsByAuthorInOoxml(oxml, { author?, allAuthors? })` | Delete matching comment definitions and anchors present in the supplied OOXML payload. Real `.docx` packages require updating both `word/comments.xml` and `word/document.xml`. |
|
package/core/word-xml.js
CHANGED
|
@@ -1,118 +1,160 @@
|
|
|
1
|
-
import { NS_W } from './types.js';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Returns true when a node is a WordprocessingML element with the given local name.
|
|
5
|
-
*
|
|
6
|
-
* @param {Node|null|undefined} node - Candidate node
|
|
7
|
-
* @param {string} localName - Word local name, for example `r` or `tbl`
|
|
8
|
-
* @returns {boolean}
|
|
9
|
-
*/
|
|
10
|
-
export function isWordElement(node, localName) {
|
|
11
|
-
if (!node || node.nodeType !== 1) return false;
|
|
12
|
-
if (node.namespaceURI === NS_W && node.localName === localName) return true;
|
|
13
|
-
const nodeName = String(node.nodeName || '');
|
|
14
|
-
return nodeName === `w:${localName}` || nodeName === localName;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Creates a WordprocessingML element using namespace-aware DOM APIs when available.
|
|
19
|
-
*
|
|
20
|
-
* @param {Document} xmlDoc - Target document
|
|
21
|
-
* @param {string} qualifiedName - Qualified name, for example `w:r`
|
|
22
|
-
* @returns {Element}
|
|
23
|
-
*/
|
|
24
|
-
export function createWordElement(xmlDoc, qualifiedName) {
|
|
25
|
-
return typeof xmlDoc.createElementNS === 'function'
|
|
26
|
-
? xmlDoc.createElementNS(NS_W, qualifiedName)
|
|
27
|
-
: xmlDoc.createElement(qualifiedName);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function wordElementsByLocalName(xmlDoc, localName) {
|
|
31
|
-
const namespaced = Array.from(xmlDoc?.getElementsByTagNameNS?.(NS_W, localName) || []);
|
|
32
|
-
if (namespaced.length > 0) return namespaced;
|
|
33
|
-
return Array.from(xmlDoc?.getElementsByTagName?.('*') || []).filter(node => isWordElement(node, localName));
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// Keep revision discovery and author discovery on the same taxonomy. Cell
|
|
37
|
-
// markers are included for fail-closed detection even though selective
|
|
38
|
-
// acceptance/rejection of those structural revisions is not yet supported.
|
|
39
|
-
const TRACKED_CHANGE_NAMES = [
|
|
40
|
-
'ins',
|
|
41
|
-
'del',
|
|
42
|
-
'moveFrom',
|
|
43
|
-
'moveTo',
|
|
44
|
-
'moveFromRangeStart',
|
|
45
|
-
'moveFromRangeEnd',
|
|
46
|
-
'moveToRangeStart',
|
|
47
|
-
'moveToRangeEnd',
|
|
48
|
-
'rPrChange',
|
|
49
|
-
'pPrChange',
|
|
50
|
-
'tblPrChange',
|
|
51
|
-
'trPrChange',
|
|
52
|
-
'tcPrChange',
|
|
53
|
-
'cellIns',
|
|
54
|
-
'cellDel'
|
|
55
|
-
];
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Returns true if a document or fragment contains Word tracked-change markup.
|
|
59
|
-
*
|
|
60
|
-
* @param {Document|Element} xmlDoc - Parsed OOXML document or element
|
|
61
|
-
* @returns {boolean}
|
|
62
|
-
*/
|
|
63
|
-
export function containsTrackedChanges(xmlDoc) {
|
|
64
|
-
return TRACKED_CHANGE_NAMES.some(localName => wordElementsByLocalName(xmlDoc, localName).length > 0);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Collects all distinct author names from tracked changes in the given XML document or element.
|
|
69
|
-
*
|
|
70
|
-
* @param {Document|Element|null|undefined} xmlDocOrElement
|
|
71
|
-
* @returns {string[]} Sorted unique list of author names.
|
|
72
|
-
*/
|
|
73
|
-
export function getTrackedChangeAuthors(xmlDocOrElement) {
|
|
74
|
-
if (!xmlDocOrElement) return [];
|
|
75
|
-
const authors = new Set();
|
|
76
|
-
for (const localName of TRACKED_CHANGE_NAMES) {
|
|
77
|
-
for (const node of wordElementsByLocalName(xmlDocOrElement, localName)) {
|
|
78
|
-
const author = node.getAttribute?.('w:author')
|
|
79
|
-
|| node.getAttribute?.('author')
|
|
80
|
-
|| (typeof node.getAttributeNS === 'function' ? node.getAttributeNS(NS_W, 'author') : null);
|
|
81
|
-
if (author && typeof author === 'string' && author.trim()) {
|
|
82
|
-
authors.add(author.trim());
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
return [...authors].sort();
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Classifies the shape of an OOXML payload.
|
|
91
|
-
*
|
|
92
|
-
* @param {string} oxml - OOXML payload
|
|
93
|
-
* @returns {'package'|'document'|'fragment'}
|
|
94
|
-
*/
|
|
95
|
-
export function classifyOoxmlSourceType(oxml) {
|
|
96
|
-
const trimmed = String(oxml || '').trim();
|
|
97
|
-
if (/^<\?xml\b[^>]*>\s*<pkg:package\b/i.test(trimmed) || /^<pkg:package\b/i.test(trimmed)) {
|
|
98
|
-
return 'package';
|
|
99
|
-
}
|
|
100
|
-
if (/^<\?xml\b[^>]*>\s*<(?:w:)?document\b/i.test(trimmed) || /^<(?:w:)?document\b/i.test(trimmed)) {
|
|
101
|
-
return 'document';
|
|
102
|
-
}
|
|
103
|
-
return 'fragment';
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Adds `sourceType` metadata to OOXML result objects without changing payloads.
|
|
108
|
-
*
|
|
109
|
-
* @template T
|
|
110
|
-
* @param {T & { oxml?: string, sourceType?: 'package'|'document'|'fragment' }} result - Result object
|
|
111
|
-
* @returns {T & { sourceType?: 'package'|'document'|'fragment' }}
|
|
112
|
-
*/
|
|
113
|
-
export function withOoxmlSourceType(result) {
|
|
114
|
-
if (!result || typeof result !== 'object' || result.sourceType || typeof result.oxml !== 'string') {
|
|
115
|
-
return result;
|
|
116
|
-
}
|
|
117
|
-
return { ...result, sourceType: classifyOoxmlSourceType(result.oxml) };
|
|
118
|
-
}
|
|
1
|
+
import { NS_W } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns true when a node is a WordprocessingML element with the given local name.
|
|
5
|
+
*
|
|
6
|
+
* @param {Node|null|undefined} node - Candidate node
|
|
7
|
+
* @param {string} localName - Word local name, for example `r` or `tbl`
|
|
8
|
+
* @returns {boolean}
|
|
9
|
+
*/
|
|
10
|
+
export function isWordElement(node, localName) {
|
|
11
|
+
if (!node || node.nodeType !== 1) return false;
|
|
12
|
+
if (node.namespaceURI === NS_W && node.localName === localName) return true;
|
|
13
|
+
const nodeName = String(node.nodeName || '');
|
|
14
|
+
return nodeName === `w:${localName}` || nodeName === localName;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Creates a WordprocessingML element using namespace-aware DOM APIs when available.
|
|
19
|
+
*
|
|
20
|
+
* @param {Document} xmlDoc - Target document
|
|
21
|
+
* @param {string} qualifiedName - Qualified name, for example `w:r`
|
|
22
|
+
* @returns {Element}
|
|
23
|
+
*/
|
|
24
|
+
export function createWordElement(xmlDoc, qualifiedName) {
|
|
25
|
+
return typeof xmlDoc.createElementNS === 'function'
|
|
26
|
+
? xmlDoc.createElementNS(NS_W, qualifiedName)
|
|
27
|
+
: xmlDoc.createElement(qualifiedName);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function wordElementsByLocalName(xmlDoc, localName) {
|
|
31
|
+
const namespaced = Array.from(xmlDoc?.getElementsByTagNameNS?.(NS_W, localName) || []);
|
|
32
|
+
if (namespaced.length > 0) return namespaced;
|
|
33
|
+
return Array.from(xmlDoc?.getElementsByTagName?.('*') || []).filter(node => isWordElement(node, localName));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Keep revision discovery and author discovery on the same taxonomy. Cell
|
|
37
|
+
// markers are included for fail-closed detection even though selective
|
|
38
|
+
// acceptance/rejection of those structural revisions is not yet supported.
|
|
39
|
+
const TRACKED_CHANGE_NAMES = [
|
|
40
|
+
'ins',
|
|
41
|
+
'del',
|
|
42
|
+
'moveFrom',
|
|
43
|
+
'moveTo',
|
|
44
|
+
'moveFromRangeStart',
|
|
45
|
+
'moveFromRangeEnd',
|
|
46
|
+
'moveToRangeStart',
|
|
47
|
+
'moveToRangeEnd',
|
|
48
|
+
'rPrChange',
|
|
49
|
+
'pPrChange',
|
|
50
|
+
'tblPrChange',
|
|
51
|
+
'trPrChange',
|
|
52
|
+
'tcPrChange',
|
|
53
|
+
'cellIns',
|
|
54
|
+
'cellDel'
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Returns true if a document or fragment contains Word tracked-change markup.
|
|
59
|
+
*
|
|
60
|
+
* @param {Document|Element} xmlDoc - Parsed OOXML document or element
|
|
61
|
+
* @returns {boolean}
|
|
62
|
+
*/
|
|
63
|
+
export function containsTrackedChanges(xmlDoc) {
|
|
64
|
+
return TRACKED_CHANGE_NAMES.some(localName => wordElementsByLocalName(xmlDoc, localName).length > 0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Collects all distinct author names from tracked changes in the given XML document or element.
|
|
69
|
+
*
|
|
70
|
+
* @param {Document|Element|null|undefined} xmlDocOrElement
|
|
71
|
+
* @returns {string[]} Sorted unique list of author names.
|
|
72
|
+
*/
|
|
73
|
+
export function getTrackedChangeAuthors(xmlDocOrElement) {
|
|
74
|
+
if (!xmlDocOrElement) return [];
|
|
75
|
+
const authors = new Set();
|
|
76
|
+
for (const localName of TRACKED_CHANGE_NAMES) {
|
|
77
|
+
for (const node of wordElementsByLocalName(xmlDocOrElement, localName)) {
|
|
78
|
+
const author = node.getAttribute?.('w:author')
|
|
79
|
+
|| node.getAttribute?.('author')
|
|
80
|
+
|| (typeof node.getAttributeNS === 'function' ? node.getAttributeNS(NS_W, 'author') : null);
|
|
81
|
+
if (author && typeof author === 'string' && author.trim()) {
|
|
82
|
+
authors.add(author.trim());
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return [...authors].sort();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Classifies the shape of an OOXML payload.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} oxml - OOXML payload
|
|
93
|
+
* @returns {'package'|'document'|'fragment'}
|
|
94
|
+
*/
|
|
95
|
+
export function classifyOoxmlSourceType(oxml) {
|
|
96
|
+
const trimmed = String(oxml || '').trim();
|
|
97
|
+
if (/^<\?xml\b[^>]*>\s*<pkg:package\b/i.test(trimmed) || /^<pkg:package\b/i.test(trimmed)) {
|
|
98
|
+
return 'package';
|
|
99
|
+
}
|
|
100
|
+
if (/^<\?xml\b[^>]*>\s*<(?:w:)?document\b/i.test(trimmed) || /^<(?:w:)?document\b/i.test(trimmed)) {
|
|
101
|
+
return 'document';
|
|
102
|
+
}
|
|
103
|
+
return 'fragment';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Adds `sourceType` metadata to OOXML result objects without changing payloads.
|
|
108
|
+
*
|
|
109
|
+
* @template T
|
|
110
|
+
* @param {T & { oxml?: string, sourceType?: 'package'|'document'|'fragment' }} result - Result object
|
|
111
|
+
* @returns {T & { sourceType?: 'package'|'document'|'fragment' }}
|
|
112
|
+
*/
|
|
113
|
+
export function withOoxmlSourceType(result) {
|
|
114
|
+
if (!result || typeof result !== 'object' || result.sourceType || typeof result.oxml !== 'string') {
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
return { ...result, sourceType: classifyOoxmlSourceType(result.oxml) };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Returns true if any tracked change in the node lacks a non-blank author.
|
|
122
|
+
* Same-author checks treat such a revision as another reviewer's, because
|
|
123
|
+
* `getTrackedChangeAuthors` omits it.
|
|
124
|
+
*
|
|
125
|
+
* @param {Document|Element|null|undefined} node
|
|
126
|
+
* @returns {boolean}
|
|
127
|
+
*/
|
|
128
|
+
export function hasUnattributedTrackedChange(node) {
|
|
129
|
+
if (!node) return false;
|
|
130
|
+
for (const localName of TRACKED_CHANGE_NAMES) {
|
|
131
|
+
for (const change of wordElementsByLocalName(node, localName)) {
|
|
132
|
+
const author = change.getAttribute?.('w:author')
|
|
133
|
+
|| change.getAttribute?.('author')
|
|
134
|
+
|| (typeof change.getAttributeNS === 'function' ? change.getAttributeNS(NS_W, 'author') : null);
|
|
135
|
+
if (!author || !String(author).trim()) return true;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Resolves `existingRevisions: 'auto'` for one target. A target without tracked
|
|
143
|
+
* changes, or whose changes are all by `author`, merges same-author history;
|
|
144
|
+
* any other or unattributed reviewer's change selects 'slice-cross-author',
|
|
145
|
+
* which preserves that history. Other policies are returned unchanged.
|
|
146
|
+
* @param {string|null|undefined} policy
|
|
147
|
+
* @param {Node|Array<Node>} node - target paragraph, scope element or document, or every paragraph of a range
|
|
148
|
+
* @param {string} author
|
|
149
|
+
* @returns {string|null|undefined}
|
|
150
|
+
*/
|
|
151
|
+
export function resolveExistingRevisionsPolicy(policy, node, author) {
|
|
152
|
+
if (policy !== 'auto') return policy;
|
|
153
|
+
const nodes = (Array.isArray(node) ? node : [node]).filter(Boolean);
|
|
154
|
+
const own = String(author || '').trim().toLowerCase();
|
|
155
|
+
const foreign = nodes.some(item => containsTrackedChanges(item) && (
|
|
156
|
+
hasUnattributedTrackedChange(item)
|
|
157
|
+
|| getTrackedChangeAuthors(item).some(name => name.trim().toLowerCase() !== own)
|
|
158
|
+
));
|
|
159
|
+
return foreign ? 'slice-cross-author' : 'merge-same-author';
|
|
160
|
+
}
|