@ansonlai/docx-redline-js 0.7.0 → 0.7.1

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.
@@ -21,8 +21,8 @@ It is a development sample, not a package API.
21
21
  docx-redline apply contract.docx --find "thirty (30) days" --replace "sixty (60) days" --profile agent --output reviewed.docx
22
22
  ```
23
23
 
24
- The source must occur in one eligible paragraph. For text after a heading, add `--search "Section 4.1" --context-range 1:3`.
25
- `--around 3` is symmetric; use it only when either side is eligible.
24
+ The source must occur in one eligible paragraph. To narrow it, use a longer, fairly unique phrase: `--search "distinctive nearby heading or phrase" --context-range 1:3`.
25
+ Generic/repeated anchors can cause `AMBIGUOUS_TARGET` and waste a recovery turn. If `anchorMatchCount > 1`, confirm the returned location and excerpts. `--around 3` is symmetric.
26
26
 
27
27
  2. For semantic drafting such as “make this provision mutual,” extract enough
28
28
  context, then copy `exactText` with `paragraphId` or `fingerprint`:
@@ -31,7 +31,7 @@ It is a development sample, not a package API.
31
31
  docx-redline extract contract.docx --search "termination" --around 3
32
32
  ```
33
33
 
34
- Search is case-insensitive; follow `selection.hint` and `selection.nextAfter`, and report `humanReference`. Apply once with a UTF-8 operations file or serializer-backed stdin:
34
+ Search is case-insensitive; follow `selection.hint` and `selection.nextAfter`, and report `humanReference`. With `--revised`, use `hasRevisions`, `revisionAuthors`, and `deleted` or `inserted` to interpret paragraphs whose active-view `exactText` is empty. Restore one rejected-view paragraph verbatim with `apply --restore --target-id ID`; add one `--find`/`--replace` pair for an exact correction. The shortcut never expands to adjacent paragraphs. Otherwise, apply once with a UTF-8 operations file or serializer-backed stdin:
35
35
 
