@ansonlai/docx-redline-js 0.8.3 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +221 -12
- package/CHANGELOG.md +803 -586
- package/README.md +1018 -901
- package/core/current-properties.js +136 -0
- package/core/paragraph-text.js +211 -209
- package/core/types.js +9 -3
- package/core/word-revision-semantics.js +256 -0
- package/dist/docx-redline-js.esm.js +4551 -967
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +112 -106
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +4690 -1106
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +4690 -1106
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_FAST_START.md +2 -0
- package/docs/AGENT_KNOWLEDGE_BASE.md +23 -4
- package/docs/SKILL_AUTHORING.md +165 -150
- package/docs/TESTING.md +1142 -1059
- package/docs/schemas/document-operations.schema.json +221 -168
- package/document/docx-document.js +1022 -1010
- package/engine/oxml-engine.js +567 -560
- package/engine/paragraph-mark-revisions.js +292 -0
- package/engine/reconstruction-mapper.js +312 -311
- package/engine/reconstruction-mode.js +183 -183
- package/engine/reconstruction-writer.js +67 -10
- package/engine/rpr-helpers.js +42 -0
- package/engine/run-builders.js +0 -73
- package/engine/surgical-diff-application.js +604 -605
- package/engine/table-mode.js +63 -63
- package/index.d.ts +51 -1
- package/index.js +47 -3
- package/node/cli-help.js +1 -0
- package/node/cli.js +992 -974
- package/orchestration/list-structural-fallback.js +67 -9
- package/package.json +1 -1
- package/pipeline/diff-engine.js +130 -1
- package/pipeline/ingestion-export.js +227 -243
- package/pipeline/ingestion-paragraph.js +93 -98
- package/pipeline/list-generation.js +415 -419
- package/pipeline/pipeline.js +206 -204
- package/pipeline/serialization.js +111 -94
- package/scripts/export-validation-fixtures.mjs +19 -6
- package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
- package/scripts/lib/word-coverage-catalogue.mjs +7 -2
- package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
- package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
- package/scripts/word-com-differential.ps1 +443 -335
- package/scripts/word-com-visual-suite.ps1 +2 -2
- package/scripts/word-revision-semantics-matrix.ps1 +108 -0
- package/services/batch-operation-orchestrator.js +26 -0
- package/services/capture-engine.js +228 -226
- package/services/document-operation-applier.js +215 -0
- package/services/document-operation-contract.js +628 -537
- package/services/document-operation-mutations.js +789 -46
- package/services/error-recovery.js +17 -0
- package/services/list-conversion-diagnostics.js +296 -0
- package/services/list-format-change.js +586 -0
- package/services/list-format-mutation.js +416 -0
- package/services/list-format.js +305 -0
- package/services/numbering-allocation.js +642 -0
- package/services/numbering-helpers.js +354 -524
- package/services/operation-batch-compiler.js +612 -436
- package/services/operation-heuristics.js +197 -173
- package/services/operation-preflight.js +1 -0
- package/services/receipt-collector.js +288 -288
- package/services/revision-comment-management.js +634 -594
- package/services/revision-fidelity-verifier.js +498 -0
- package/services/standalone-docx-plumbing.js +627 -631
- package/services/standalone-operation-runner.d.ts +606 -427
- package/services/table-reconciliation.js +103 -49
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM core of `list-format` `convert-plain` (canonical list operations plan, WP-4).
|
|
3
|
+
*
|
|
4
|
+
* Converts plain paragraphs IN PLACE: the paragraph element, its `w14:paraId`, bookmarks, comments and direct
|
|
5
|
+
* formatting stay; the typed marker (when stripped) becomes a tracked `w:del` of exactly the marker text, and the
|
|
6
|
+
* new `w:numPr` binding is recorded as a tracked `w:pPrChange` through the same path as
|
|
7
|
+
* `paragraph-format.numbering` (`snapshotAndAttachPPrChange` + `applyParagraphPropertiesToPPr`). Word's Reject All
|
|
8
|
+
* restores the literal marker and the unnumbered paragraph; Accept All shows a real list label.
|
|
9
|
+
*
|
|
10
|
+
* Every refusal is decided in a planning pass before anything is mutated. Current properties are read only
|
|
11
|
+
* through `core/current-properties.js`; no paragraph-mark revision is created (a binding is a property change).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
15
|
+
import {
|
|
16
|
+
getCurrentChild,
|
|
17
|
+
getCurrentNumIdElements,
|
|
18
|
+
getCurrentParagraphProperties,
|
|
19
|
+
getDirectWordChild
|
|
20
|
+
} from '../core/current-properties.js';
|
|
21
|
+
import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
|
|
22
|
+
import { refreshRunPropertyChangeIds } from '../core/revision-cloning.js';
|
|
23
|
+
import { createWordElement, isWordElement } from '../core/word-xml.js';
|
|
24
|
+
import { getParagraphListInfo } from '../core/list-targeting.js';
|
|
25
|
+
import { createTrackChange, snapshotAndAttachPPrChange } from '../engine/run-builders.js';
|
|
26
|
+
import { applyParagraphPropertiesToPPr } from '../engine/rpr-helpers.js';
|
|
27
|
+
import {
|
|
28
|
+
createRunFromPieces,
|
|
29
|
+
getRunContentPieces,
|
|
30
|
+
getRunTextLength,
|
|
31
|
+
sliceRunPieces
|
|
32
|
+
} from '../engine/surgical-run-splitting.js';
|
|
33
|
+
import { allocateNumberingDefinition, mergeNumberingParts } from './numbering-allocation.js';
|
|
34
|
+
import { createDynamicNumberingIdState } from './numbering-helpers.js';
|
|
35
|
+
import {
|
|
36
|
+
LIST_FORMAT_STYLE_NUMBERED,
|
|
37
|
+
LIST_FORMAT_TARGET_IS_LIST,
|
|
38
|
+
LIST_MARKER_AMBIGUOUS,
|
|
39
|
+
LIST_MARKER_NOT_FOUND,
|
|
40
|
+
LIST_MARKER_UNSUPPORTED,
|
|
41
|
+
listFormatSequenceKey,
|
|
42
|
+
listLevelSpec,
|
|
43
|
+
markerValue,
|
|
44
|
+
parseTypedListMarker,
|
|
45
|
+
resolveMarkerFormat
|
|
46
|
+
} from './list-format.js';
|
|
47
|
+
|
|
48
|
+
const PARAGRAPH_FORMAT_REVISION_CONFLICT = 'PARAGRAPH_FORMAT_REVISION_CONFLICT';
|
|
49
|
+
/** Paragraph children that carry no visible text and may precede the marker. */
|
|
50
|
+
const ZERO_WIDTH_CHILDREN = new Set([
|
|
51
|
+
'pPr', 'bookmarkStart', 'bookmarkEnd', 'proofErr', 'commentRangeStart', 'commentRangeEnd', 'permStart', 'permEnd'
|
|
52
|
+
]);
|
|
53
|
+
/** Run children a marker-bearing run may contain (anything else, such as a field or drawing, is refused). */
|
|
54
|
+
const SPLITTABLE_RUN_CHILDREN = new Set(['rPr', 't', 'tab', 'br', 'cr', 'noBreakHyphen', 'softHyphen', 'lastRenderedPageBreak']);
|
|
55
|
+
const MARKER_RUN_CHILDREN = new Set(['rPr', 't', 'tab', 'lastRenderedPageBreak']);
|
|
56
|
+
|
|
57
|
+
const excerpt = (text, limit = 80) => {
|
|
58
|
+
const value = String(text ?? '');
|
|
59
|
+
return value.length > limit ? `${value.slice(0, limit)}...` : value;
|
|
60
|
+
};
|
|
61
|
+
const localName = node => String(node?.localName || node?.nodeName || '').replace(/^.*:/, '');
|
|
62
|
+
const readVal = element => element?.getAttribute?.('w:val') ?? element?.getAttribute?.('val') ?? null;
|
|
63
|
+
|
|
64
|
+
export function refusal(code, message, extra = {}, recovery = null) {
|
|
65
|
+
return { ok: false, error: { code, message, ...extra, ...(recovery ? { recovery } : {}) } };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
69
|
+
// Style-linked numbering
|
|
70
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
export function parseStyles(stylesXml) {
|
|
73
|
+
const text = String(stylesXml || '');
|
|
74
|
+
if (!text.trim()) return null;
|
|
75
|
+
const parsed = parseOoxmlSafe(text, 'application/xml');
|
|
76
|
+
return parsed?.doc || null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The numId a paragraph style (or its `w:basedOn` ancestors) binds, or null. A paragraph that is numbered by its
|
|
81
|
+
* style is already a list item; converting it would stack a second binding over the style's list.
|
|
82
|
+
*/
|
|
83
|
+
export function styleNumbering(stylesDoc, styleId) {
|
|
84
|
+
if (!stylesDoc) return null;
|
|
85
|
+
const styles = Array.from(stylesDoc.getElementsByTagNameNS('*', 'style'))
|
|
86
|
+
.filter(style => (style.getAttribute('w:type') ?? style.getAttribute('type')) === 'paragraph');
|
|
87
|
+
const byId = new Map(styles.map(style => [style.getAttribute('w:styleId') ?? style.getAttribute('styleId'), style]));
|
|
88
|
+
let style = styleId
|
|
89
|
+
? byId.get(styleId)
|
|
90
|
+
: styles.find(candidate => ['1', 'true', 'on'].includes(candidate.getAttribute('w:default') ?? candidate.getAttribute('default')));
|
|
91
|
+
for (let depth = 0; style && depth < 16; depth += 1) {
|
|
92
|
+
const numPr = getCurrentChild(getDirectWordChild(style, 'pPr'), 'numPr');
|
|
93
|
+
const numId = readVal(getCurrentChild(numPr, 'numId'));
|
|
94
|
+
if (numId != null) return numId === '0' ? null : { numId, styleId: style.getAttribute('w:styleId') ?? style.getAttribute('styleId') };
|
|
95
|
+
style = byId.get(readVal(getDirectWordChild(style, 'basedOn')));
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
101
|
+
// Marker run location
|
|
102
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Finds the plain runs that carry the first `prefixLength` characters of the paragraph. Returns the run segments
|
|
106
|
+
* or a reason when the marker is not plain run text (inside a hyperlink, field, tracked change, ...).
|
|
107
|
+
*/
|
|
108
|
+
function locateMarkerRuns(paragraph, prefixLength, expectedPrefix) {
|
|
109
|
+
const segments = [];
|
|
110
|
+
let offset = 0;
|
|
111
|
+
let collected = '';
|
|
112
|
+
for (const child of Array.from(paragraph.childNodes || [])) {
|
|
113
|
+
if (offset >= prefixLength) break;
|
|
114
|
+
if (child.nodeType !== 1) continue;
|
|
115
|
+
const name = localName(child);
|
|
116
|
+
if (isWordElement(child, 'r')) {
|
|
117
|
+
const pieces = getRunContentPieces(child);
|
|
118
|
+
const length = getRunTextLength(pieces);
|
|
119
|
+
if (length === 0) {
|
|
120
|
+
const hasContent = Array.from(child.childNodes || []).some(node => node.nodeType === 1 && !MARKER_RUN_CHILDREN.has(localName(node)));
|
|
121
|
+
if (hasContent) return { reason: 'run-content' };
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const elementNames = Array.from(child.childNodes || []).filter(node => node.nodeType === 1).map(localName);
|
|
125
|
+
if (elementNames.some(item => !SPLITTABLE_RUN_CHILDREN.has(item))) return { reason: 'run-content' };
|
|
126
|
+
const overlap = Math.min(prefixLength, offset + length) - offset;
|
|
127
|
+
// The marker part of the run may hold only text and tabs.
|
|
128
|
+
let covered = 0;
|
|
129
|
+
for (const piece of pieces) {
|
|
130
|
+
if (covered >= overlap) break;
|
|
131
|
+
if (!isWordElement(piece.node, 't') && !isWordElement(piece.node, 'tab')) return { reason: 'run-content' };
|
|
132
|
+
covered = piece.end;
|
|
133
|
+
}
|
|
134
|
+
segments.push({ run: child, start: offset, end: offset + length, pieces });
|
|
135
|
+
collected += pieces.map(piece => piece.text).join('');
|
|
136
|
+
offset += length;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (ZERO_WIDTH_CHILDREN.has(name)) continue;
|
|
140
|
+
return { reason: name === 'ins' || name === 'del' || name === 'moveTo' || name === 'moveFrom' ? 'tracked-change' : 'container' };
|
|
141
|
+
}
|
|
142
|
+
if (offset < prefixLength || collected.slice(0, prefixLength) !== expectedPrefix) return { reason: 'text-mismatch' };
|
|
143
|
+
return { segments };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Removes the first `prefixLength` characters: a tracked `w:del` when tracking, a direct removal otherwise. */
|
|
147
|
+
function stripMarkerPrefix(xmlDoc, segments, prefixLength, { author, generateRedlines, revisionIdAllocator }) {
|
|
148
|
+
for (const segment of segments) {
|
|
149
|
+
const { run, start, end, pieces } = segment;
|
|
150
|
+
const localEnd = Math.min(prefixLength, end) - start;
|
|
151
|
+
if (localEnd <= 0) continue;
|
|
152
|
+
const parent = run.parentNode;
|
|
153
|
+
const rPr = getDirectWordChild(run, 'rPr');
|
|
154
|
+
if (generateRedlines) {
|
|
155
|
+
const deletedRun = createRunFromPieces(xmlDoc, sliceRunPieces(xmlDoc, pieces, 0, localEnd, true), rPr);
|
|
156
|
+
parent.insertBefore(createTrackChange(xmlDoc, 'del', deletedRun, author), run);
|
|
157
|
+
}
|
|
158
|
+
if (localEnd < end - start) {
|
|
159
|
+
const keptRun = createRunFromPieces(xmlDoc, sliceRunPieces(xmlDoc, pieces, localEnd, end - start, false), rPr);
|
|
160
|
+
// The deleted copy keeps any rPrChange id; the kept copy needs its own.
|
|
161
|
+
if (generateRedlines) refreshRunPropertyChangeIds(keptRun, revisionIdAllocator);
|
|
162
|
+
parent.insertBefore(keptRun, run);
|
|
163
|
+
}
|
|
164
|
+
parent.removeChild(run);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
169
|
+
// Numbering
|
|
170
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
export function numIdIsLive(xmlDoc, numId) {
|
|
173
|
+
if (numId == null) return false;
|
|
174
|
+
return getCurrentNumIdElements(xmlDoc.documentElement).some(element => readVal(element) === String(numId));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function sequenceMapOf(runtimeContext) {
|
|
178
|
+
if (!runtimeContext || typeof runtimeContext !== 'object') return null;
|
|
179
|
+
if (!runtimeContext.listFallbackSequenceState || typeof runtimeContext.listFallbackSequenceState !== 'object') {
|
|
180
|
+
runtimeContext.listFallbackSequenceState = { explicitByNumberingKey: new Map() };
|
|
181
|
+
}
|
|
182
|
+
const state = runtimeContext.listFallbackSequenceState;
|
|
183
|
+
if (!(state.explicitByNumberingKey instanceof Map)) state.explicitByNumberingKey = new Map();
|
|
184
|
+
return state.explicitByNumberingKey;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function numberingIdStateOf(runtimeContext, options) {
|
|
188
|
+
if (runtimeContext?.numberingIdState) return runtimeContext.numberingIdState;
|
|
189
|
+
const state = createDynamicNumberingIdState(runtimeContext?.numberingXml || options?.numberingXml || undefined);
|
|
190
|
+
if (runtimeContext && typeof runtimeContext === 'object') runtimeContext.numberingIdState = state;
|
|
191
|
+
return state;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
195
|
+
// Plan + apply
|
|
196
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Converts plain paragraphs (one target or a same-container range) to list items.
|
|
200
|
+
*
|
|
201
|
+
* @param {Document} xmlDoc
|
|
202
|
+
* @param {Element[]} paragraphs - the resolved target range, in document order
|
|
203
|
+
* @param {{ format: string|null, kind: string|null, level: number, strip: 'marker'|'none', start: number|null }} request
|
|
204
|
+
* @param {{
|
|
205
|
+
* author: string, generateRedlines?: boolean, revisionIdAllocator?: object|null,
|
|
206
|
+
* runtimeContext?: object|null, stylesXml?: string|null, numberingXml?: string|null,
|
|
207
|
+
* paragraphIndexOf?: (paragraph: Element) => number
|
|
208
|
+
* }} context
|
|
209
|
+
* @returns {{ ok: true, format: string, items: object[], skipped: object[], numberingXml: string|null, allocatedNumIds: number[] }
|
|
210
|
+
* | { ok: false, error: object }}
|
|
211
|
+
*/
|
|
212
|
+
export function convertPlainParagraphsToList(xmlDoc, paragraphs, request, context = {}) {
|
|
213
|
+
const generateRedlines = context.generateRedlines !== false;
|
|
214
|
+
const author = context.author;
|
|
215
|
+
const indexOf = typeof context.paragraphIndexOf === 'function' ? context.paragraphIndexOf : () => null;
|
|
216
|
+
const stylesDoc = parseStyles(context.stylesXml);
|
|
217
|
+
const strip = request.strip === 'none' ? 'none' : 'marker';
|
|
218
|
+
|
|
219
|
+
// 1. Classify every paragraph without mutating anything.
|
|
220
|
+
const entries = [];
|
|
221
|
+
const skipped = [];
|
|
222
|
+
for (const paragraph of paragraphs) {
|
|
223
|
+
const paragraphIndex = indexOf(paragraph);
|
|
224
|
+
const text = extractCanonicalParagraphText(paragraph, { revisionView: 'accepted' });
|
|
225
|
+
const listInfo = getParagraphListInfo(paragraph);
|
|
226
|
+
if (listInfo) {
|
|
227
|
+
return refusal(LIST_FORMAT_TARGET_IS_LIST,
|
|
228
|
+
`Paragraph ${paragraphIndex ?? ''} is already a list item (numId ${listInfo.numId}, level ${listInfo.ilvl}); convert-plain converts plain paragraphs only.`.replace(' ', ' '),
|
|
229
|
+
{ paragraphIndex, numId: listInfo.numId, level: listInfo.ilvl, targetExcerpt: excerpt(text) },
|
|
230
|
+
{ action: 'use_paragraph_numbering', hint: 'Narrow the range to plain paragraphs. To move a list item to another existing list or level, use paragraph-format with properties.numbering.' });
|
|
231
|
+
}
|
|
232
|
+
const current = getCurrentParagraphProperties(paragraph);
|
|
233
|
+
const suppressed = current.numPr?.numId === '0';
|
|
234
|
+
const styled = suppressed ? null : styleNumbering(stylesDoc, current.pStyle);
|
|
235
|
+
if (styled) {
|
|
236
|
+
return refusal(LIST_FORMAT_STYLE_NUMBERED,
|
|
237
|
+
`Paragraph ${paragraphIndex ?? ''} is numbered by its style "${styled.styleId}" (numId ${styled.numId}); it is already a list item through the style.`.replace(' ', ' '),
|
|
238
|
+
{ paragraphIndex, styleId: styled.styleId, numId: styled.numId, targetExcerpt: excerpt(text) },
|
|
239
|
+
{ action: 'convert_in_host', hint: 'Style-linked numbering is not rebound by list-format-v1. Change the paragraph style or the style\'s numbering in the host.' });
|
|
240
|
+
}
|
|
241
|
+
if (!text.trim()) {
|
|
242
|
+
skipped.push({ paragraph, paragraphIndex, reason: 'empty' });
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (strip === 'none') {
|
|
246
|
+
entries.push({ paragraph, paragraphIndex, text, parsed: null });
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const parsed = parseTypedListMarker(text);
|
|
250
|
+
if (parsed?.unsupported) {
|
|
251
|
+
const detail = {
|
|
252
|
+
multilevel: 'multi-level markers such as "2.1." need a multi-level list definition',
|
|
253
|
+
parenthesized: 'parenthesized markers such as "(a)" are not supported',
|
|
254
|
+
zero: 'a marker value of 0 cannot start a Word list'
|
|
255
|
+
}[parsed.unsupported];
|
|
256
|
+
return refusal(LIST_MARKER_UNSUPPORTED,
|
|
257
|
+
`The typed marker "${parsed.marker}" is not supported by list-format-v1: ${detail}.`,
|
|
258
|
+
{ reason: parsed.unsupported, marker: parsed.marker, paragraphIndex, targetExcerpt: excerpt(text) },
|
|
259
|
+
{ action: 'convert_in_host', hint: 'Convert this paragraph in the host, or leave the marker as literal text.' });
|
|
260
|
+
}
|
|
261
|
+
if (!parsed) {
|
|
262
|
+
// A non-list paragraph between converted headers stays body text; the list continues across it.
|
|
263
|
+
skipped.push({ paragraph, paragraphIndex, reason: 'no-marker' });
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
entries.push({ paragraph, paragraphIndex, text, parsed });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (entries.length === 0) {
|
|
270
|
+
return refusal(LIST_MARKER_NOT_FOUND,
|
|
271
|
+
strip === 'marker'
|
|
272
|
+
? 'No target paragraph starts with a typed list marker (for example "A. ", "1. ", "iv. " or "- "), so there is nothing to strip.'
|
|
273
|
+
: 'The target range has no non-empty paragraph to convert.',
|
|
274
|
+
{ targetExcerpt: excerpt(skipped[0] ? extractCanonicalParagraphText(skipped[0].paragraph) : '') },
|
|
275
|
+
{ action: 'change_request', hint: 'Target the paragraphs that carry the typed markers, or pass strip: "none" with list.format to bind paragraphs whose text has no marker.', field: 'list.strip', allowedValue: 'none' });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// 2. One format for the whole run.
|
|
279
|
+
let format = request.format;
|
|
280
|
+
if (strip === 'marker') {
|
|
281
|
+
const resolved = resolveMarkerFormat(entries.map(entry => entry.parsed), request.format);
|
|
282
|
+
if (resolved.error) {
|
|
283
|
+
const { reason } = resolved.error;
|
|
284
|
+
if (reason === 'ambiguous') {
|
|
285
|
+
return refusal(LIST_MARKER_AMBIGUOUS,
|
|
286
|
+
`The typed marker "${resolved.error.marker}" reads as ${resolved.error.formats.join(' or ')}; list-format will not guess.`,
|
|
287
|
+
{ marker: resolved.error.marker, formats: resolved.error.formats },
|
|
288
|
+
{ action: 'set_list_format', hint: `Pass list.format (${resolved.error.formats.join(' or ')}).`, field: 'list.format', allowedValues: resolved.error.formats });
|
|
289
|
+
}
|
|
290
|
+
const message = reason === 'format-mismatch'
|
|
291
|
+
? `The typed marker "${resolved.error.marker}" is not a ${resolved.error.requestedFormat} marker.`
|
|
292
|
+
: `The target range mixes list markers (${[...new Set(resolved.error.markers)].join(', ')}); one convert-plain operation converts one list format.`;
|
|
293
|
+
return refusal(LIST_MARKER_UNSUPPORTED, message,
|
|
294
|
+
{ reason, ...(resolved.error.marker ? { marker: resolved.error.marker } : {}), ...(resolved.error.markers ? { markers: resolved.error.markers } : {}) },
|
|
295
|
+
{ action: 'change_request', hint: reason === 'format-mismatch' ? 'Omit list.format to use the typed marker, or correct it.' : 'Split the range into one operation per marker format.' });
|
|
296
|
+
}
|
|
297
|
+
format = resolved.format;
|
|
298
|
+
}
|
|
299
|
+
if (request.kind && (request.kind === 'bullet') !== (format === 'bullet')) {
|
|
300
|
+
return refusal(LIST_MARKER_UNSUPPORTED, `The typed markers are ${format} markers, not a ${request.kind} list.`,
|
|
301
|
+
{ reason: 'format-mismatch', requestedKind: request.kind },
|
|
302
|
+
{ action: 'change_request', hint: 'Omit list.kind, or target paragraphs whose markers match it.' });
|
|
303
|
+
}
|
|
304
|
+
const terminator = strip === 'marker' ? entries[0].parsed.terminator : '.';
|
|
305
|
+
const bulletChar = strip === 'marker' ? (entries[0].parsed.bulletChar || null) : null;
|
|
306
|
+
const level = request.level || 0;
|
|
307
|
+
|
|
308
|
+
// 3. Paragraph-property snapshots and marker runs.
|
|
309
|
+
for (const entry of entries) {
|
|
310
|
+
const pendingChange = getCurrentChild(getDirectWordChild(entry.paragraph, 'pPr'), 'pPrChange');
|
|
311
|
+
const pendingAuthor = pendingChange?.getAttribute('w:author') ?? pendingChange?.getAttribute('author') ?? '';
|
|
312
|
+
if (generateRedlines && pendingChange && pendingAuthor !== author) {
|
|
313
|
+
return refusal(PARAGRAPH_FORMAT_REVISION_CONFLICT,
|
|
314
|
+
`Paragraph ${entry.paragraphIndex ?? ''} already has a pending paragraph-property change by "${pendingAuthor || 'unattributed'}". Overwriting its snapshot would change what Reject All restores.`.replace(' ', ' '),
|
|
315
|
+
{ pendingAuthor, paragraphIndex: entry.paragraphIndex },
|
|
316
|
+
{ action: 'resolve_pending_revision', hint: 'Ask the user to accept or reject the existing paragraph formatting change first; this tool does not resolve revisions by other reviewers.' });
|
|
317
|
+
}
|
|
318
|
+
entry.keepSnapshot = !!pendingChange;
|
|
319
|
+
if (strip === 'marker') {
|
|
320
|
+
const located = locateMarkerRuns(entry.paragraph, entry.parsed.prefixLength, entry.text.slice(0, entry.parsed.prefixLength));
|
|
321
|
+
if (located.reason) {
|
|
322
|
+
return refusal(LIST_MARKER_UNSUPPORTED,
|
|
323
|
+
`The typed marker "${entry.parsed.marker}" is not plain run text (${located.reason}), so it cannot be stripped as one tracked deletion.`,
|
|
324
|
+
{ reason: 'marker-shape', shape: located.reason, marker: entry.parsed.marker, paragraphIndex: entry.paragraphIndex, targetExcerpt: excerpt(entry.text) },
|
|
325
|
+
{ action: 'convert_in_host', hint: 'The marker sits inside a hyperlink, field, content control or pending revision. Resolve that markup first or convert the paragraph in the host.' });
|
|
326
|
+
}
|
|
327
|
+
entry.segments = located.segments;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// 4. Values and list groups. A value that does not continue the previous one starts a new list, so every
|
|
332
|
+
// label Word renders after Accept All is the label the paragraph showed as text.
|
|
333
|
+
const sequence = sequenceMapOf(context.runtimeContext);
|
|
334
|
+
const sequenceKey = listFormatSequenceKey({ format, level, terminator, bulletChar });
|
|
335
|
+
const previous = sequence?.get(sequenceKey) || null;
|
|
336
|
+
const previousLive = previous && numIdIsLive(xmlDoc, previous.numId);
|
|
337
|
+
let nextValue = strip === 'none'
|
|
338
|
+
? (request.start ?? (previousLive && Number.isInteger(previous.nextStartAt) ? previous.nextStartAt : 1))
|
|
339
|
+
: null;
|
|
340
|
+
for (const entry of entries) {
|
|
341
|
+
if (format === 'bullet') entry.value = null;
|
|
342
|
+
else if (strip === 'marker') entry.value = markerValue(entry.parsed, format);
|
|
343
|
+
else entry.value = nextValue++;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const numberingIdState = numberingIdStateOf(context.runtimeContext, context);
|
|
347
|
+
const spec = listLevelSpec({ format, level, terminator, bulletChar });
|
|
348
|
+
const parts = [];
|
|
349
|
+
const allocatedNumIds = [];
|
|
350
|
+
let groupNumId = null;
|
|
351
|
+
let lastValue = null;
|
|
352
|
+
for (const [index, entry] of entries.entries()) {
|
|
353
|
+
const continues = index > 0 && (format === 'bullet' || entry.value === lastValue + 1);
|
|
354
|
+
if (!continues) {
|
|
355
|
+
const reusePrevious = index === 0 && previousLive
|
|
356
|
+
&& (format === 'bullet' || previous.nextStartAt === entry.value);
|
|
357
|
+
if (reusePrevious) {
|
|
358
|
+
groupNumId = String(previous.numId);
|
|
359
|
+
} else {
|
|
360
|
+
const allocated = allocateNumberingDefinition({
|
|
361
|
+
numberingIdState,
|
|
362
|
+
levels: [spec],
|
|
363
|
+
startOverrides: entry.value != null && entry.value !== 1 ? { [level]: entry.value } : undefined
|
|
364
|
+
});
|
|
365
|
+
if (!allocated) {
|
|
366
|
+
return refusal('NUMBERING_ID_EXHAUSTED', 'No numbering ids are available for a new list definition.', {},
|
|
367
|
+
{ action: 'report_library_failure' });
|
|
368
|
+
}
|
|
369
|
+
groupNumId = String(allocated.numId);
|
|
370
|
+
allocatedNumIds.push(allocated.numId);
|
|
371
|
+
parts.push(allocated.numberingXml);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
entry.numId = groupNumId;
|
|
375
|
+
lastValue = entry.value;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// 5. Mutate.
|
|
379
|
+
for (const entry of entries) {
|
|
380
|
+
let pPr = getDirectWordChild(entry.paragraph, 'pPr');
|
|
381
|
+
if (!pPr) {
|
|
382
|
+
pPr = createWordElement(xmlDoc, 'w:pPr');
|
|
383
|
+
entry.paragraph.insertBefore(pPr, entry.paragraph.firstChild);
|
|
384
|
+
}
|
|
385
|
+
// A same-author pending snapshot already records the earliest "before" state; keep it.
|
|
386
|
+
if (generateRedlines && !entry.keepSnapshot) snapshotAndAttachPPrChange(xmlDoc, pPr, author);
|
|
387
|
+
applyParagraphPropertiesToPPr(xmlDoc, pPr, { numbering: { numId: entry.numId, level } });
|
|
388
|
+
if (strip === 'marker') {
|
|
389
|
+
stripMarkerPrefix(xmlDoc, entry.segments, entry.parsed.prefixLength, {
|
|
390
|
+
author,
|
|
391
|
+
generateRedlines,
|
|
392
|
+
revisionIdAllocator: context.revisionIdAllocator || null
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const last = entries[entries.length - 1];
|
|
398
|
+
sequence?.set(sequenceKey, { numId: last.numId, nextStartAt: last.value != null ? last.value + 1 : null });
|
|
399
|
+
|
|
400
|
+
return {
|
|
401
|
+
ok: true,
|
|
402
|
+
format,
|
|
403
|
+
level,
|
|
404
|
+
strip,
|
|
405
|
+
items: entries.map(entry => ({
|
|
406
|
+
paragraph: entry.paragraph,
|
|
407
|
+
paragraphIndex: entry.paragraphIndex,
|
|
408
|
+
marker: entry.parsed?.marker ?? null,
|
|
409
|
+
value: entry.value,
|
|
410
|
+
numId: entry.numId
|
|
411
|
+
})),
|
|
412
|
+
skipped,
|
|
413
|
+
numberingXml: parts.length > 0 ? mergeNumberingParts('', parts) : null,
|
|
414
|
+
allocatedNumIds
|
|
415
|
+
};
|
|
416
|
+
}
|