@ansonlai/docx-redline-js 0.5.3 → 0.6.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/AGENTS.md +82 -667
- package/ARCHITECTURE.md +51 -4
- package/CHANGELOG.md +11 -0
- package/README.md +176 -39
- package/core/paragraph-revision-safety.js +10 -8
- package/core/paragraph-targeting.js +14 -2
- package/core/redline-validation.js +7 -4
- package/core/revision-cloning.js +21 -0
- package/core/validation-delta.js +23 -0
- package/dist/docx-redline-js.esm.js +275 -45
- package/dist/docx-redline-js.esm.js.map +3 -3
- package/dist/docx-redline-js.esm.min.js +82 -82
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/AGENT_FAST_START.md +59 -0
- package/docs/AGENT_KNOWLEDGE_BASE.md +868 -0
- package/docs/TESTING.md +20 -1
- package/docs/schemas/document-operations.schema.json +16 -2
- package/docs/validation-reports/2026-09-12-agent-protocol-rollout.md +82 -0
- package/engine/oxml-engine.js +80 -13
- package/engine/run-builders.js +5 -15
- package/engine/surgical-mode.js +148 -3
- package/engine/surgical-run-splitting.js +19 -7
- package/engine/surgical-spans.js +2 -1
- package/index.d.ts +17 -1
- package/node/cli.js +235 -36
- package/node/docx-document.js +137 -83
- package/node/index.d.ts +6 -2
- package/package.json +10 -3
- package/pipeline/diff-engine.js +15 -0
- package/scripts/generate-cross-author-slicing-fixtures.ps1 +25 -25
- package/services/batch-operation-orchestrator.js +215 -120
- package/services/document-inspection.js +5 -3
- package/services/document-operation-applier.js +99 -36
- package/services/document-operation-contract.js +50 -6
- package/services/document-operation-mutations.js +404 -41
- package/services/document-operation-session.js +4 -0
- package/services/error-recovery.js +174 -0
- package/services/operation-batch-compiler.js +394 -0
- package/services/operation-preflight.js +91 -72
- package/services/standalone-operation-runner.d.ts +35 -1
- package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +0 -1669
- package/docs/plans/2026-09-08-cross-author-revision-slicing.md +0 -856
- package/docs/plans/completed/2026-03-01-release-0.1.4-design.md +0 -33
- package/docs/plans/completed/2026-03-01-release-0.1.4.md +0 -110
- package/docs/plans/completed/2026-05-31-architectural changes.md +0 -593
- package/docs/plans/completed/2026-08-02-reliability-improvements.md +0 -1155
- package/docs/plans/completed/2026-08-30-reliability-testing-improvements.md +0 -488
- package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +0 -669
- package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +0 -427
- package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +0 -519
- package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +0 -69
- package/docs/plans/completed/structural-revision-capability-matrix.md +0 -115
- package/docs/test-comparison-dashboard.html +0 -4338
- package/docs/validation-reports/2026-08-30-phase-1-word-visual-preflight.md +0 -22
- package/docs/validation-reports/2026-08-30-phase-2-word-visual-preflight.md +0 -24
- package/docs/validation-reports/2026-08-30-phase-3-coverage.md +0 -73
- package/docs/validation-reports/2026-09-02-multilevel-bullets-visual-review.md +0 -82
- package/docs/validation-reports/2026-09-02-multimodal-visual-samples.md +0 -114
- package/docs/validation-reports/2026-09-02-visual-failures-preflight.md +0 -79
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
export const ERROR_RECOVERY_VERSION = 1;
|
|
2
|
+
|
|
3
|
+
const RULES = Object.freeze({
|
|
4
|
+
INVALID_OPERATION: ['request', 'request_fixable', 'change_request'],
|
|
5
|
+
INVALID_AGENT_REQUEST: ['request', 'request_fixable', 'change_request'],
|
|
6
|
+
INVALID_FILTER: ['request', 'request_fixable', 'change_request'],
|
|
7
|
+
INVALID_OPERATIONS_FILE: ['request', 'request_fixable', 'change_request'],
|
|
8
|
+
OPERATIONS_REQUIRED: ['request', 'request_fixable', 'change_request'],
|
|
9
|
+
UNKNOWN_OPTION: ['request', 'request_fixable', 'change_request'],
|
|
10
|
+
UNEXPECTED_ARGUMENT: ['request', 'request_fixable', 'change_request'],
|
|
11
|
+
INVALID_ACTION: ['request', 'request_fixable', 'change_request'],
|
|
12
|
+
INVALID_PROFILE: ['request', 'request_fixable', 'change_request'],
|
|
13
|
+
INVALID_REVISION_TOKEN: ['revision-check', 'request_fixable', 'change_request'],
|
|
14
|
+
BATCH_OPERATION_FAILED: ['batch-execution', 'request_fixable', 'inspect_failed_operations'],
|
|
15
|
+
REVISION_TOKEN_SCOPE_MISMATCH: ['revision-check', 'request_fixable', 'change_request'],
|
|
16
|
+
REVISION_MISMATCH: ['revision-check', 'target_refresh_required', 'reinspect'],
|
|
17
|
+
TARGET_NOT_FOUND: ['target-resolution', 'target_refresh_required', 'reinspect'],
|
|
18
|
+
SOURCE_TARGET_NOT_FOUND: ['target-resolution', 'target_refresh_required', 'reinspect'],
|
|
19
|
+
TARGET_TEXT_MISMATCH: ['target-resolution', 'target_refresh_required', 'reinspect'],
|
|
20
|
+
TARGET_FINGERPRINT_MISMATCH: ['target-resolution', 'target_refresh_required', 'reinspect'],
|
|
21
|
+
TARGET_INDEX_MISMATCH: ['target-resolution', 'target_refresh_required', 'reinspect'],
|
|
22
|
+
TARGET_OCCURRENCE_MISMATCH: ['target-resolution', 'candidate_selection_required', 'choose_candidate'],
|
|
23
|
+
STALE_TARGET_HANDLE: ['target-resolution', 'target_refresh_required', 'reinspect'],
|
|
24
|
+
TARGET_HANDLE_NOT_FOUND: ['target-resolution', 'target_refresh_required', 'reinspect'],
|
|
25
|
+
AMBIGUOUS_TARGET: ['target-resolution', 'candidate_selection_required', 'choose_candidate'],
|
|
26
|
+
TARGET_RANGE_INVALID: ['target-resolution', 'request_fixable', 'change_target_range'],
|
|
27
|
+
ANCHOR_NOT_FOUND: ['anchor-resolution', 'request_fixable', 'change_anchor'],
|
|
28
|
+
AMBIGUOUS_ANCHOR: ['anchor-resolution', 'candidate_selection_required', 'choose_candidate'],
|
|
29
|
+
PATCH_SOURCE_NOT_FOUND: ['patch-compilation', 'request_fixable', 'change_patch'],
|
|
30
|
+
AMBIGUOUS_PATCH_SOURCE: ['patch-compilation', 'candidate_selection_required', 'choose_occurrence'],
|
|
31
|
+
OVERLAPPING_PATCHES: ['patch-compilation', 'source_conflict', 'combine_patches'],
|
|
32
|
+
CONFLICTING_PATCHES: ['patch-compilation', 'source_conflict', 'combine_patches'],
|
|
33
|
+
PATCH_ROUNDTRIP_MISMATCH: ['patch-validation', 'request_fixable', 'reinspect_and_narrow'],
|
|
34
|
+
DIFF_TOKEN_LIMIT: ['patch-compilation', 'request_fixable', 'narrow_operation'],
|
|
35
|
+
STRUCTURED_CONTENT_INVALID: ['request', 'request_fixable', 'change_request'],
|
|
36
|
+
UNSUPPORTED_INSERTION_AFFINITY: ['request', 'request_fixable', 'change_request'],
|
|
37
|
+
EXISTING_REVISIONS: ['target-safety', 'policy_choice_required', 'set_option'],
|
|
38
|
+
COMMENTED_CONTENT_MERGE: ['target-safety', 'user_authorization_required', 'resolve_comments'],
|
|
39
|
+
COMMENTED_CONTENT_DELETE: ['target-safety', 'user_authorization_required', 'resolve_comments'],
|
|
40
|
+
OVERLAPPING_SOURCE_TARGETS: ['batch-compilation', 'source_conflict', 'consolidate_operations'],
|
|
41
|
+
OVERLAPPING_TEXT_EDITS: ['batch-compilation', 'source_conflict', 'consolidate_operations'],
|
|
42
|
+
REVISION_ORDER_CONFLICT: ['batch-compilation', 'source_conflict', 'split_or_consolidate_operations'],
|
|
43
|
+
TARGET_CONSUMED_BY_OPERATION: ['batch-execution', 'source_conflict', 'use_created_content_dependency'],
|
|
44
|
+
CAPTURE_FANOUT_CONFLICT: ['batch-compilation', 'source_conflict', 'split_or_chain_capture_consumers'],
|
|
45
|
+
DUPLICATE_CAPTURE_KEY: ['batch-compilation', 'request_fixable', 'rename_capture'],
|
|
46
|
+
CAPTURE_NOT_FOUND: ['batch-compilation', 'request_fixable', 'add_or_correct_capture'],
|
|
47
|
+
CAPTURE_DEPENDENCY_CYCLE: ['batch-compilation', 'source_conflict', 'replan_batch'],
|
|
48
|
+
AMBIGUOUS_CAPTURE_SELECTION: ['batch-compilation', 'candidate_selection_required', 'choose_candidate'],
|
|
49
|
+
CAPTURE_STALE: ['batch-execution', 'source_conflict', 'replan_batch'],
|
|
50
|
+
GENERATED_OOXML_INVALID: ['validation', 'library_or_builder_failure', 'report_library_failure'],
|
|
51
|
+
DOCUMENT_SERIALIZATION_FAILED: ['serialization', 'library_or_builder_failure', 'report_library_failure'],
|
|
52
|
+
PACKAGE_OPERATION_FAILED: ['package', 'library_or_builder_failure', 'report_library_failure'],
|
|
53
|
+
PACKAGE_VALIDATION: ['package-validation', 'library_or_builder_failure', 'report_library_failure'],
|
|
54
|
+
MUTATION_RECEIPT_MISMATCH: ['receipt-validation', 'library_or_builder_failure', 'report_library_failure'],
|
|
55
|
+
FOREIGN_PARAGRAPH_MARK_DELETION: ['target-safety', 'policy_choice_required', 'use_restore_or_leave_deleted'],
|
|
56
|
+
RESTORATION_STATE_REQUIRED: ['target-safety', 'target_refresh_required', 'reinspect'],
|
|
57
|
+
RESTORATION_COUNT_MISMATCH: ['request', 'request_fixable', 'change_request'],
|
|
58
|
+
REJECTED_INSERTION_STATE_REQUIRED: ['target-safety', 'target_refresh_required', 'reinspect'],
|
|
59
|
+
UNSAFE_REVISION_NESTING: ['target-safety', 'manual_document_resolution', 'manual_resolution'],
|
|
60
|
+
UNSAFE_REVISION_BOUNDARY: ['target-safety', 'manual_document_resolution', 'manual_resolution'],
|
|
61
|
+
UNSAFE_PARAGRAPH_BOUNDARY: ['target-safety', 'manual_document_resolution', 'manual_resolution'],
|
|
62
|
+
UNSAFE_DELETED_TABLE_ROW: ['target-safety', 'manual_document_resolution', 'manual_resolution'],
|
|
63
|
+
UNSUPPORTED_MOVE_REVISION: ['target-safety', 'manual_document_resolution', 'manual_resolution'],
|
|
64
|
+
SECTION_BREAK_PARAGRAPH: ['target-safety', 'manual_document_resolution', 'manual_resolution'],
|
|
65
|
+
UNSAFE_PARAGRAPH_PLACEMENT: ['target-safety', 'manual_document_resolution', 'manual_resolution'],
|
|
66
|
+
UNSUPPORTED_REVISION_VIEW_MUTATION: ['target-safety', 'manual_document_resolution', 'manual_resolution']
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
function issueSummary(error) {
|
|
70
|
+
const issues = Array.isArray(error?.generatedIssues)
|
|
71
|
+
? error.generatedIssues
|
|
72
|
+
: (Array.isArray(error?.issues) ? error.issues : null);
|
|
73
|
+
if (!issues) return null;
|
|
74
|
+
const byCode = new Map();
|
|
75
|
+
for (const issue of issues) {
|
|
76
|
+
const code = issue?.code || 'UNKNOWN';
|
|
77
|
+
byCode.set(code, (byCode.get(code) || 0) + 1);
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
total: issues.length,
|
|
81
|
+
byCode: Array.from(byCode, ([code, count]) => ({ code, count }))
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function ruleFor(code) {
|
|
86
|
+
return RULES[code] || ['operation', 'manual_document_resolution', 'inspect_error'];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Add stable machine recovery metadata while preserving code-specific details. */
|
|
90
|
+
export function normalizeErrorWithRecovery(error, context = {}) {
|
|
91
|
+
const source = error && typeof error === 'object' ? error : {};
|
|
92
|
+
const code = typeof source.code === 'string' && source.code ? source.code : 'OPERATION_ERROR';
|
|
93
|
+
const [defaultStage, category, action] = ruleFor(code);
|
|
94
|
+
const details = {};
|
|
95
|
+
for (const [key, value] of Object.entries(source)) {
|
|
96
|
+
if (key === 'name' || key === 'stack' || key === 'message' || key === 'code') continue;
|
|
97
|
+
details[key] = value;
|
|
98
|
+
}
|
|
99
|
+
const requiresAuthorization = category === 'user_authorization_required';
|
|
100
|
+
const requiresReinspection = category === 'target_refresh_required'
|
|
101
|
+
|| code === 'PATCH_ROUNDTRIP_MISMATCH';
|
|
102
|
+
const recovery = {
|
|
103
|
+
action,
|
|
104
|
+
sameArgumentsSafe: false,
|
|
105
|
+
requiresReinspection,
|
|
106
|
+
requiresUserAuthorization: requiresAuthorization,
|
|
107
|
+
...(code === 'EXISTING_REVISIONS' ? {
|
|
108
|
+
field: 'existingRevisions',
|
|
109
|
+
recommendedValue: 'slice-cross-author'
|
|
110
|
+
} : {}),
|
|
111
|
+
...(source.recovery && typeof source.recovery === 'object' ? source.recovery : {})
|
|
112
|
+
};
|
|
113
|
+
const summary = issueSummary(source);
|
|
114
|
+
const derivedContext = {
|
|
115
|
+
...context,
|
|
116
|
+
...(Array.isArray(source.revisionAuthors) ? { revisionAuthors: source.revisionAuthors } : {}),
|
|
117
|
+
...(source.currentPolicy ? { currentPolicy: source.currentPolicy } : {})
|
|
118
|
+
};
|
|
119
|
+
if (
|
|
120
|
+
['TARGET_TEXT_MISMATCH', 'TARGET_FINGERPRINT_MISMATCH'].includes(code)
|
|
121
|
+
&& Array.isArray(source.candidates)
|
|
122
|
+
&& source.candidates.length === 1
|
|
123
|
+
) {
|
|
124
|
+
derivedContext.currentTarget = source.candidates[0];
|
|
125
|
+
derivedContext.requiresRecomposeModified = true;
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
recoveryVersion: ERROR_RECOVERY_VERSION,
|
|
129
|
+
code,
|
|
130
|
+
message: source.message || String(error),
|
|
131
|
+
stage: source.stage || defaultStage,
|
|
132
|
+
category: source.category || category,
|
|
133
|
+
...details,
|
|
134
|
+
...(Object.keys(derivedContext).length > 0 || source.context ? {
|
|
135
|
+
context: { ...(source.context || {}), ...derivedContext }
|
|
136
|
+
} : {}),
|
|
137
|
+
...(summary ? { issueSummary: summary } : {}),
|
|
138
|
+
recovery
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function createRetryPlan({
|
|
143
|
+
atomic = false,
|
|
144
|
+
rolledBack = false,
|
|
145
|
+
results = [],
|
|
146
|
+
receipts = [],
|
|
147
|
+
operationCount = null
|
|
148
|
+
} = {}) {
|
|
149
|
+
const count = Number.isInteger(operationCount)
|
|
150
|
+
? operationCount
|
|
151
|
+
: Math.max(results.length, receipts.length);
|
|
152
|
+
const attempted = new Set(results.map(result => result?.index).filter(Number.isInteger));
|
|
153
|
+
const failedIndexes = results
|
|
154
|
+
.filter(result => result?.status === 'error')
|
|
155
|
+
.map(result => result.index)
|
|
156
|
+
.filter(Number.isInteger);
|
|
157
|
+
const committedIndexes = receipts
|
|
158
|
+
.filter(receipt => receipt?.committed === true)
|
|
159
|
+
.map(receipt => receipt.operationIndex)
|
|
160
|
+
.filter(Number.isInteger);
|
|
161
|
+
const unattemptedIndexes = [];
|
|
162
|
+
for (let index = 1; index <= count; index += 1) {
|
|
163
|
+
if (!attempted.has(index)) unattemptedIndexes.push(index);
|
|
164
|
+
}
|
|
165
|
+
const base = rolledBack || atomic || committedIndexes.length === 0 ? 'original' : 'output';
|
|
166
|
+
return {
|
|
167
|
+
base,
|
|
168
|
+
committedIndexes,
|
|
169
|
+
failedIndexes,
|
|
170
|
+
unattemptedIndexes,
|
|
171
|
+
replayWholeBatch: base === 'original',
|
|
172
|
+
sameArgumentsSafe: false
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildParagraphMetadataIndex,
|
|
3
|
+
getDocumentParagraphNodes,
|
|
4
|
+
getParagraphId,
|
|
5
|
+
resolveTargetParagraph
|
|
6
|
+
} from '../core/paragraph-targeting.js';
|
|
7
|
+
import {
|
|
8
|
+
normalizeDocumentOperation,
|
|
9
|
+
normalizeTargetDescriptor,
|
|
10
|
+
validateDocumentOperation
|
|
11
|
+
} from './document-operation-contract.js';
|
|
12
|
+
|
|
13
|
+
const TEXT_WRITE_KINDS = new Set(['redline', 'restore', 'rejected-insert']);
|
|
14
|
+
const FORMAT_WRITE_KINDS = new Set(['highlight', 'format', 'paragraph-format']);
|
|
15
|
+
|
|
16
|
+
function normalizedError(error) {
|
|
17
|
+
return {
|
|
18
|
+
code: typeof error?.code === 'string' && error.code ? error.code : 'OPERATION_ERROR',
|
|
19
|
+
message: error?.message || String(error),
|
|
20
|
+
...(Array.isArray(error?.candidates) ? { candidates: error.candidates } : {})
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isParagraphAttached(document, paragraph) {
|
|
25
|
+
return !!paragraph && getDocumentParagraphNodes(document).includes(paragraph);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function paragraphNodesFromMutation(nodes = []) {
|
|
29
|
+
const paragraphs = [];
|
|
30
|
+
for (const node of nodes) {
|
|
31
|
+
if (!node || node.nodeType !== 1) continue;
|
|
32
|
+
if (node.localName === 'p') paragraphs.push(node);
|
|
33
|
+
for (const paragraph of Array.from(node.getElementsByTagNameNS?.('*', 'p') || [])) {
|
|
34
|
+
if (!paragraphs.includes(paragraph)) paragraphs.push(paragraph);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return paragraphs;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Session-local source identities over live paragraph nodes. */
|
|
41
|
+
export class SourceTargetRegistry {
|
|
42
|
+
constructor(document) {
|
|
43
|
+
this.document = document;
|
|
44
|
+
this.nextId = 1;
|
|
45
|
+
this.entries = new Map();
|
|
46
|
+
this.idsByNode = new WeakMap();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
register(paragraph, metadata = {}) {
|
|
50
|
+
const existing = this.idsByNode.get(paragraph);
|
|
51
|
+
if (existing) return existing;
|
|
52
|
+
const sourceId = `S${this.nextId++}`;
|
|
53
|
+
this.entries.set(sourceId, {
|
|
54
|
+
sourceId,
|
|
55
|
+
paragraph,
|
|
56
|
+
sourceIndex: metadata.index || null,
|
|
57
|
+
paragraphId: metadata.paragraphId || null,
|
|
58
|
+
fingerprint: metadata.fingerprint || null,
|
|
59
|
+
text: metadata.text || '',
|
|
60
|
+
revisionView: metadata.revisionView === 'rejected' ? 'rejected' : 'accepted',
|
|
61
|
+
consumed: false,
|
|
62
|
+
consumedByOperation: null
|
|
63
|
+
});
|
|
64
|
+
this.idsByNode.set(paragraph, sourceId);
|
|
65
|
+
return sourceId;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe(sourceId) {
|
|
69
|
+
const entry = this.entries.get(sourceId);
|
|
70
|
+
if (!entry) return null;
|
|
71
|
+
return {
|
|
72
|
+
sourceId,
|
|
73
|
+
index: entry.sourceIndex,
|
|
74
|
+
paragraphId: entry.paragraphId,
|
|
75
|
+
fingerprint: entry.fingerprint,
|
|
76
|
+
text: entry.text,
|
|
77
|
+
revisionView: entry.revisionView
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
resolve(sourceId, document = this.document) {
|
|
82
|
+
const entry = this.entries.get(sourceId);
|
|
83
|
+
if (!entry) {
|
|
84
|
+
return {
|
|
85
|
+
error: {
|
|
86
|
+
code: 'SOURCE_TARGET_NOT_FOUND',
|
|
87
|
+
message: `Compiled source target ${String(sourceId)} was not found.`
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (entry.consumed) {
|
|
92
|
+
return {
|
|
93
|
+
error: {
|
|
94
|
+
code: 'TARGET_CONSUMED_BY_OPERATION',
|
|
95
|
+
message: `Compiled source target ${sourceId} was consumed by operation ${entry.consumedByOperation}.`,
|
|
96
|
+
sourceTarget: this.describe(sourceId),
|
|
97
|
+
consumedByOperation: entry.consumedByOperation
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (isParagraphAttached(document, entry.paragraph)) {
|
|
102
|
+
return { paragraph: entry.paragraph, resolvedBy: 'batch_source' };
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
error: {
|
|
106
|
+
code: 'TARGET_CONSUMED_BY_OPERATION',
|
|
107
|
+
message: `Compiled source target ${sourceId} is no longer present in the live document.`,
|
|
108
|
+
sourceTarget: this.describe(sourceId),
|
|
109
|
+
consumedByOperation: entry.consumedByOperation
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
createSavepoint(document = this.document) {
|
|
115
|
+
const paragraphs = getDocumentParagraphNodes(document);
|
|
116
|
+
return new Map(Array.from(this.entries, ([sourceId, entry]) => [sourceId, {
|
|
117
|
+
paragraphIndex: paragraphs.indexOf(entry.paragraph),
|
|
118
|
+
consumed: entry.consumed,
|
|
119
|
+
consumedByOperation: entry.consumedByOperation
|
|
120
|
+
}]));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
restoreSavepoint(document, snapshot) {
|
|
124
|
+
this.document = document;
|
|
125
|
+
this.idsByNode = new WeakMap();
|
|
126
|
+
const paragraphs = getDocumentParagraphNodes(document);
|
|
127
|
+
for (const [sourceId, entry] of this.entries) {
|
|
128
|
+
const saved = snapshot instanceof Map ? snapshot.get(sourceId) : null;
|
|
129
|
+
entry.consumed = saved?.consumed === true;
|
|
130
|
+
entry.consumedByOperation = saved?.consumedByOperation || null;
|
|
131
|
+
entry.paragraph = Number.isInteger(saved?.paragraphIndex) && saved.paragraphIndex >= 0
|
|
132
|
+
? (paragraphs[saved.paragraphIndex] || null)
|
|
133
|
+
: null;
|
|
134
|
+
if (entry.paragraph) this.idsByNode.set(entry.paragraph, sourceId);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
commitMutation(sourceId, removedNodes, liveNodes, operationIndex) {
|
|
139
|
+
const entry = this.entries.get(sourceId);
|
|
140
|
+
if (!entry || !Array.isArray(removedNodes) || !removedNodes.includes(entry.paragraph)) return;
|
|
141
|
+
const candidates = paragraphNodesFromMutation(liveNodes);
|
|
142
|
+
const sameId = entry.paragraphId
|
|
143
|
+
? candidates.filter(paragraph => getParagraphId(paragraph) === entry.paragraphId)
|
|
144
|
+
: [];
|
|
145
|
+
const successor = sameId.length === 1
|
|
146
|
+
? sameId[0]
|
|
147
|
+
: (candidates.length === 1 ? candidates[0] : null);
|
|
148
|
+
if (successor) {
|
|
149
|
+
entry.paragraph = successor;
|
|
150
|
+
this.idsByNode.set(successor, sourceId);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
entry.paragraph = null;
|
|
154
|
+
entry.consumed = true;
|
|
155
|
+
entry.consumedByOperation = operationIndex;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function resolvedTarget(metadata, revisionView) {
|
|
160
|
+
return {
|
|
161
|
+
index: metadata.index,
|
|
162
|
+
paragraphId: metadata.paragraphId,
|
|
163
|
+
fingerprint: metadata.fingerprint,
|
|
164
|
+
text: metadata.text,
|
|
165
|
+
inTable: metadata.inTable,
|
|
166
|
+
revisionView
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function resolveDescriptor(xmlDoc, descriptor, kind, strictTargets, metadataIndices, callbacks = {}) {
|
|
171
|
+
const revisionView = descriptor?.revisionView === 'rejected' ? 'rejected' : 'accepted';
|
|
172
|
+
if (!metadataIndices[revisionView]) {
|
|
173
|
+
metadataIndices[revisionView] = buildParagraphMetadataIndex(xmlDoc, { revisionView });
|
|
174
|
+
}
|
|
175
|
+
const index = metadataIndices[revisionView];
|
|
176
|
+
const resolution = resolveTargetParagraph(xmlDoc, {
|
|
177
|
+
targetText: descriptor.text,
|
|
178
|
+
targetRef: descriptor.index,
|
|
179
|
+
targetDescriptor: descriptor,
|
|
180
|
+
opType: kind,
|
|
181
|
+
strictAmbiguity: strictTargets,
|
|
182
|
+
paragraphMetadataIndex: index,
|
|
183
|
+
metadataIndices,
|
|
184
|
+
onInfo: callbacks.onInfo,
|
|
185
|
+
onWarn: callbacks.onWarn
|
|
186
|
+
});
|
|
187
|
+
const metadata = index.byParagraph.get(resolution.paragraph);
|
|
188
|
+
return {
|
|
189
|
+
paragraph: resolution.paragraph,
|
|
190
|
+
resolvedBy: resolution.resolvedBy,
|
|
191
|
+
metadata: resolvedTarget(metadata, revisionView)
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function buildConflict(code, message, bindings, target, action) {
|
|
196
|
+
return {
|
|
197
|
+
code,
|
|
198
|
+
message,
|
|
199
|
+
operationIndexes: bindings.map(binding => binding.index).sort((a, b) => a - b),
|
|
200
|
+
target,
|
|
201
|
+
recovery: {
|
|
202
|
+
action,
|
|
203
|
+
sameArgumentsSafe: false,
|
|
204
|
+
requiresReinspection: false,
|
|
205
|
+
requiresUserAuthorization: false
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Resolve source targets once and derive source-level conflicts before mutation. */
|
|
211
|
+
export function compileOperationBatch(xmlDoc, operations = [], options = {}) {
|
|
212
|
+
const sourceOperations = Array.isArray(operations) ? operations : [];
|
|
213
|
+
const strictTargets = options.strictTargets !== false;
|
|
214
|
+
const registry = new SourceTargetRegistry(xmlDoc);
|
|
215
|
+
const metadataIndices = {
|
|
216
|
+
accepted: buildParagraphMetadataIndex(xmlDoc, { revisionView: 'accepted' }),
|
|
217
|
+
rejected: null
|
|
218
|
+
};
|
|
219
|
+
const bindings = [];
|
|
220
|
+
const compiledOperations = [];
|
|
221
|
+
|
|
222
|
+
for (let index = 0; index < sourceOperations.length; index += 1) {
|
|
223
|
+
const sourceOperation = sourceOperations[index];
|
|
224
|
+
const validation = validateDocumentOperation(sourceOperation);
|
|
225
|
+
const operation = validation.operation || normalizeDocumentOperation(sourceOperation);
|
|
226
|
+
const binding = {
|
|
227
|
+
index: index + 1,
|
|
228
|
+
operationKind: operation.operationKind,
|
|
229
|
+
operationId: operation.operationId,
|
|
230
|
+
sourceIds: [],
|
|
231
|
+
warnings: []
|
|
232
|
+
};
|
|
233
|
+
const compiled = { ...sourceOperation };
|
|
234
|
+
|
|
235
|
+
const dynamicOccurrence = operation.targetDescriptor?.occurrence != null
|
|
236
|
+
&& !operation.targetDescriptor?.paragraphId
|
|
237
|
+
&& !operation.targetDescriptor?.fingerprint;
|
|
238
|
+
const compositeTextTarget = typeof operation.targetDescriptor?.text === 'string'
|
|
239
|
+
&& /\r|\n/.test(operation.targetDescriptor.text)
|
|
240
|
+
&& !operation.targetEndDescriptor;
|
|
241
|
+
if (dynamicOccurrence || compositeTextTarget) binding.dynamic = true;
|
|
242
|
+
|
|
243
|
+
if (
|
|
244
|
+
validation.valid
|
|
245
|
+
&& operation.operationKind !== 'comment_reply'
|
|
246
|
+
&& !operation.targetDescriptor?.captureRef
|
|
247
|
+
&& !binding.dynamic
|
|
248
|
+
) {
|
|
249
|
+
try {
|
|
250
|
+
const start = resolveDescriptor(
|
|
251
|
+
xmlDoc,
|
|
252
|
+
operation.targetDescriptor,
|
|
253
|
+
operation.operationKind,
|
|
254
|
+
strictTargets,
|
|
255
|
+
metadataIndices,
|
|
256
|
+
{
|
|
257
|
+
onInfo: options.onInfo,
|
|
258
|
+
onWarn: warning => binding.warnings.push(String(warning))
|
|
259
|
+
}
|
|
260
|
+
);
|
|
261
|
+
const startId = registry.register(start.paragraph, start.metadata);
|
|
262
|
+
binding.sourceIds.push(startId);
|
|
263
|
+
binding.resolvedBy = start.resolvedBy;
|
|
264
|
+
binding.resolvedTarget = start.metadata;
|
|
265
|
+
compiled._compiledSourceId = startId;
|
|
266
|
+
compiled._compiledResolvedBy = start.resolvedBy;
|
|
267
|
+
if (binding.warnings.length > 0) compiled._compiledWarnings = binding.warnings;
|
|
268
|
+
|
|
269
|
+
const targetEndDescriptor = operation.targetEndDescriptor
|
|
270
|
+
|| (operation.targetEndRef != null
|
|
271
|
+
? normalizeTargetDescriptor(null, operation.targetEndRef, operation.targetDescriptor.revisionView)
|
|
272
|
+
: null);
|
|
273
|
+
if (targetEndDescriptor) {
|
|
274
|
+
const end = resolveDescriptor(
|
|
275
|
+
xmlDoc,
|
|
276
|
+
targetEndDescriptor,
|
|
277
|
+
operation.operationKind,
|
|
278
|
+
strictTargets,
|
|
279
|
+
metadataIndices,
|
|
280
|
+
{
|
|
281
|
+
onInfo: options.onInfo,
|
|
282
|
+
onWarn: warning => binding.warnings.push(String(warning))
|
|
283
|
+
}
|
|
284
|
+
);
|
|
285
|
+
const endId = registry.register(end.paragraph, end.metadata);
|
|
286
|
+
compiled._compiledSourceEndId = endId;
|
|
287
|
+
const paragraphs = getDocumentParagraphNodes(xmlDoc);
|
|
288
|
+
const startIndex = paragraphs.indexOf(start.paragraph);
|
|
289
|
+
const endIndex = paragraphs.indexOf(end.paragraph);
|
|
290
|
+
if (startIndex < 0 || endIndex < startIndex) {
|
|
291
|
+
throw Object.assign(new Error('Target range is not a forward contiguous paragraph range.'), {
|
|
292
|
+
code: 'TARGET_RANGE_INVALID'
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
for (const paragraph of paragraphs.slice(startIndex, endIndex + 1)) {
|
|
296
|
+
const viewIndex = metadataIndices[operation.targetDescriptor.revisionView] || metadataIndices.accepted;
|
|
297
|
+
const metadata = viewIndex.byParagraph.get(paragraph);
|
|
298
|
+
binding.sourceIds.push(registry.register(paragraph, metadata || {}));
|
|
299
|
+
}
|
|
300
|
+
binding.sourceIds = [...new Set(binding.sourceIds)];
|
|
301
|
+
binding.resolvedTargetEnd = end.metadata;
|
|
302
|
+
}
|
|
303
|
+
} catch (error) {
|
|
304
|
+
binding.error = normalizedError(error);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
bindings.push(binding);
|
|
308
|
+
compiledOperations.push(compiled);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const existingCaptureKeys = new Set(compiledOperations.map(operation => operation?.captureKey).filter(Boolean));
|
|
312
|
+
let nextImplicitCapture = 1;
|
|
313
|
+
for (const binding of bindings) {
|
|
314
|
+
if (binding.error?.code !== 'TARGET_NOT_FOUND') continue;
|
|
315
|
+
const consumerIndex = binding.index - 1;
|
|
316
|
+
const consumer = normalizeDocumentOperation(sourceOperations[consumerIndex]);
|
|
317
|
+
const targetText = consumer.targetDescriptor?.text;
|
|
318
|
+
if (!targetText) continue;
|
|
319
|
+
const producers = sourceOperations.flatMap((candidate, producerIndex) => {
|
|
320
|
+
if (producerIndex === consumerIndex || typeof candidate?.modified !== 'string') return [];
|
|
321
|
+
const createdParagraphs = candidate.modified.split(/\r?\n/).map(text => text.trim()).filter(Boolean);
|
|
322
|
+
return createdParagraphs.includes(targetText.trim()) ? [{ candidate, producerIndex }] : [];
|
|
323
|
+
});
|
|
324
|
+
if (producers.length !== 1) continue;
|
|
325
|
+
const [{ candidate: producer, producerIndex }] = producers;
|
|
326
|
+
let captureKey = producer.captureKey || compiledOperations[producerIndex].captureKey;
|
|
327
|
+
if (!captureKey) {
|
|
328
|
+
do {
|
|
329
|
+
captureKey = `__batch_created_${nextImplicitCapture++}`;
|
|
330
|
+
} while (existingCaptureKeys.has(captureKey));
|
|
331
|
+
existingCaptureKeys.add(captureKey);
|
|
332
|
+
compiledOperations[producerIndex].captureKey = captureKey;
|
|
333
|
+
}
|
|
334
|
+
compiledOperations[consumerIndex].target = { captureRef: captureKey, select: targetText };
|
|
335
|
+
delete compiledOperations[consumerIndex]._compiledSourceId;
|
|
336
|
+
delete compiledOperations[consumerIndex]._compiledResolvedBy;
|
|
337
|
+
delete binding.error;
|
|
338
|
+
binding.sourceIds = [];
|
|
339
|
+
binding.resolvedBy = 'created_content_dependency';
|
|
340
|
+
binding.createdByOperation = producerIndex + 1;
|
|
341
|
+
binding.captureRef = captureKey;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const bySource = new Map();
|
|
345
|
+
for (const binding of bindings) {
|
|
346
|
+
if (binding.error || binding.dynamic) continue;
|
|
347
|
+
for (const sourceId of binding.sourceIds) {
|
|
348
|
+
if (!bySource.has(sourceId)) bySource.set(sourceId, []);
|
|
349
|
+
bySource.get(sourceId).push(binding);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const conflicts = [];
|
|
354
|
+
const conflictKeys = new Set();
|
|
355
|
+
for (const [sourceId, sourceBindings] of bySource) {
|
|
356
|
+
const textWrites = sourceBindings.filter(binding => TEXT_WRITE_KINDS.has(binding.operationKind));
|
|
357
|
+
const formatWrites = sourceBindings.filter(binding => FORMAT_WRITE_KINDS.has(binding.operationKind));
|
|
358
|
+
if (textWrites.length > 1) {
|
|
359
|
+
const key = `text:${textWrites.map(binding => binding.index).sort().join(',')}`;
|
|
360
|
+
if (!conflictKeys.has(key)) {
|
|
361
|
+
conflictKeys.add(key);
|
|
362
|
+
conflicts.push(buildConflict(
|
|
363
|
+
'OVERLAPPING_SOURCE_TARGETS',
|
|
364
|
+
`Operations ${textWrites.map(binding => binding.index).join(', ')} contain incompatible text writes to the same batch-start source target.`,
|
|
365
|
+
textWrites,
|
|
366
|
+
registry.describe(sourceId),
|
|
367
|
+
'consolidate_operations'
|
|
368
|
+
));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (textWrites.length > 0 && formatWrites.length > 0) {
|
|
372
|
+
const affected = [...textWrites, ...formatWrites];
|
|
373
|
+
const key = `format:${affected.map(binding => binding.index).sort().join(',')}`;
|
|
374
|
+
if (!conflictKeys.has(key)) {
|
|
375
|
+
conflictKeys.add(key);
|
|
376
|
+
conflicts.push(buildConflict(
|
|
377
|
+
'REVISION_ORDER_CONFLICT',
|
|
378
|
+
`Operations ${affected.map(binding => binding.index).join(', ')} combine text and formatting writes on the same batch-start source target.`,
|
|
379
|
+
affected,
|
|
380
|
+
registry.describe(sourceId),
|
|
381
|
+
'split_or_consolidate_operations'
|
|
382
|
+
));
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
valid: conflicts.length === 0 && bindings.every(binding => !binding.error),
|
|
389
|
+
compiledOperations,
|
|
390
|
+
bindings,
|
|
391
|
+
conflicts,
|
|
392
|
+
registry
|
|
393
|
+
};
|
|
394
|
+
}
|