36
36
  ```bash
37
37
  node emit-operations.mjs | docx-redline apply contract.docx --operations - --profile agent --compact --output reviewed.docx
@@ -46,8 +46,9 @@ The `agent` profile preserves progressive execution and the ordinary revision po
46
46
  - Strong inspected targets bind to the batch start; independent edits need no bottom-up sorting.
47
47
  - Consolidate writes to one source; use captures for created-content dependencies.
48
48
  - Require `completion: true`, `written: true`, a non-null `outputPath`, and no
49
- per-operation error. For localized edits also require `change.committed: true`
50
- and `change.verification.acceptedViewMatchesCompiledText: true`. The source is
49
+ per-operation error. For localized edits also require
50
+ `results[i].change.committed: true`, `finalDisposition: "applied"`, and
51
+ `verification.acceptedViewMatchesCompiledText: true`. The source is
51
52
  not overwritten unless `--in-place` is explicit.
52
53
  - On failure, follow `error.recovery.action`. Never retry unchanged arguments.
53
54
  - `retryPlan.base: "original"` means replay the batch; `"output"` means retain committed work and retry reported indexes.
@@ -484,9 +484,10 @@ The short route for a document-editing request is:
484
484
 
485
485
  1. For an exact mechanical change with known old and new literals, use localized
486
486
  `--find`/`--replace`. Omit the target for global fail-closed resolution, or
487
- scope a visible heading with `--search` and a directional range such as
488
- `--context-range 1:3`. Repeated anchor matches are unioned; one eligible patch
489
- paragraph is still required. `--occurrence` selects only within that paragraph.
487
+ scope it with a longer, fairly unique nearby `--search` phrase and a
488
+ directional range such as `--context-range 1:3`. Generic or repeated anchors
489
+ widen the unioned scope and may fail with `AMBIGUOUS_TARGET`, costing another
490
+ turn. `--occurrence` selects only within one uniquely resolved paragraph.
490
491
  2. For semantic drafting, run one focused `extract`. Add `--around 3` when
491
492
  surrounding context on either side is needed, then copy `exactText` plus
492
493
  `paragraphId` or `fingerprint`.
@@ -499,8 +500,10 @@ The short route for a document-editing request is:
499
500
  text created elsewhere in the batch is scheduled automatically; use explicit
500
501
  captures/selectors for non-unique or advanced created-content dependencies.
501
502
  5. Walk every result and require `completion: true`, `written: true`, and no
502
- per-operation error. For localized patches also require committed `change`
503
- evidence and positive accepted-view verification.
503
+ per-operation error. For localized patches also require
504
+ `results[i].change.committed: true`, `finalDisposition: "applied"`, and
505
+ positive accepted-view verification. If `anchorMatchCount > 1`, confirm the
506
+ returned location and excerpts because the anchor was repeated.
504
507
  6. Run a focused `extract` on changed clauses only when placement or list/table
505
508
  structure needs confirmation.
506
509
 
@@ -512,8 +515,8 @@ below and make one cause-specific correction.
512
515
  # 1. Globally unique mechanical edit in one call
513
516
  docx-redline apply contract.docx --find "thirty (30) days" --replace "sixty (60) days" --profile agent --output reviewed.docx
514
517
 
515
- # 2. Mechanical edit strictly within three paragraphs after a visible heading
516
- docx-redline apply contract.docx --search "Section 4.1" --context-range 1:3 --find "thirty (30) days" --replace "sixty (60) days" --profile agent --output reviewed.docx
518
+ # 2. Directional edit using a longer phrase with a higher chance of uniqueness
519
+ docx-redline apply contract.docx --search "distinctive nearby heading or phrase" --context-range 1:3 --find "thirty (30) days" --replace "sixty (60) days" --profile agent --output reviewed.docx
517
520
 
518
521
  # 3. Focused contextual discovery for semantic drafting
519
522
  docx-redline extract contract.docx --search "termination" --around 3
@@ -590,6 +593,13 @@ Paragraph indexes are 1-based. Inspection filters are `--index 12`,
590
593
  case-insensitive. When a search returns 0 matches in the active view but matches
591
594
  exist in the alternate view (such as searching for a deleted clause to restore),
592
595
  `selection.hint` provides immediate guidance to re-run with `--view rejected`.
596
+ Compact `extract --revised` results retain `hasRevisions`, `revisionAuthors`,
597
+ and `deleted`/`inserted` cues, so an empty `exactText` is identifiable as content
598
+ hidden by the selected revision view rather than a failed filter.
599
+ For one strongly identified rejected-view paragraph, `apply --restore
600
+ --target-id ID` restores the rejected text verbatim. One exact
601
+ `--find`/`--replace` pair may adjust it without restating the paragraph. The
602
+ shortcut refuses ranges, captures, weak targets, and inferred adjacent content.
593
603
  Add `--around N` (`--context N` or `-C N`) to a search;
594
604
  context records are labeled separately and do not consume the direct-hit
595
605
  `--limit`. Continue a bounded result with `--after <paragraph-index>`. Ordinary
@@ -101,12 +101,13 @@ the first executable example should use the one-turn fast path:
101
101
  docx-redline apply input.docx --find "thirty (30) days" --replace "sixty (60) days" --profile agent --output reviewed.docx
102
102
  ```
103
103
 
104
- If the literal is not globally unique, scope it with a meaningful visible
105
- anchor. A heading followed by a multi-paragraph provision should use a
106
- directional window such as `--search "Section 4.1" --context-range 1:3`;
107
- `--around 3` is symmetric and should be used only when either side is eligible.
108
- Require the returned localized `change` evidence, including `committed: true`
109
- and `verification.acceptedViewMatchesCompiledText: true`.
104
+ If the literal is not globally unique, use a longer, fairly unique nearby phrase
105
+ as `--search` context. Generic or repeated anchors widen the unioned scope and
106
+ may cause `AMBIGUOUS_TARGET`, wasting a recovery turn. Use a directional window
107
+ such as `1:3`; use symmetric `--around 3` only when either side is eligible. On
108
+ success, `anchorMatchCount > 1` signals a repeated anchor: confirm the returned
109
+ location and excerpts. Require `results[i].change` with `committed: true`,
110
+ `finalDisposition: "applied"`, and positive accepted-view verification.
110
111
 
111
112
  Semantic requests such as “make this provision mutual” still begin with focused
112
113
  contextual extraction because the agent must identify and draft the complete
@@ -70,11 +70,12 @@ Globally unique literal edits no longer require preliminary extraction:
70
70
  docx-redline apply contract.docx --find "thirty (30) days" --replace "sixty (60) days" --profile agent --output reviewed.docx
71
71
  ```
72
72
 
73
- For a heading followed by a multi-paragraph provision, use a visible text anchor
74
- and directional paragraph scope:
73
+ When global matching is too broad, use a longer, fairly unique nearby phrase and
74
+ a directional paragraph scope. Longer anchors generally have a higher chance of
75
+ being unique:
75
76
 
76
77
  ```bash
