@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
|
@@ -467,6 +467,7 @@ type ExistingRevisionsPolicy =
|
|
|
467
467
|
- Exact Accept-All equality with the submitted modified string.
|
|
468
468
|
- Explicit fail-closed test proving mismatch status, error code, mismatch offset, and byte-exact original OOXML rollback.
|
|
469
469
|
* **Verification**:
|
|
470
|
+
- `node tests/cross_author_slicing_replacement_anchor_tests.mjs` — PASS, 12 scenarios.
|
|
470
471
|
- `node tests/cross_author_slicing_hyperlink_roundtrip_tests.mjs` — PASS.
|
|
471
472
|
- `npm test` — PASS, 93 test files passed and 0 failed.
|
|
472
473
|
- `npm run lint` — PASS.
|
|
@@ -504,11 +505,291 @@ type ExistingRevisionsPolicy =
|
|
|
504
505
|
- Stress helpers `escapeXml`, `run`, `insertion`, `paragraph`, `parsed`, `acceptedText`, `authorOf`, `assertValid`, and `assertInsertionRoundTrip` (NEW).
|
|
505
506
|
* **Verification**:
|
|
506
507
|
- `node tests/cross_author_slicing_insertion_stress_tests.mjs` — PASS, 76 scenarios.
|
|
507
|
-
- `npm test` — PASS,
|
|
508
|
+
- `npm test` — PASS, 95 test files passed and 0 failed.
|
|
508
509
|
- `npm run lint` — PASS.
|
|
509
510
|
- `npm run check:types` — PASS; all 123 runtime exports have declarations.
|
|
510
511
|
- `git diff --check` — PASS (line-ending conversion notices only; no whitespace errors).
|
|
511
512
|
|
|
513
|
+
### Hyperlink-Adjacent Replacement Follow-Up [COMPLETED 2026-09-08]
|
|
514
|
+
* **Bug Report**: Replacing the space immediately after a Privacy Policy hyperlink with a comma and execution-date qualifier failed with `PATCH_ROUNDTRIP_MISMATCH`. The generated intermediate OOXML moved the qualifier and URL relative to the following definition text.
|
|
515
|
+
* **Root Cause**: `processDelete` split and removed the run containing the replaced boundary space, but `processInsert` subsequently resolved the paired insertion through the pre-mutation span index. That span still referenced the detached source run, so insertion placement fell back to the wrong paragraph location.
|
|
516
|
+
* **Fix**: `processDelete` now records a stable parent/reference-node anchor at a non-carrier deletion boundary. `processInsert` consumes that anchor for the immediately paired insertion when no explicit insertion affinity was requested. Foreign `w:ins` carriers continue to use their existing carrier-splitting anchor and explicit affinity remains authoritative.
|
|
517
|
+
* **Additional Defect Found by the Matrix**: Two pure insertions in the same source run could detach the shared pre-mutation span after the first insertion and relocate the second insertion to the paragraph end. Multi-insertion-only slicing now applies insertions from right to left and rebuilds the live span index between mutations.
|
|
518
|
+
* **Files Touched**:
|
|
519
|
+
- `engine/surgical-diff-application.js`
|
|
520
|
+
- `engine/surgical-mode.js`
|
|
521
|
+
- `tests/cross_author_slicing_hyperlink_roundtrip_tests.mjs`
|
|
522
|
+
- `tests/cross_author_slicing_replacement_anchor_tests.mjs` (NEW)
|
|
523
|
+
- `CHANGELOG.md`
|
|
524
|
+
- `docs/plans/2026-09-08-cross-author-revision-slicing.md`
|
|
525
|
+
* **Functions Touched**:
|
|
526
|
+
- `processDelete` (MODIFIED): records the live DOM insertion boundary while splitting a deleted run.
|
|
527
|
+
- `processInsert` (MODIFIED): consumes the stable replacement anchor before consulting stale pre-mutation spans.
|
|
528
|
+
- `applySurgicalMode` (MODIFIED): uses live right-to-left application for multiple insertion-only diffs.
|
|
529
|
+
- `collectInsertionOperations` (NEW): records original/new offsets for insertion-only mutations.
|
|
530
|
+
- Hyperlink round-trip test helpers and assertions (MODIFIED): cover low-level apply, Accept All, hyperlink relationship preservation, and the atomic strict-target document runner.
|
|
531
|
+
- Replacement-anchor matrix helpers `escapeXml`, `run`, `hyperlink`, `insertion`, `paragraph`, `parse`, `acceptedText`, and `hyperlinkIds` (NEW).
|
|
532
|
+
* **Additional Future-Regression Coverage**:
|
|
533
|
+
- 12 deterministic replacements at run starts, interiors, and ends; before and after hyperlinks; across an entire spacer run; beside bold/underlined runs, bookmarks, and comment markers; across multiple replacements; and beside/inside a foreign insertion carrier.
|
|
534
|
+
- Every case asserts exact current view, Accept All, Reject Reviewer, structural validation, unique revision IDs, and hyperlink relationship preservation.
|
|
535
|
+
- Four representative hyperlink and multi-replacement cases also execute through the atomic strict-target document runner used by the CLI.
|
|
536
|
+
* **Verification**:
|
|
537
|
+
- `node tests/cross_author_slicing_replacement_anchor_tests.mjs` — PASS, 12 scenarios.
|
|
538
|
+
- `node tests/cross_author_slicing_hyperlink_roundtrip_tests.mjs` — PASS.
|
|
539
|
+
- `node tests/cross_author_slicing_insertion_stress_tests.mjs` — PASS, 76 scenarios.
|
|
540
|
+
- `node tests/insertion_affinity_tests.mjs` — PASS.
|
|
541
|
+
- `npm test` — PASS, 95 test files passed and 0 failed.
|
|
542
|
+
- `npm run lint` — PASS.
|
|
543
|
+
- `npm run check:types` — PASS; all 123 runtime exports have declarations.
|
|
544
|
+
- `git diff --check` — PASS (line-ending conversion notices only; no whitespace errors).
|
|
545
|
+
|
|
546
|
+
### WP08a — Fail-Closed Gate for Foreign Paragraph-Mark Deletions [COMPLETED 2026-09-08]
|
|
547
|
+
|
|
548
|
+
WP08a is separable from, and a prerequisite of, WP08b. It ships on its own as a patch release: it adds no new capability, only refuses an operation that currently returns `status: 'ok'` while producing a lifecycle-unsafe document. Every prior follow-up in this document shipped the fail-closed guard before the feature; WP08 follows the same order.
|
|
549
|
+
|
|
550
|
+
#### Scope
|
|
551
|
+
|
|
552
|
+
1. At the mutation gate, refuse any operation that would add visible runs or `w:ins` content to a paragraph in the **resurrection state** defined in WP08b's trigger taxonomy (foreign paragraph-mark deletion + every pre-existing content node already deleted + new non-empty insertion). Return `FOREIGN_PARAGRAPH_MARK_DELETION` with the owning author, and the original document unchanged under atomic mode.
|
|
553
|
+
2. Add the same predicate to `core/redline-validation.js` as a **warning**, not an error. Validation runs over documents this engine did not author; see the Validation Predicate section below for why the broader rule is unsafe.
|
|
554
|
+
3. Regression fixture reproducing the reported restoration shape (foreign `w:pPr/w:rPr/w:del` plus an attempted same-paragraph `w:ins`), asserting the refusal, the error code, and byte-exact rollback.
|
|
555
|
+
4. Lifecycle assertions in the fixture proving *why* the shape is refused: Accept All loses Reviewer B's text, and Reject Reviewer A yields duplicate visible text.
|
|
556
|
+
|
|
557
|
+
Package validation alone is not a sufficient oracle for this case — the unsafe shape is structurally valid. Accept/Reject lifecycle checks are mandatory.
|
|
558
|
+
|
|
559
|
+
#### Implementation Record
|
|
560
|
+
|
|
561
|
+
* **Result**: Added a shared, narrowly scoped resurrection-state predicate. Both the low-level paragraph engine and the document mutation runner now refuse a non-empty edit when a different author owns the paragraph-mark deletion and every pre-existing content child is deleted. The refusal returns `FOREIGN_PARAGRAPH_MARK_DELETION`, includes `ownerAuthor`, and leaves the original input unchanged. Same-author deleted paragraphs, foreign deleted marks with surviving content, and content-only deletions without a paragraph-mark deletion remain on their existing paths.
|
|
562
|
+
* **Validation**: `validateRedlineOoxml` uses the same structural model to emit a warning for already-authored unsafe shapes. The warning does not make otherwise valid OOXML invalid.
|
|
563
|
+
* **Files Touched**:
|
|
564
|
+
- `core/paragraph-revision-safety.js` (NEW)
|
|
565
|
+
- `core/redline-validation.js`
|
|
566
|
+
- `engine/oxml-engine.js`
|
|
567
|
+
- `services/document-operation-mutations.js`
|
|
568
|
+
- `index.d.ts`
|
|
569
|
+
- `tests/foreign_paragraph_mark_deletion_gate_tests.mjs` (NEW)
|
|
570
|
+
- `CHANGELOG.md`
|
|
571
|
+
- `docs/plans/2026-09-08-cross-author-revision-slicing.md`
|
|
572
|
+
* **Functions and Types Touched**:
|
|
573
|
+
- `inspectForeignDeletedParagraphTarget` (NEW): identifies the pre-mutation WP08 resurrection state and excludes same-author ownership.
|
|
574
|
+
- `findForeignDeletedParagraphResurrections` (NEW): identifies already-authored unsafe foreign-insertion shapes for warning-only validation.
|
|
575
|
+
- `paragraphMarkDeletion`, `hasVisibleInsertionContent`, `isAnchorOnlyRun`, and DOM/name/author helpers (NEW): implement the shared structural inspection without mutating the source DOM, while excluding non-visible comment/bookmark anchors from the content-state decision.
|
|
576
|
+
- `applyRedlineToOxml` (MODIFIED): fails closed before existing-revision normalization or diff application for paragraph-level calls.
|
|
577
|
+
- `applyToParagraphByExactText` (MODIFIED): fails closed immediately after strict target resolution and before preprocessing/mutation for document-runner calls.
|
|
578
|
+
- `validateRedlineOoxml` (MODIFIED): reports `FOREIGN_PARAGRAPH_MARK_DELETION` as a warning for structurally valid but lifecycle-unsafe authored output.
|
|
579
|
+
- `RedlineError` (MODIFIED): documents the new error code and optional `ownerAuthor` metadata.
|
|
580
|
+
- WP08a fixture helpers and assertions (NEW): cover the low-level tracked and direct-edit paths, strict atomic runner rollback, validator severity, Accept/Reject lifecycle evidence, and every non-triggering taxonomy row.
|
|
581
|
+
* **Verification**:
|
|
582
|
+
- `node tests/foreign_paragraph_mark_deletion_gate_tests.mjs` — PASS.
|
|
583
|
+
- Focused validation, revision-policy, replacement-anchor, and paragraph-boundary suites — PASS.
|
|
584
|
+
- `npm test` — PASS, 96 test files passed and 0 failed.
|
|
585
|
+
- `npm run lint` — PASS.
|
|
586
|
+
- `npm run check:types` — PASS; all 123 runtime exports have declarations.
|
|
587
|
+
- `npm run build` — PASS.
|
|
588
|
+
- `git diff --check` — PASS (line-ending conversion notices only; no whitespace errors).
|
|
589
|
+
|
|
590
|
+
---
|
|
591
|
+
|
|
592
|
+
### WP08b — Paragraph-Level Cross-Author Slicing for Deleted Paragraph Restoration [COMPLETED 2026-09-08]
|
|
593
|
+
|
|
594
|
+
#### Motivation
|
|
595
|
+
|
|
596
|
+
Restoring text from another reviewer's pending whole-paragraph deletion is the paragraph-level counterpart of run-level cross-author slicing. The accepted/current view of such a paragraph is empty, while its text exists only in the rejected view. Writing replacement text into that same paragraph can look correct before revisions are resolved, but it is lifecycle-unsafe because the foreign paragraph-mark deletion still owns the paragraph.
|
|
597
|
+
|
|
598
|
+
The observed unsafe shape is conceptually:
|
|
599
|
+
|
|
600
|
+
```xml
|
|
601
|
+
<w:p>
|
|
602
|
+
<w:pPr><w:rPr><w:del w:author="Reviewer A"/></w:rPr></w:pPr>
|
|
603
|
+
<w:del w:author="Reviewer A">...</w:del>
|
|
604
|
+
<w:ins w:author="Reviewer B">restored text</w:ins>
|
|
605
|
+
</w:p>
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
This passes structural package validation and looks correct in the current view, but Accept All removes the entire paragraph because Reviewer A's paragraph-mark deletion remains active. Rejecting Reviewer A can also expose both the original deleted text and Reviewer B's inserted copy.
|
|
609
|
+
|
|
610
|
+
Nesting Reviewer B's `<w:ins>` inside Reviewer A's `<w:del>` is not a solution: `w:del/w:ins` nesting is invalid for this use, and accepting the outer deletion would remove the nested text.
|
|
611
|
+
|
|
612
|
+
#### Required Paragraph-Level Slicing Model
|
|
613
|
+
|
|
614
|
+
Preserve the foreign deleted paragraph and materialize the restoring reviewer's counterproposal as a new adjacent tracked paragraph:
|
|
615
|
+
|
|
616
|
+
```text
|
|
617
|
+
[restored/adjusted paragraph inserted by Reviewer B]
|
|
618
|
+
[paragraph deleted by Reviewer A]
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
#### Paragraph-Mark Semantics (Normative)
|
|
622
|
+
|
|
623
|
+
This is the part the run-level slicing model has no analogue for, and it governs every lifecycle row below.
|
|
624
|
+
|
|
625
|
+
**Accepting a paragraph-mark deletion does not remove the paragraph — it merges the paragraph into the next paragraph.** `mergeParagraphIntoNextAndRemove` in `services/revision-comment-management.js` moves the deleted paragraph's surviving children into the following `w:p` and removes the emptied paragraph; the **following** paragraph's `pPr` is the one that survives. Any design statement phrased as "Reviewer A's paragraph is removed" is imprecise and must be read as "merged forward".
|
|
626
|
+
|
|
627
|
+
Three consequences are binding on the implementation:
|
|
628
|
+
|
|
629
|
+
1. **Sibling order is a design decision, not cosmetic.** Placing Reviewer B's paragraph *after* Reviewer A's makes B the merge target when Reviewer A is accepted: A's surviving children land inside B. This is harmless only while A's content is 100% deleted, and stops being harmless the moment A retains content (a partially resolved deletion, or a third author's `w:ins` still pending inside A). Placing B *before* A leaves A's merge target exactly as it was before the restoration existed, so accepting A behaves identically with or without B.
|
|
630
|
+
* **Decision: place Reviewer B's paragraph immediately BEFORE Reviewer A's**, for merge-target neutrality. The current view is unaffected (A is invisible), and All-Markup view order is a rendering preference, not a correctness property. Fixtures must assert the merge target explicitly rather than inferring it from the resulting text.
|
|
631
|
+
2. **Reviewer B's paragraph MUST carry its own inserted paragraph mark** (`w:pPr/w:rPr/w:ins` attributed to Reviewer B, with an allocator-issued ID). Adding a paragraph adds a paragraph mark. Without it, Reject Reviewer B removes B's content but leaves an empty stub paragraph permanently, silently violating the Reject-B lifecycle row and drifting the document's paragraph count.
|
|
632
|
+
3. **The existing inserted-paragraph builders do not do this today.** `wrapParagraphContentInInsertion` and `buildFallbackInsertedPlainParagraph` in `services/document-operation-mutations.js` emit no paragraph-mark revision and clone `pPr` verbatim. Cloning `pPr` verbatim from the deleted source paragraph would copy Reviewer A's `w:rPr/w:del` onto Reviewer B's paragraph, reproducing the exact unsafe shape WP08 exists to prevent. Emitting the inserted mark and sanitizing `pPr` is new work in those builders, not reuse of them.
|
|
633
|
+
|
|
634
|
+
#### Paragraph Property Sanitization (Normative Allowlist)
|
|
635
|
+
|
|
636
|
+
When deriving Reviewer B's paragraph from the deleted source, copy only:
|
|
637
|
+
|
|
638
|
+
* `w:pStyle`, `w:numPr`, `w:ind`, `w:jc`, `w:spacing`, `w:tabs`, `w:keepNext`/`w:keepLines`, `w:outlineLvl`, `w:contextualSpacing`.
|
|
639
|
+
|
|
640
|
+
Strip unconditionally:
|
|
641
|
+
|
|
642
|
+
* `w:rPr/w:del` and `w:rPr/w:ins` (foreign mark revisions — replaced by Reviewer B's own inserted mark),
|
|
643
|
+
* `w:sectPr` (section identity must never be duplicated; see refusals),
|
|
644
|
+
* `w:pPrChange`, `w:rPrChange`, and every other `*Change` element (they describe a revision of the *source* paragraph and are meaningless on the clone).
|
|
645
|
+
|
|
646
|
+
#### Paragraph Identity (Normative)
|
|
647
|
+
|
|
648
|
+
Reviewer B's paragraph MUST receive a **fresh `w14:paraId`**, and MUST drop `w14:textId` and all `w:rsid*` attributes. This is unconditional, not best-effort: `extractParagraphIdFromOoxml` in `core/ooxml-identifiers.js` resolves strict targets by `w14:paraId`, so a duplicated paraId makes Reviewer A's and Reviewer B's paragraphs indistinguishable to paragraph-ID targeting — including to this work package's own strict-targeting test case.
|
|
649
|
+
|
|
650
|
+
#### Trigger Taxonomy (Normative)
|
|
651
|
+
|
|
652
|
+
"Whole-paragraph deletion" is ambiguous across the four combinations of paragraph-mark state and content state. Only one routes to WP08b:
|
|
653
|
+
|
|
654
|
+
| Paragraph mark | Pre-existing content | Route |
|
|
655
|
+
|:--|:--|:--|
|
|
656
|
+
| Deleted by foreign author | All deleted | **WP08b sibling restoration** (the resurrection state) |
|
|
657
|
+
| Deleted by foreign author | Intact or partially deleted | Ordinary run-level cross-author slicing — the accepted view is non-empty; a pending forward merge is legal and Word-native |
|
|
658
|
+
| Not deleted | All deleted | Ordinary cross-author insertion — no foreign mark owns the paragraph; MUST NOT route to WP08b |
|
|
659
|
+
| Deleted by current author | All deleted | `merge-same-author`; MUST NOT route to WP08b |
|
|
660
|
+
|
|
661
|
+
#### Validation Predicate (Normative)
|
|
662
|
+
|
|
663
|
+
The reconciliation rule must match the resurrection state exactly. A broader rule of the form "foreign paragraph-mark deletion plus visible insertion in the same paragraph" is **wrong** — inserting text into a paragraph whose mark is deleted by another author is legal, Word-native, and common (it is an ordinary pending merge). Flagging it would reject valid third-party documents.
|
|
664
|
+
|
|
665
|
+
The predicate is: foreign `w:pPr/w:rPr/w:del` **AND** every pre-existing content node deleted **AND** a new non-empty foreign `w:ins`. It is a **warning** in `core/redline-validation.js` and an **error** only at the mutation gate (WP08a).
|
|
666
|
+
|
|
667
|
+
#### Required Behavior
|
|
668
|
+
|
|
669
|
+
1. Detect the resurrection state per the trigger taxonomy when an operation attempts to restore non-empty text into the paragraph's empty accepted view.
|
|
670
|
+
2. Never append visible runs or `w:ins` content to the paragraph still owned by the foreign paragraph deletion.
|
|
671
|
+
3. Derive Reviewer B's paragraph as a sanitized sibling of the source paragraph per the allowlist and identity rules above, without mutating Reviewer A's original deleted paragraph.
|
|
672
|
+
4. Track the new paragraph's content and its paragraph mark as Reviewer B insertions with document-scoped revision IDs.
|
|
673
|
+
5. Preserve document order and ensure `w:sectPr`, tables, list boundaries, comments, bookmarks, and other structural anchors are neither displaced nor duplicated.
|
|
674
|
+
6. **Restoring a multi-paragraph range emits one inserted sibling paragraph per restored source paragraph**, as a contiguous block preserving source order, with N paragraph-mark insertions — never one merged paragraph.
|
|
675
|
+
7. **Idempotency**: re-running the same restoration must not emit a second Reviewer B paragraph. If an adjacent same-author inserted paragraph already carries the restoration, route the edit through ordinary run-level slicing of that paragraph.
|
|
676
|
+
8. Rejected-view descriptors remain read-only targeting aids until rejected-view mutation is deliberately supported. Do not silently treat `revisionView: 'rejected'` as accepted-view mutation.
|
|
677
|
+
9. **Contract decision (resolved, not deferred):** restoration requires **explicit caller intent** — a dedicated restore operation or an explicit restoration option. A `redline` operation with an empty accepted-view target and non-empty modified text remains fail-closed under WP08a. Automatic conversion is rejected because the same request shape is indistinguishable from an ordinary "insert text into an empty paragraph", and silently choosing restoration would move the caller's content into a different paragraph than the one they targeted.
|
|
678
|
+
|
|
679
|
+
#### Structural Anchors (Normative)
|
|
680
|
+
|
|
681
|
+
Reviewer B's paragraph MUST NOT clone `w:bookmarkStart`/`w:bookmarkEnd` or `w:commentRangeStart`/`w:commentRangeEnd`/`w:commentReference`. Bookmark names are document-unique, and duplicating a comment range attaches one comment to two disjoint locations. Anchors stay on Reviewer A's paragraph, where they remain valid until that deletion is resolved. Every anchor not carried over is reported in the receipt as a structured warning naming the bookmark or comment ID, so the caller can re-anchor deliberately.
|
|
682
|
+
|
|
683
|
+
#### Fail-Closed Refusals (Distinct Codes)
|
|
684
|
+
|
|
685
|
+
A single `UNSAFE_PARAGRAPH_BOUNDARY` code conflates unrelated conditions and reads as a near-collision with the existing `PAIRING_SKIPPED_STRUCTURAL_BOUNDARY`. Enumerate:
|
|
686
|
+
|
|
687
|
+
| Condition | Code |
|
|
688
|
+
|:--|:--|
|
|
689
|
+
| Resurrection attempted without explicit restore intent (WP08a gate) | `FOREIGN_PARAGRAPH_MARK_DELETION` |
|
|
690
|
+
| Source paragraph inside a row deleted via `w:trPr/w:del` — a sibling paragraph cannot survive the row | `UNSAFE_DELETED_TABLE_ROW` |
|
|
691
|
+
| Source paragraph is part of a move (`w:moveFrom` / `w:moveFromRangeStart`) | `UNSUPPORTED_MOVE_REVISION` |
|
|
692
|
+
| Source paragraph's `pPr` carries `w:sectPr` — mirrors the existing deletion refusal in `core/paragraph-targeting.js` | `SECTION_BREAK_PARAGRAPH` |
|
|
693
|
+
| Source paragraph is the final paragraph of the body, so no safe sibling placement exists | `UNSAFE_PARAGRAPH_PLACEMENT` |
|
|
694
|
+
|
|
695
|
+
All refusals return the original document unchanged under atomic mode.
|
|
696
|
+
|
|
697
|
+
#### Lifecycle Invariants
|
|
698
|
+
|
|
699
|
+
For the paragraph pair `[ins(B), del(A)]` in document order:
|
|
700
|
+
|
|
701
|
+
| Resolution | Expected Result |
|
|
702
|
+
|:--|:--|
|
|
703
|
+
| Current view | Reviewer B's restored/adjusted paragraph appears exactly once; Reviewer A's paragraph is invisible. |
|
|
704
|
+
| Accept All | Reviewer B's paragraph and mark become baseline; Reviewer A's content deletion resolves and A's mark merges A forward into its **original** successor (not into B). Net: Reviewer B's paragraph remains exactly once. |
|
|
705
|
+
| Reject Reviewer B | Reviewer B's content is removed and B's inserted mark is rejected, merging the now-empty B forward into A. Net: the document returns to its pre-restoration text with Reviewer A's deletion still pending. |
|
|
706
|
+
| Reject Reviewer A | Reviewer A's paragraph and its text return; Reviewer B's insertion remains independently pending. Both marks remain attributable and structurally valid. |
|
|
707
|
+
| Accept Reviewer A only | Reviewer A's content deletion resolves and A merges forward into its original successor; Reviewer B's inserted paragraph remains pending and visible. |
|
|
708
|
+
| Accept Reviewer B only | Reviewer B's paragraph and mark become baseline; Reviewer A's deleted paragraph remains pending and invisible in the current view. |
|
|
709
|
+
|
|
710
|
+
No lifecycle path may silently discard Reviewer B's restoration, produce invalid nested revisions, orphan comments/bookmarks, or leave duplicate visible text after all revisions are resolved.
|
|
711
|
+
|
|
712
|
+
#### Verification Oracle (Normative)
|
|
713
|
+
|
|
714
|
+
Package validation is not an oracle for this feature; neither is a paragraph-local text comparison. Accepting a paragraph-mark deletion **crosses the paragraph boundary**, so a paragraph-scoped round-trip check cannot observe the merge.
|
|
715
|
+
|
|
716
|
+
Every WP08b fixture must therefore:
|
|
717
|
+
|
|
718
|
+
1. Reconstruct **body-scoped** (or at minimum a window of source paragraph ± 2) canonical text for **both the accepted view and the rejected view**, and compare each exactly against expectation. The rejected view is where duplicate-text regressions surface; the accepted view alone would pass the reported bug.
|
|
719
|
+
2. Assert the merge target of each paragraph-mark resolution explicitly, not inferred from resulting text.
|
|
720
|
+
3. Fail closed with a structured mismatch code and byte-exact rollback on divergence, matching the established `PATCH_ROUNDTRIP_MISMATCH` pattern.
|
|
721
|
+
|
|
722
|
+
#### Planned Implementation Areas
|
|
723
|
+
|
|
724
|
+
- `services/document-operation-applier.js`: route explicit restoration intent; retain the rejected-view mutation guard for unsupported generic mutations.
|
|
725
|
+
- `services/document-operation-mutations.js`: add the paragraph-level restoration mutation and sibling placement; extend the inserted-paragraph builders to emit inserted paragraph marks and sanitized `pPr` (see Paragraph-Mark Semantics item 3).
|
|
726
|
+
- `core/paragraph-targeting.js`: resolve the deleted paragraph identity consistently across accepted and rejected metadata without allowing stale descriptors; reuse the existing `w:sectPr` refusal.
|
|
727
|
+
- Revision allocator and receipt collector: report every paragraph-mark and content revision ID allocated by the restoration, plus dropped-anchor warnings.
|
|
728
|
+
- `core/redline-validation.js`: add the narrowed resurrection-state warning (see Validation Predicate).
|
|
729
|
+
|
|
730
|
+
#### Required Test Matrix
|
|
731
|
+
|
|
732
|
+
1. Plain whole-paragraph deletion restored verbatim.
|
|
733
|
+
2. Restored paragraph adjusted while being restored.
|
|
734
|
+
3. Bold, italic, underline, and mixed-run formatting preservation.
|
|
735
|
+
4. Numbered and bulleted paragraph restoration without list-label drift.
|
|
736
|
+
5. Paragraph immediately before `w:sectPr`, **and** a paragraph whose own `pPr` carries `w:sectPr` (refusal).
|
|
737
|
+
6. Paragraph inside a table cell; paragraph inside a row deleted via `w:trPr/w:del` (refusal).
|
|
738
|
+
7. Deleted paragraph containing bookmarks or comment anchors: assert anchor counts are **unchanged**, that no name or comment ID appears twice, and that each dropped anchor is reported in the receipt.
|
|
739
|
+
8. Multiple adjacent deleted paragraphs restored independently and as a range, asserting one inserted sibling per source paragraph and preserved order.
|
|
740
|
+
9. Same-author deletion behavior remains governed by `merge-same-author` and is not routed through cross-author restoration.
|
|
741
|
+
10. Each non-triggering row of the trigger taxonomy routes to its stated path and not to WP08b.
|
|
742
|
+
11. Third-author follow-up edits to Reviewer B's restored paragraph continue to use ordinary cross-author slicing.
|
|
743
|
+
12. Repeat application of the same restoration is idempotent — no duplicate Reviewer B paragraph.
|
|
744
|
+
13. Source paragraph retains unresolved content (partial deletion, or a third author's pending `w:ins`) — proves the merge target is A's original successor and that surviving content does not land inside Reviewer B's paragraph.
|
|
745
|
+
14. `w:moveFrom` source paragraph (refusal); final-paragraph-of-body source (refusal).
|
|
746
|
+
15. Atomic runner rollback and progressive batch receipts.
|
|
747
|
+
16. Strict targeting by paragraph ID, index, fingerprint, and `revisionView: 'rejected'` diagnostics — including an assertion that Reviewer A's and Reviewer B's paragraphs carry distinct `w14:paraId` values.
|
|
748
|
+
|
|
749
|
+
Every successful fixture must assert exact current text, both-view body-scoped round-trip equality, structural validation, unique revision IDs, receipt reconciliation, paragraph ordering, and the six lifecycle outcomes above.
|
|
750
|
+
|
|
751
|
+
#### Implementation Record
|
|
752
|
+
|
|
753
|
+
* **Public Contract**: Added a dedicated `restore` document operation. A single restoration accepts a non-empty `modified` string; a contiguous range accepts one string per source paragraph. `generateRedlines: false` is rejected because restoration necessarily creates both a tracked content insertion and an inserted paragraph mark. Generic `redline` operations remain protected by WP08a.
|
|
754
|
+
* **Paragraph Model**: Each counterproposal is inserted immediately before the foreign-deleted source paragraph (or, for a range, as one contiguous inserted block before the source block). The source paragraph is not modified. The new paragraph receives two allocator-issued revisions, a fresh `w14:paraId`, no copied `w14:textId`/`w:rsid*`, and only allowlisted paragraph properties.
|
|
755
|
+
* **Lifecycle Oracle**: Before commit, restoration validates the authored OOXML and compares body-scoped paragraph text vectors for current view, Accept All, and Reject All against independently constructed expected documents. Any mismatch returns `PATCH_ROUNDTRIP_MISMATCH`; the operation savepoint supplies byte-exact rollback.
|
|
756
|
+
* **Idempotency**: An identical adjacent same-author restoration is a no-op. A changed same-author reapplication replaces the prior counterproposal rather than adding a duplicate paragraph.
|
|
757
|
+
* **Files Touched**:
|
|
758
|
+
- `core/paragraph-revision-safety.js`
|
|
759
|
+
- `core/paragraph-targeting.js`
|
|
760
|
+
- `services/document-operation-contract.js`
|
|
761
|
+
- `services/document-operation-applier.js`
|
|
762
|
+
- `services/document-operation-mutations.js`
|
|
763
|
+
- `services/operation-preflight.js`
|
|
764
|
+
- `services/standalone-operation-runner.d.ts`
|
|
765
|
+
- `docs/schemas/document-operations.schema.json`
|
|
766
|
+
- `index.d.ts`
|
|
767
|
+
- `tests/paragraph_level_cross_author_restoration_tests.mjs` (NEW)
|
|
768
|
+
- `tests/types/usage.ts`
|
|
769
|
+
- `README.md`
|
|
770
|
+
- `AGENTS.md`
|
|
771
|
+
- `CHANGELOG.md`
|
|
772
|
+
- `docs/plans/2026-09-08-cross-author-revision-slicing.md`
|
|
773
|
+
* **Functions and Types Touched**:
|
|
774
|
+
- `getParagraphRestorationRefusal` and move-range/content-state helpers (NEW): distinguish deleted-row, move-from, section-break, and unsafe-placement refusals while preserving the narrow trigger taxonomy.
|
|
775
|
+
- `resolveTargetParagraph` (MODIFIED): supports strict fingerprint-only descriptors, bringing runtime targeting into alignment with the published schema.
|
|
776
|
+
- `getCanonicalOperationType`, `normalizeDocumentOperation`, and `validateDocumentOperation` (MODIFIED): normalize and validate explicit single/range `restore` operations and retain full `targetEnd` descriptors.
|
|
777
|
+
- `applyOperationToDocumentXml` (MODIFIED): routes restoration separately and keeps rejected-view mutation read-only for both range endpoints.
|
|
778
|
+
- `restoreDeletedParagraphByExactText` (NEW): resolves the source block, enforces trigger/safety rules, reconstructs rejected-view content, inserts tracked siblings, handles idempotency, and runs the lifecycle oracle.
|
|
779
|
+
- `createSanitizedRestorationPPr`, `buildRejectedRestorationTemplate`, `editRestorationTemplate`, `allocateFreshParagraphId`, `trackRestoredParagraph`, and lifecycle/anchor helpers (NEW): implement property sanitization, formatting preservation, fresh identity, dropped-anchor diagnostics, and exact round-trip checks.
|
|
780
|
+
- `wrapParagraphContentInInsertion`, `buildFallbackInsertedPlainParagraph`, and `buildInsertedPlainParagraph` (MODIFIED): optionally emit inserted paragraph marks, use typed insertion receipt metadata, sanitize restoration properties, and accept fresh paragraph identity.
|
|
781
|
+
- `preflightOperations` (MODIFIED): recognizes restoration state, range cardinality, and structural refusals without mutating the document.
|
|
782
|
+
- `RestoreDocumentOperation` and `RedlineError` (MODIFIED/NEW): publish the operation shape and structured refusal/oracle metadata.
|
|
783
|
+
- WP08b fixture helpers and assertions (NEW): exercise six lifecycle outcomes, strict descriptors, independent and range restoration, formatting/list preservation, anchor warnings, table cells/deleted rows, section/move/placement refusals, taxonomy exclusions, progressive and atomic batches, idempotency, and third-author follow-up slicing.
|
|
784
|
+
* **Verification**:
|
|
785
|
+
- `node tests/paragraph_level_cross_author_restoration_tests.mjs` — PASS.
|
|
786
|
+
- Focused list and insertion-affinity regressions — PASS.
|
|
787
|
+
- `$env:DOCX_TEST_CONCURRENCY='1'; npm test` — PASS, 97 test files passed and 0 failed. The serial final run was used after concurrent attempts hit unrelated per-file timeouts under host contention; each timed-out suite also passed directly.
|
|
788
|
+
- `npm run lint` — PASS.
|
|
789
|
+
- `npm run check:types` — PASS; all 123 runtime exports have declarations.
|
|
790
|
+
- `npm run build` — PASS.
|
|
791
|
+
- `git diff --check` — PASS (line-ending conversion notices only; no whitespace errors).
|
|
792
|
+
|
|
512
793
|
---
|
|
513
794
|
|
|
514
795
|
## 6. Comprehensive Verification Plan (Synthetic & Real Test Series)
|
|
@@ -88,6 +88,9 @@
|
|
|
88
88
|
{
|
|
89
89
|
"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"] } } } ]
|
|
90
90
|
},
|
|
91
|
+
{
|
|
92
|
+
"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"] } } } ]
|
|
93
|
+
},
|
|
91
94
|
{
|
|
92
95
|
"allOf": [ { "$ref": "#/$defs/base" }, { "type": "object", "properties": { "type": { "const": "delete" }, "modified": { "const": "" } } } ]
|
|
93
96
|
},
|
package/engine/oxml-engine.js
CHANGED
|
@@ -40,6 +40,7 @@ import { getDocumentParagraphs } from './format-extraction.js';
|
|
|
40
40
|
import { isDiffTokenLimitError } from '../pipeline/diff-engine.js';
|
|
41
41
|
import { NumberingService } from '../services/numbering-service.js';
|
|
42
42
|
import { recordRouteSelection } from './route-selection.js';
|
|
43
|
+
import { inspectForeignDeletedParagraphTarget } from '../core/paragraph-revision-safety.js';
|
|
43
44
|
|
|
44
45
|
function getCommentIdsInOoxml(node) {
|
|
45
46
|
const ids = new Set();
|
|
@@ -138,9 +139,29 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
138
139
|
const revisionIdAllocator = options?._revisionIdAllocator instanceof RevisionIdAllocator
|
|
139
140
|
? options._revisionIdAllocator
|
|
140
141
|
: new RevisionIdAllocator();
|
|
141
|
-
seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
|
|
142
|
-
|
|
143
|
-
|
|
142
|
+
seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
|
|
143
|
+
|
|
144
|
+
const inputParagraphs = xmlDoc.documentElement && String(xmlDoc.documentElement.localName || '').toLowerCase() === 'p'
|
|
145
|
+
? [xmlDoc.documentElement]
|
|
146
|
+
: getDocumentParagraphs(xmlDoc);
|
|
147
|
+
if (inputParagraphs.length === 1 && modifiedText.length > 0) {
|
|
148
|
+
const resurrectionTarget = inspectForeignDeletedParagraphTarget(inputParagraphs[0], author);
|
|
149
|
+
if (resurrectionTarget.matches) {
|
|
150
|
+
const ownerAuthor = resurrectionTarget.ownerAuthor || 'unattributed';
|
|
151
|
+
return finalize({
|
|
152
|
+
oxml: inputOoxml,
|
|
153
|
+
hasChanges: false,
|
|
154
|
+
status: 'error',
|
|
155
|
+
error: {
|
|
156
|
+
code: 'FOREIGN_PARAGRAPH_MARK_DELETION',
|
|
157
|
+
message: `Refusing to add visible text to a paragraph whose paragraph mark is deleted by another author (${ownerAuthor}). Use explicit paragraph restoration when supported.`,
|
|
158
|
+
ownerAuthor
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (containsTrackedChanges(xmlDoc)) {
|
|
144
165
|
if (existingRevisionsPolicy === 'merge-same-author' || existingRevisionsPolicy === 'slice-cross-author') {
|
|
145
166
|
const authors = getTrackedChangeAuthors(xmlDoc);
|
|
146
167
|
const currentAuthor = String(author || '').trim().toLowerCase();
|
|
@@ -161,7 +161,18 @@ export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, gener
|
|
|
161
161
|
if (delWrapper && record.deletedPieces.length > 0) {
|
|
162
162
|
delWrapper.appendChild(createRunFromPieces(xmlDoc, record.deletedPieces, record.rPr));
|
|
163
163
|
}
|
|
164
|
-
insertRunPiecesBefore(xmlDoc, parent, runElement, record.afterPieces, record.rPr);
|
|
164
|
+
const afterRun = insertRunPiecesBefore(xmlDoc, parent, runElement, record.afterPieces, record.rPr);
|
|
165
|
+
if (
|
|
166
|
+
record.globalEnd === endPos
|
|
167
|
+
&& !isWordElement(parent, 'ins')
|
|
168
|
+
) {
|
|
169
|
+
if (!spanIndex.replacementInsertionAnchors) spanIndex.replacementInsertionAnchors = new Map();
|
|
170
|
+
spanIndex.replacementInsertionAnchors.set(endPos, {
|
|
171
|
+
parent,
|
|
172
|
+
referenceNode: afterRun || runElement.nextSibling,
|
|
173
|
+
rPr: record.rPr
|
|
174
|
+
});
|
|
175
|
+
}
|
|
165
176
|
parent.removeChild(runElement);
|
|
166
177
|
changed = true;
|
|
167
178
|
}
|
|
@@ -209,6 +220,29 @@ export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints
|
|
|
209
220
|
);
|
|
210
221
|
}
|
|
211
222
|
|
|
223
|
+
const replacementAnchor = spanIndex.replacementInsertionAnchors?.get(pos) || null;
|
|
224
|
+
if (
|
|
225
|
+
replacementAnchor
|
|
226
|
+
&& !affinity
|
|
227
|
+
&& isConnected(replacementAnchor.parent)
|
|
228
|
+
&& (!replacementAnchor.referenceNode || replacementAnchor.referenceNode.parentNode === replacementAnchor.parent)
|
|
229
|
+
) {
|
|
230
|
+
spanIndex.replacementInsertionAnchors.delete(pos);
|
|
231
|
+
insertTextRuns(
|
|
232
|
+
xmlDoc,
|
|
233
|
+
replacementAnchor.parent,
|
|
234
|
+
replacementAnchor.referenceNode,
|
|
235
|
+
text,
|
|
236
|
+
replacementAnchor.rPr,
|
|
237
|
+
author,
|
|
238
|
+
formatHints,
|
|
239
|
+
insertOffset,
|
|
240
|
+
generateRedlines,
|
|
241
|
+
revisionMetadata
|
|
242
|
+
);
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
|
|
212
246
|
if (!affinity) {
|
|
213
247
|
let targetSpan = findContainingSpan(spanIndex, pos);
|
|
214
248
|
|
package/engine/surgical-mode.js
CHANGED
|
@@ -124,7 +124,42 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
124
124
|
let newPos = 0;
|
|
125
125
|
let hasChanges = false;
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
const insertionOperations = insertionOnlyDiffs
|
|
128
|
+
? collectInsertionOperations(insertionOnlyDiffs)
|
|
129
|
+
: [];
|
|
130
|
+
if (insertionOperations.length > 1 && formatHints.length === 0) {
|
|
131
|
+
for (const operation of insertionOperations.slice().reverse()) {
|
|
132
|
+
const liveSpans = buildSurgicalTextSpans(allParagraphs).textSpans;
|
|
133
|
+
const liveSpanIndex = buildSpanIndex(liveSpans);
|
|
134
|
+
const textWithoutNewlines = operation.text.replace(/\n/g, ' ');
|
|
135
|
+
if (textWithoutNewlines.length === 0) continue;
|
|
136
|
+
const insertResult = processInsert(
|
|
137
|
+
xmlDoc,
|
|
138
|
+
liveSpanIndex,
|
|
139
|
+
operation.originalPos,
|
|
140
|
+
textWithoutNewlines,
|
|
141
|
+
author,
|
|
142
|
+
formatHints,
|
|
143
|
+
operation.newPos,
|
|
144
|
+
generateRedlines,
|
|
145
|
+
allParagraphs[0] || null,
|
|
146
|
+
null,
|
|
147
|
+
options?.insertionAffinity || null,
|
|
148
|
+
options?.existingRevisions || 'merge-same-author'
|
|
149
|
+
);
|
|
150
|
+
if (insertResult && typeof insertResult === 'object' && insertResult.error) {
|
|
151
|
+
return withOoxmlSourceType({
|
|
152
|
+
oxml: serializer.serializeToString(xmlDoc),
|
|
153
|
+
hasChanges: false,
|
|
154
|
+
status: 'error',
|
|
155
|
+
error: insertResult.error
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
if (insertResult === true) hasChanges = true;
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
|
|
162
|
+
for (let i = 0; i < diffs.length; i++) {
|
|
128
163
|
const [op, text] = diffs[i];
|
|
129
164
|
if (op === 0) {
|
|
130
165
|
const len = text.length;
|
|
@@ -216,6 +251,7 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
216
251
|
}
|
|
217
252
|
newPos += text.length;
|
|
218
253
|
}
|
|
254
|
+
}
|
|
219
255
|
}
|
|
220
256
|
|
|
221
257
|
const actualText = allParagraphs.map(paragraph => extractCanonicalParagraphText(paragraph)).join('\n');
|
|
@@ -257,3 +293,21 @@ function excerptAt(text, offset, radius = 40) {
|
|
|
257
293
|
const end = Math.min(text.length, offset + radius);
|
|
258
294
|
return text.slice(start, end);
|
|
259
295
|
}
|
|
296
|
+
|
|
297
|
+
function collectInsertionOperations(diffs) {
|
|
298
|
+
const operations = [];
|
|
299
|
+
let originalPos = 0;
|
|
300
|
+
let newPos = 0;
|
|
301
|
+
for (const [op, text] of diffs) {
|
|
302
|
+
if (op === 0) {
|
|
303
|
+
originalPos += text.length;
|
|
304
|
+
newPos += text.length;
|
|
305
|
+
} else if (op === -1) {
|
|
306
|
+
originalPos += text.length;
|
|
307
|
+
} else if (op === 1) {
|
|
308
|
+
operations.push({ originalPos, newPos, text });
|
|
309
|
+
newPos += text.length;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return operations;
|
|
313
|
+
}
|
package/index.d.ts
CHANGED
|
@@ -29,6 +29,7 @@ export type {
|
|
|
29
29
|
OperationPreflightResult,
|
|
30
30
|
ParagraphTargetDescriptor,
|
|
31
31
|
RedlineDocumentOperation,
|
|
32
|
+
RestoreDocumentOperation,
|
|
32
33
|
ResolvedCommentAnchor,
|
|
33
34
|
ResolvedDocumentTarget,
|
|
34
35
|
StandaloneRunnerOptions
|
|
@@ -43,6 +44,13 @@ export interface RedlineError {
|
|
|
43
44
|
| 'UNSUPPORTED_REVISION_VIEW_MUTATION'
|
|
44
45
|
| 'UNSUPPORTED_INSERTION_AFFINITY'
|
|
45
46
|
| 'UNSAFE_PARAGRAPH_BOUNDARY'
|
|
47
|
+
| 'FOREIGN_PARAGRAPH_MARK_DELETION'
|
|
48
|
+
| 'RESTORATION_STATE_REQUIRED'
|
|
49
|
+
| 'RESTORATION_COUNT_MISMATCH'
|
|
50
|
+
| 'UNSAFE_DELETED_TABLE_ROW'
|
|
51
|
+
| 'UNSUPPORTED_MOVE_REVISION'
|
|
52
|
+
| 'SECTION_BREAK_PARAGRAPH'
|
|
53
|
+
| 'UNSAFE_PARAGRAPH_PLACEMENT'
|
|
46
54
|
| 'TARGET_INDEX_MISMATCH'
|
|
47
55
|
| 'REVISION_MISMATCH'
|
|
48
56
|
| 'REVISION_TOKEN_SCOPE_MISMATCH'
|
|
@@ -54,6 +62,10 @@ export interface RedlineError {
|
|
|
54
62
|
| 'CAPTURE_STALE'
|
|
55
63
|
| string;
|
|
56
64
|
message: string;
|
|
65
|
+
ownerAuthor?: string;
|
|
66
|
+
stage?: string;
|
|
67
|
+
expected?: unknown;
|
|
68
|
+
actual?: unknown;
|
|
57
69
|
commentIds?: string[];
|
|
58
70
|
comments?: Array<{ id: string; author: string; text: string }>;
|
|
59
71
|
}
|
package/package.json
CHANGED
|
@@ -14,8 +14,9 @@ import {
|
|
|
14
14
|
applyCommentToParagraphByExactText,
|
|
15
15
|
applyFormattingToParagraphByExactText,
|
|
16
16
|
applyHighlightToParagraphByExactText,
|
|
17
|
-
applyParagraphFormatToParagraphByExactText,
|
|
18
|
-
|
|
17
|
+
applyParagraphFormatToParagraphByExactText,
|
|
18
|
+
restoreDeletedParagraphByExactText,
|
|
19
|
+
applyToParagraphByExactText
|
|
19
20
|
} from './document-operation-mutations.js';
|
|
20
21
|
import { applyCommentReplyToParts } from './comment-replies.js';
|
|
21
22
|
import {
|
|
@@ -58,7 +59,13 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
|
|
|
58
59
|
const operation = validation.operation || normalizeDocumentOperation(op);
|
|
59
60
|
const authorUsed = resolveDocumentOperationAuthor(operation, author, getDefaultAuthor());
|
|
60
61
|
|
|
61
|
-
if (
|
|
62
|
+
if (
|
|
63
|
+
operation.operationKind !== 'comment_reply'
|
|
64
|
+
&& (
|
|
65
|
+
operation.targetDescriptor?.revisionView === 'rejected'
|
|
66
|
+
|| operation.targetEndDescriptor?.revisionView === 'rejected'
|
|
67
|
+
)
|
|
68
|
+
) {
|
|
62
69
|
return {
|
|
63
70
|
documentXml,
|
|
64
71
|
hasChanges: false,
|
|
@@ -159,7 +166,8 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
|
|
|
159
166
|
pairReplacements: typeof operation.pairReplacements === 'boolean' ? operation.pairReplacements : (options.pairReplacements !== false),
|
|
160
167
|
...(operation.insertionAffinity ? { insertionAffinity: operation.insertionAffinity } : {}),
|
|
161
168
|
...(operation.formattingRevisionPolicy ? { formattingRevisionPolicy: operation.formattingRevisionPolicy } : {}),
|
|
162
|
-
targetDescriptor: operation.targetDescriptor,
|
|
169
|
+
targetDescriptor: operation.targetDescriptor,
|
|
170
|
+
targetEndDescriptor: operation.targetEndDescriptor,
|
|
163
171
|
_resolutionCapture: resolutionCapture,
|
|
164
172
|
_revisionIdAllocator: session.revisionIdAllocator,
|
|
165
173
|
_documentOperationSession: session,
|
|
@@ -242,7 +250,18 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
|
|
|
242
250
|
runtimeContext,
|
|
243
251
|
operationOptions
|
|
244
252
|
);
|
|
245
|
-
} else {
|
|
253
|
+
} else if (operation.operationKind === 'restore') {
|
|
254
|
+
result = await restoreDeletedParagraphByExactText(
|
|
255
|
+
documentXml,
|
|
256
|
+
operation.target,
|
|
257
|
+
operation.modified,
|
|
258
|
+
authorUsed,
|
|
259
|
+
operation.targetRef,
|
|
260
|
+
operation.targetEndRef,
|
|
261
|
+
runtimeContext,
|
|
262
|
+
operationOptions
|
|
263
|
+
);
|
|
264
|
+
} else {
|
|
246
265
|
result = await applyToParagraphByExactText(
|
|
247
266
|
documentXml,
|
|
248
267
|
operation.target,
|