77
- docx-redline apply contract.docx --search "Section 4.1" --context-range 1:3 --find "thirty (30) days" --replace "sixty (60) days" --profile agent --output reviewed.docx
78
+ docx-redline apply contract.docx --search "distinctive nearby heading or phrase" --context-range 1:3 --find "thirty (30) days" --replace "sixty (60) days" --profile agent --output reviewed.docx
78
79
  ```
79
80
 
80
81
  `1:3` means strictly the next three physical paragraphs, excluding the heading
@@ -84,10 +85,13 @@ document boundaries. `--around 3` is shorthand for `-3:3`.
84
85
 
85
86
  Every matching search anchor contributes a window. The library unions and
86
87
  deduplicates those windows, then requires exactly one eligible paragraph to
87
- contain the exact `find` literal. Thus a visible section number may appear both
88
- as a heading and in cross-references without the anchor itself deciding what is
89
- edited. `--occurrence` may disambiguate only inside the uniquely selected
90
- paragraph; it never chooses between paragraphs.
88
+ contain the exact `find` literal. A generic or repeated anchor can therefore
89
+ widen the scope until multiple paragraphs contain `find`, producing a safe
90
+ `AMBIGUOUS_TARGET` failure and an extra recovery turn. On success,
91
+ `results[i].change.context.anchorMatchCount > 1` means the anchor was repeated;
92
+ confirm the returned location and excerpts. It is a specificity signal, not an
93
+ automatic failure. `--occurrence` may disambiguate only inside the uniquely
94
+ selected paragraph; it never chooses between paragraphs.
91
95
 
92
96
  ### 3. Refusal Without Output Damage
93
97
 
@@ -106,43 +110,59 @@ overwritten. The source remains immutable unless `--in-place` is explicit.
106
110
 
107
111
  ### 4. Explicit Post-Mutation Change Evidence
108
112
 
109
- Successful localized operations return a compact evidence object:
113
+ Successful localized operations return a compact evidence object inside the
114
+ corresponding operation result:
110
115
 
111
116
  ```json
112
117
  {
113
- "change": {
114
- "kind": "localized_replacement",
115
- "committed": true,
116
- "finalDisposition": "applied",
117
- "target": {
118
- "paragraphId": "1A2B3C4D",
119
- "humanReference": "Section 4.1 — Termination"
120
- },
121
- "context": {
122
- "search": "Section 4.1",
123
- "range": "1:3",
124
- "anchorMatchCount": 2
125
- },
126
- "replacements": [
127
- {
128
- "find": "thirty (30) days",
129
- "replace": "sixty (60) days",
130
- "beforeExcerpt": "...thirty (30) days...",
131
- "afterExcerpt": "...sixty (60) days..."
118
+ "status": "ok",
119
+ "written": true,
120
+ "completion": true,
121
+ "results": [
122
+ {
123
+ "index": 1,
124
+ "status": "applied",
125
+ "change": {
126
+ "kind": "localized_replacement",
127
+ "committed": true,
128
+ "finalDisposition": "applied",
129
+ "target": {
130
+ "paragraphId": "1A2B3C4D",
131
+ "humanReference": "Relevant heading or text lead"
132
+ },
133
+ "context": {
134
+ "search": "distinctive nearby heading or phrase",
135
+ "range": "1:3",
136
+ "anchorMatchCount": 1
137
+ },
138
+ "replacements": [
139
+ {
140
+ "find": "thirty (30) days",
141
+ "replace": "sixty (60) days",
142
+ "beforeExcerpt": "...thirty (30) days...",
143
+ "afterExcerpt": "...sixty (60) days..."
144
+ }
145
+ ],
146
+ "verification": {
147
+ "acceptedViewMatchesCompiledText": true
148
+ }
132
149
  }
133
- ],
134
- "verification": {
135
- "acceptedViewMatchesCompiledText": true
136
150
  }
137
- }
151
+ ]
138
152
  }
139
153
  ```
140
154
 
141
- Evidence is derived from the resolved source and actual post-mutation accepted
142
- view rather than simply echoing the request. Excerpts are deterministically
143
- bounded to 120 characters. Agent completion additionally requires committed,
144
- applied evidence and positive accepted-view verification. Atomic rollback marks
145
- previously attempted evidence `committed: false` and `rolled_back`.
155
+ The excerpts come from the resolved batch-start source and compiled desired
156
+ text; the complete actual post-mutation accepted view is then compared with that
157
+ desired text. Positive verification therefore authenticates the reported after
158
+ state without returning the full paragraph. Excerpts are deterministically
159
+ bounded to 120 characters.
160
+
161
+ Report success only when `completion: true`, `written: true`, the output path is
162
+ non-null, every operation succeeded, and every localized result has
163
+ `change.committed: true`, `change.finalDisposition: "applied"`, and
164
+ `change.verification.acceptedViewMatchesCompiledText: true`. Atomic rollback
165
+ marks previously attempted evidence `committed: false` and `rolled_back`.
146
166
 
147
167
  ### 5. Agent Guidance and Development Wrapper
148
168
 
@@ -116,7 +116,7 @@
116
116
  "allOf": [ { "$ref": "#/$defs/base" }, { "type": "object", "required": ["modified"], "properties": { "type": { "enum": ["redline", "replace", "format", "list-change", "table-reconciliation", "insert"] }, "modified": { "type": "string" }, "structuredContent": { "type": "boolean" }, "targetEnd": { "$ref": "#/$defs/target" }, "targetEndRef": { "type": ["integer", "string", "null"] }, "anchor": { "$ref": "#/$defs/rejectedTextInsertionAnchor" } } } ]
117
117
  },
118
118
  {
119
- "allOf": [ { "$ref": "#/$defs/base" }, { "type": "object", "required": ["modified"], "properties": { "type": { "const": "restore" }, "modified": { "oneOf": [ { "type": "string", "minLength": 1 }, { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } } ] }, "targetEnd": { "$ref": "#/$defs/target" }, "targetEndRef": { "type": ["integer", "string", "null"] } } } ]
119
+ "allOf": [ { "$ref": "#/$defs/base" }, { "type": "object", "properties": { "type": { "const": "restore" }, "modified": { "oneOf": [ { "type": "string", "minLength": 1 }, { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } } ] }, "replacements": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/localizedReplacement" } }, "targetEnd": { "$ref": "#/$defs/target" }, "targetEndRef": { "type": ["integer", "string", "null"] } }, "oneOf": [ { "required": ["modified"], "not": { "required": ["replacements"] } }, { "required": ["replacements"], "not": { "required": ["modified"] } }, { "not": { "anyOf": [ { "required": ["modified"] }, { "required": ["replacements"] } ] } } ] } ]
120
120
  },
121
121
  {
122
122
  "allOf": [ { "$ref": "#/$defs/base" }, { "type": "object", "properties": { "type": { "const": "delete" }, "modified": { "const": "" } } } ]
package/node/cli-help.js CHANGED
@@ -12,7 +12,7 @@ const COMPACT = option('compact', '--compact', 'Emit one-line JSON to reduce pro
12
12
  const INSPECTION_OPTIONS = Object.freeze([
13
13
  HELP,
14
14
  option('search', '--search <text>', 'Case-insensitive substring search.'),
15
- option('revised', '--revised', 'Select paragraphs containing tracked revisions.'),
15
+ option('revised', '--revised', 'Select paragraphs containing tracked revisions; extract identifies authors and fully hidden deleted/inserted paragraphs.'),
16
16
  option('table', '--table', 'Select paragraphs inside tables.'),
17
17
  option('body', '--body', 'Select paragraphs outside tables.'),
18
18
  option('nonEmpty', '--non-empty', 'Exclude empty paragraphs; this is not a narrow document scope.'),
@@ -56,15 +56,14 @@ const APPLY_EXAMPLES = Object.freeze([
56
56
  }
57
57
  },
58
58
  {
59
- description: 'Counterpropose a wholly foreign-deleted paragraph found through --view rejected.',
59
+ description: 'Restore one wholly foreign-deleted paragraph verbatim from a strong rejected-view target.',
60
60
  operation: {
61
61
  type: 'restore',
62
62
  target: {
63
63
  exactText: 'Deleted source paragraph.',
64
64
  paragraphId: '3A2B3C4D',
65
65
  revisionView: 'rejected'
66
- },
67
- modified: 'Restored and revised paragraph.'
66
+ }
68
67
  }
69
68
  }
70
69
  ]);
@@ -133,11 +132,12 @@ export const CLI_COMMAND_HELP = Object.freeze({
133
132
  option('expectedRevision', '--expected-revision <token|json>', 'Reject a stale package revision.'),
134
133
  option('target', '--target <text>', 'Inline one-operation target text.'),
135
134
  option('targetId', '--target-id <paragraphId>', 'Strong inline target using a paragraphId from fresh inspection.'),
135
+ option('restore', '--restore', 'Restore one rejected-view paragraph identified by --target-id; omit --modified for verbatim restoration.'),
136
136
  option('modified', '--modified <text>', 'Complete desired accepted-view target content.'),
137
137
  option('find', '--find <exact text>', 'Exact case-sensitive source span for one localized replacement.'),
138
138
  option('replace', '--replace <text>', 'Replacement text for --find; an empty string deletes the span.'),
139
139
  option('occurrence', '--occurrence <N>', 'Positive 1-based --find occurrence within the uniquely resolved target paragraph.'),
140
- option('search', '--search <text>', 'Case-insensitive context anchor for speculative find/replace; all matching anchors are considered.'),
140
+ option('search', '--search <text>', 'Case-insensitive context anchor for speculative find/replace; prefer a longer, fairly unique phrase because all matches contribute scope.'),
141
141
  option('contextRange', '--context-range <START:END>', 'Signed inclusive paragraph offsets (-20..20) from each --search anchor; 1:3 means strictly the next three paragraphs.'),
142
142
  option('around', '--around <N>', 'With --search, shorthand for symmetric --context-range -N:N (0-20).'),
143
143
  option('comment', '--comment <text>', 'Create an inline comment operation.'),
@@ -154,7 +154,9 @@ export const CLI_COMMAND_HELP = Object.freeze({
154
154
  notes: [
155
155
  'modified is complete desired accepted-view content, not only inserted words.',
156
156
  'Use either modified or find/replace. A strong accepted-view target is preferred when already known.',
157
+ '--restore requires --target-id from rejected-view extraction; omit modified for verbatim restore or use one inline find/replace.',
157
158
  'Without a strong target, find/replace may resolve globally or within --search plus a directional context range; it fails unless exactly one paragraph is eligible.',
159
+ 'Generic or repeated search anchors widen the unioned scope and may cause AMBIGUOUS_TARGET; on success, anchorMatchCount greater than 1 signals that the returned location and excerpts should be confirmed.',
158
160
  '--occurrence disambiguates repeated text only within one uniquely selected paragraph, never between paragraphs.',
159
161
  'Inline find/replace accepts one replacement; use operations JSON for multiple simultaneous replacements.',
160
162
  'Use a structured JSON file or serializer-backed stdin; never interpolate legal text through raw shell quoting.',
package/node/cli.js CHANGED
@@ -31,6 +31,7 @@ const CLI_CAPABILITIES = [
31
31
  'localized-replacements-v1',
32
32
  'speculative-search-apply-v1',
33
33
  'localized-change-summary-v1',
34
+ 'restore-shortcuts-v1',
34
35
  'deduplicated-cli-receipts',
35
36
  'compact-cli-json-v1'
36
37
  ];
@@ -278,6 +279,7 @@ function resolveSpeculativePatchTarget(document, flags) {
278
279
  }
279
280
 
280
281
  async function readOperations(file, flags = {}, stdin = process.stdin, document = null) {
282
+ const inlineRestore = flags?.restore !== undefined;
281
283
  const hasInlineTarget = flags?.target !== undefined
282
284
  || flags?.targetId !== undefined
283
285
  || flags?.targetRef !== undefined;
@@ -287,8 +289,8 @@ async function readOperations(file, flags = {}, stdin = process.stdin, document
287
289
  const hasSpeculativeScope = flags?.search !== undefined
288
290
  || flags?.contextRange !== undefined
289
291
  || flags?.around !== undefined;
290
- if (file && (hasInlinePatch || hasSpeculativeScope)) {
291
- throw Object.assign(new Error('Use --operations or inline speculative find/replace options, not both.'), {
292
+ if (file && (hasInlinePatch || hasSpeculativeScope || inlineRestore)) {
293
+ throw Object.assign(new Error('Use --operations or inline restore/find/replace options, not both.'), {
292
294
  code: 'INVALID_OPERATION'
293
295
  });
294
296
  }
@@ -297,7 +299,12 @@ async function readOperations(file, flags = {}, stdin = process.stdin, document
297
299
  code: 'INVALID_OPERATION'
298
300
  });
299
301
  }
300
- if (!file && (hasInlineTarget || hasInlinePatch || hasSpeculativeScope)) {
302
+ if (!file && (hasInlineTarget || hasInlinePatch || hasSpeculativeScope || inlineRestore)) {
303
+ if (inlineRestore && flags.restore !== true) {
304
+ throw Object.assign(new Error('--restore is a boolean flag and does not take a value.'), {
305
+ code: 'INVALID_OPERATION'
306
+ });
307
+ }
301
308
  if (hasSpeculativeScope && !hasInlinePatch) {
302
309
  throw Object.assign(new Error('--search, --context-range, and --around are only valid with inline --find/--replace.'), {
303
310
  code: 'INVALID_OPERATION'
@@ -319,9 +326,22 @@ async function readOperations(file, flags = {}, stdin = process.stdin, document
319
326
  let target = targetId
320
327
  ? { ...(targetText == null ? {} : { exactText: targetText }), paragraphId: targetId }
321
328
  : targetText;
329
+ if (inlineRestore) {
330
+ if (!targetId) {
331
+ throw Object.assign(new Error('Inline --restore requires --target-id from rejected-view extraction.'), {
332
+ code: 'INVALID_OPERATION'
333
+ });
334
+ }
335
+ target = { ...target, revisionView: 'rejected' };
336
+ }
322
337
  let speculativeContext = null;
323
338
  let op;
324
339
  if (flags.comment) {
340
+ if (inlineRestore) {
341
+ throw Object.assign(new Error('Use --restore or --comment, not both.'), {
342
+ code: 'INVALID_OPERATION'
343
+ });
344
+ }
325
345
  if (hasInlinePatch) {
326
346
  throw Object.assign(new Error('Inline comments cannot be combined with --find/--replace.'), {
327
347
  code: 'INVALID_OPERATION'
@@ -364,7 +384,7 @@ async function readOperations(file, flags = {}, stdin = process.stdin, document
364
384
  speculativeContext = resolution.context;
365
385
  }
366
386
  op = {
367
- type: 'redline',
387
+ type: inlineRestore ? 'restore' : 'redline',
368
388
  ...(target == null ? {} : { target }),
369
389
  ...(targetRef == null ? {} : { targetRef }),
370
390
  replacements: [{
@@ -376,6 +396,14 @@ async function readOperations(file, flags = {}, stdin = process.stdin, document
376
396
  ...(flags.author ? { author: String(flags.author) } : {}),
377
397
  ...(flags.existingRevisions ? { existingRevisions: String(flags.existingRevisions) } : {})
378
398
  };
399
+ } else if (inlineRestore) {
400
+ op = {
401
+ type: 'restore',
402
+ target,
403
+ ...(flags.modified === undefined ? {} : { modified: String(flags.modified) }),
404
+ ...(flags.author ? { author: String(flags.author) } : {}),
405
+ ...(flags.existingRevisions ? { existingRevisions: String(flags.existingRevisions) } : {})
406
+ };
379
407
  } else {
380
408
  op = {
381
409
  type: 'replace',
@@ -430,8 +458,14 @@ function compactExtractParagraph(paragraph) {
430
458
  const {
431
459
  humanReference, provision, nearestHeading, index, ref, paragraphId,
432
460
  fingerprint, revisionView, exactText, inTable, list, selectionRole,
433
- contextFor
461
+ contextFor, hasRevisions, revisionAuthors, segments
434
462
  } = paragraph;
463
+ const hiddenRevisionContent = exactText.length === 0 && Array.isArray(segments)
464
+ && segments.some(segment => segment.text.length > 0 && (
465
+ revisionView === 'rejected'
466
+ ? segment.acceptedStart !== null
467
+ : segment.rejectedStart !== null
468
+ ));
435
469
  return {
436
470
  humanReference,
437
471
  provision,
@@ -444,6 +478,12 @@ function compactExtractParagraph(paragraph) {
444
478
  exactText,
445
479
  inTable,
446
480
  list,
481
+ ...(hasRevisions ? {
482
+ hasRevisions: true,
483
+ revisionAuthors,
484
+ ...(hiddenRevisionContent && revisionView === 'accepted' ? { deleted: true } : {}),
485
+ ...(hiddenRevisionContent && revisionView === 'rejected' ? { inserted: true } : {})
486
+ } : {}),
447
487
  ...(selectionRole ? { selectionRole } : {}),
448
488
  ...(Array.isArray(contextFor) ? { contextFor } : {})
449
489
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ansonlai/docx-redline-js",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Host-independent OOXML reconciliation engine for .docx manipulation with track changes",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -324,9 +324,8 @@ export function inspectDocumentParts(parts, options = {}) {
324
324
  if (options.range && (index < rangeStart || index > rangeEnd)) continue;
325
325
  if (Array.isArray(options.indexes) && !options.indexes.includes(index)) continue;
326
326
  const pNode = paragraphNodes[i];
327
- if (options.table && !hasAncestor(pNode, 'tc')) continue;
328
- if (options.body && hasAncestor(pNode, 'tc')) continue;
329
- if (options.revised && revisionAuthors(pNode).length === 0) continue;
327
+ if (options.inTable != null && hasAncestor(pNode, 'tc') !== !!options.inTable) continue;
328
+ if (options.revisedOnly && revisionAuthors(pNode).length === 0) continue;
330
329
  const altText = extractCanonicalParagraphText(pNode, { revisionView: alternateView });
331
330
  if (options.skipEmpty && altText.length === 0) continue;
332
331
  if (altText.toLowerCase().includes(needle)) {
@@ -160,7 +160,10 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
160
160
  };
161
161
  }
162
162
 
163
- if (Array.isArray(operation.replacements)) {
163
+ if (
164
+ Array.isArray(operation.replacements)
165
+ || (operation.operationKind === 'restore' && op?.modified === undefined)
166
+ ) {
164
167
  const compilation = compileOperationBatch(session.document, [op], {
165
168
  strictTargets: options.strictTargets !== false,
166
169
  onInfo: options.onInfo,
@@ -415,7 +418,14 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
415
418
  }
416
419
  if (operation._localizedReplacementCompilation) {
417
420
  let outputText = null;
418
- if (operation._compiledSourceId && session.sourceTargetRegistry) {
421
+ if (operation.operationKind === 'restore') {
422
+ const restoredParagraph = operationOptions._mutationLiveNodes.find(node => node?.localName === 'p');
423
+ if (restoredParagraph) {
424
+ outputText = extractCanonicalParagraphText(restoredParagraph, {
425
+ revisionView: 'accepted'
426
+ });
427
+ }
428
+ } else if (operation._compiledSourceId && session.sourceTargetRegistry) {
419
429
  const bound = session.sourceTargetRegistry.resolve(
420
430
  operation._compiledSourceId,
421
431
  session.document
@@ -307,19 +307,66 @@ export function validateDocumentOperation(operation) {
307
307
  }
308
308
 
309
309
  if (normalized.operationKind === 'restore') {
310
+ const hasModified = operation.modified !== undefined;
311
+ const hasReplacements = operation.replacements !== undefined;
310
312
  const validSingle = nonEmptyString(normalized.modified);
311
313
  const validRange = Array.isArray(normalized.modified)
312
314
  && normalized.modified.length > 0
313
315
  && normalized.modified.every(nonEmptyString);
314
- if (!validSingle && !validRange) {
316
+ if (hasModified && hasReplacements) {
315
317
  return {
316
318
  valid: false,
317
319
  error: {
318
320
  code: 'INVALID_OPERATION',
319
- message: 'Restore operations require a non-empty string or non-empty string array in "modified".'
321
+ message: 'Restore operations accept modified or replacements, not both.'
320
322
  }
321
323
  };
322
324
  }
325
+ if (hasModified && !validSingle && !validRange) {
326
+ return {
327
+ valid: false,
328
+ error: {
329
+ code: 'INVALID_OPERATION',
330
+ message: 'Restore modified must be a non-empty string or non-empty string array.'
331
+ }
332
+ };
333
+ }
334
+ if (!hasModified) {
335
+ if (
336
+ target.revisionView !== 'rejected'
337
+ || (!target.paragraphId && !target.fingerprint)
338
+ || target.captureRef
339
+ || target.occurrence != null
340
+ || normalized.captureKey
341
+ || normalized.targetEndDescriptor
342
+ || normalized.targetEndRef != null
343
+ || /\r|\n/.test(target.text || '')
344
+ ) {
345
+ return {
346
+ valid: false,
347
+ error: {
348
+ code: 'INVALID_OPERATION',
349
+ message: 'Verbatim and localized restore require one strong batch-start rejected-view paragraph target without captures, target occurrence, or a target range.'
350
+ }
351
+ };
352
+ }
353
+ }
354
+ if (hasReplacements) {
355
+ const replacementValidation = validateExactReplacementRequests(normalized.replacements);
356
+ if (!replacementValidation.ok) {
357
+ return { valid: false, error: replacementValidation.error };
358
+ }
359
+ if (replacementValidation.replacements.some(replacement => /\r|\n/.test(replacement.replace))) {
360
+ return {
361
+ valid: false,
362
+ error: {
363
+ code: 'INVALID_OPERATION',
364
+ message: 'Localized restore cannot insert paragraph breaks.'
365
+ }
366
+ };
367
+ }
368
+ normalized.replacements = replacementValidation.replacements;
369
+ }
323
370
  if (normalized.generateRedlines === false) {
324
371
  return {
325
372
  valid: false,
@@ -223,7 +223,37 @@ function describeTargetTextMatch(actualText, requestedText) {
223
223
  requestedExcerpt: escapeInvisibleText(requested)
224
224
  };
225
225
  }
226
-
226
+
227
+ function resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options) {
228
+ const revisionView = options?.targetDescriptor?.revisionView === 'rejected'
229
+ ? 'rejected'
230
+ : 'accepted';
231
+ const viewMetadata = metadata?.revisionView === revisionView ? metadata : null;
232
+ const index = viewMetadata?.index ?? getDocumentParagraphNodes(xmlDoc).indexOf(paragraph) + 1;
233
+ const paragraphId = viewMetadata?.paragraphId ?? getParagraphId(paragraph);
234
+ const inTable = viewMetadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl');
235
+ const text = viewMetadata?.text ?? extractCanonicalParagraphText(paragraph, { revisionView });
236
+ const fingerprint = viewMetadata?.fingerprint ?? createParagraphFingerprint(paragraph, {
237
+ text,
238
+ index,
239
+ paragraphId,
240
+ inTable,
241
+ revisionView
242
+ });
243
+ return {
244
+ index,
245
+ paragraphId,
246
+ text,
247
+ fingerprint,
248
+ revisionView,
249
+ inTable,
250
+ targetTextMatch: describeTargetTextMatch(
251
+ text,
252
+ options?.targetDescriptor?.text ?? options?.targetDescriptor?.exactText ?? targetText
253
+ )
254
+ };
255
+ }
256
+
227
257
  function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeContext = null, options = {}) {
228
258
  const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
229
259
  const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
@@ -239,17 +269,7 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
239
269
  const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
240
270
  Object.assign(options._resolutionCapture, {
241
271
  resolvedBy: resolved.resolvedBy,
242
- resolvedTarget: {
243
- index: metadata?.index ?? getDocumentParagraphNodes(xmlDoc).indexOf(paragraph) + 1,
244
- paragraphId: metadata?.paragraphId ?? getParagraphId(paragraph),
245
- text: metadata?.text ?? getParagraphText(paragraph),
246
- fingerprint: metadata?.fingerprint ?? createParagraphFingerprint(paragraph),
247
- inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl'),
248
- targetTextMatch: describeTargetTextMatch(
249
- metadata?.text ?? getParagraphText(paragraph),
250
- options?.targetDescriptor?.exactText ?? targetText
251
- )
252
- }
272
+ resolvedTarget: resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options)
253
273
  });
254
274
  }
255
275
  return resolved;
@@ -263,17 +283,7 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
263
283
  const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
264
284
  Object.assign(options._resolutionCapture, {
265
285
  resolvedBy: resolved.resolvedBy,
266
- resolvedTarget: {
267
- index: metadata?.index ?? Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'p')).indexOf(paragraph) + 1,
268
- paragraphId: metadata?.paragraphId ?? getParagraphId(paragraph),
269
- text: metadata?.text ?? getParagraphText(paragraph),
270
- fingerprint: metadata?.fingerprint ?? createParagraphFingerprint(paragraph),
271
- inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl'),
272
- targetTextMatch: describeTargetTextMatch(
273
- metadata?.text ?? getParagraphText(paragraph),
274
- options?.targetDescriptor?.exactText ?? targetText
275
- )
276
- }
286
+ resolvedTarget: resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options)
277
287
  });
278
288
  }
279
289
  return resolved;
@@ -301,19 +311,9 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
301
311
  if (options?._resolutionCapture && resolved?.paragraph) {
302
312
  const paragraph = resolved.paragraph;
303
313
  const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
304
- Object.assign(options._resolutionCapture, {
305
- resolvedBy: resolved.resolvedBy,
306
- resolvedTarget: {
307
- index: metadata?.index ?? Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'p')).indexOf(paragraph) + 1,
308
- paragraphId: metadata?.paragraphId ?? getParagraphId(paragraph),
309
- text: metadata?.text ?? getParagraphText(paragraph),
310
- fingerprint: metadata?.fingerprint ?? createParagraphFingerprint(paragraph),
311
- inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl'),
312
- targetTextMatch: describeTargetTextMatch(
313
- metadata?.text ?? getParagraphText(paragraph),
314
- options?.targetDescriptor?.exactText ?? targetText
315
- )
316
- }
314
+ Object.assign(options._resolutionCapture, {
315
+ resolvedBy: resolved.resolvedBy,
316
+ resolvedTarget: resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options)
317
317
  });
318
318
  }
319
319
  return resolved;