@ansonlai/docx-redline-js 0.8.4 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +679 -460
- package/CHANGELOG.md +208 -1
- package/README.md +152 -16
- 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/core/word-xml.js +160 -118
- package/dist/docx-redline-js.esm.js +4338 -796
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +111 -108
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +4463 -921
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +4463 -921
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_FAST_START.md +2 -0
- package/docs/AGENT_KNOWLEDGE_BASE.md +49 -7
- package/docs/SKILL_AUTHORING.md +169 -150
- package/docs/TESTING.md +1146 -1059
- package/docs/schemas/document-operations.schema.json +55 -2
- package/document/docx-document.js +1022 -1010
- package/engine/oxml-engine.js +21 -22
- package/engine/paragraph-mark-revisions.js +292 -0
- package/engine/reconstruction-mapper.js +312 -311
- 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 +53 -2
- package/index.js +13 -2
- package/node/cli-help.js +1 -0
- package/node/cli.js +21 -3
- 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/markdown-processor.js +5 -2
- package/pipeline/pipeline.js +206 -204
- 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 +23 -3
- package/services/capture-engine.js +228 -226
- package/services/comment-replies.js +1 -1
- package/services/document-operation-applier.js +214 -0
- package/services/document-operation-contract.js +76 -0
- package/services/document-operation-mutations.js +688 -51
- package/services/error-recovery.js +17 -0
- package/services/inspection-comparison.js +82 -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 +183 -11
- package/services/operation-preflight.js +43 -33
- package/services/receipt-collector.js +288 -288
- package/services/revision-comment-management.js +38 -5
- package/services/revision-fidelity-verifier.js +484 -0
- package/services/standalone-docx-plumbing.js +627 -631
- package/services/standalone-operation-runner.d.ts +182 -5
- package/services/table-reconciliation.js +59 -7
|
@@ -33,6 +33,21 @@ const RULES = Object.freeze({
|
|
|
33
33
|
PATCH_ROUNDTRIP_MISMATCH: ['patch-validation', 'request_fixable', 'reinspect_and_narrow'],
|
|
34
34
|
DIFF_TOKEN_LIMIT: ['patch-compilation', 'request_fixable', 'narrow_operation'],
|
|
35
35
|
STRUCTURED_CONTENT_INVALID: ['request', 'request_fixable', 'change_request'],
|
|
36
|
+
UNSUPPORTED_LIST_CONVERSION: ['list-conversion', 'request_fixable', 'use_list_format_convert_plain'],
|
|
37
|
+
UNSUPPORTED_LIST_FORMAT_CHANGE: ['list-conversion', 'request_fixable', 'use_list_format_change_format'],
|
|
38
|
+
LIST_MARKER_NOT_FOUND: ['list-conversion', 'request_fixable', 'change_request'],
|
|
39
|
+
LIST_MARKER_AMBIGUOUS: ['list-conversion', 'request_fixable', 'set_list_format'],
|
|
40
|
+
LIST_MARKER_UNSUPPORTED: ['list-conversion', 'request_fixable', 'convert_in_host'],
|
|
41
|
+
LIST_FORMAT_TARGET_IS_LIST: ['list-conversion', 'request_fixable', 'use_paragraph_numbering'],
|
|
42
|
+
LIST_FORMAT_STYLE_NUMBERED: ['list-conversion', 'request_fixable', 'convert_in_host'],
|
|
43
|
+
LIST_FORMAT_TARGET_NOT_LIST: ['list-conversion', 'request_fixable', 'use_list_format_convert_plain'],
|
|
44
|
+
LIST_FORMAT_MIXED_RANGE: ['list-conversion', 'request_fixable', 'narrow_range'],
|
|
45
|
+
LIST_FORMAT_STYLE_LINKED: ['list-conversion', 'request_fixable', 'convert_in_host'],
|
|
46
|
+
LIST_FORMAT_SPLIT_UNSUPPORTED: ['list-conversion', 'request_fixable', 'extend_range_or_use_list_scope'],
|
|
47
|
+
LIST_FORMAT_DEFINITION_UNSUPPORTED: ['list-conversion', 'request_fixable', 'convert_in_host'],
|
|
48
|
+
LIST_FORMAT_JOIN_INVALID: ['list-conversion', 'request_fixable', 'change_request'],
|
|
49
|
+
PARAGRAPH_NUMBERING_NOT_FOUND: ['target-resolution', 'target_refresh_required', 'inspect_numbering'],
|
|
50
|
+
PARAGRAPH_FORMAT_REVISION_CONFLICT: ['target-safety', 'user_authorization_required', 'resolve_pending_revision'],
|
|
36
51
|
UNSUPPORTED_INSERTION_AFFINITY: ['request', 'request_fixable', 'change_request'],
|
|
37
52
|
EXISTING_REVISIONS: ['target-safety', 'policy_choice_required', 'set_option'],
|
|
38
53
|
PART_NOT_FOUND: ['target-resolution', 'target_refresh_required', 'reinspect'],
|
|
@@ -46,6 +61,7 @@ const RULES = Object.freeze({
|
|
|
46
61
|
OVERLAPPING_SOURCE_TARGETS: ['batch-compilation', 'source_conflict', 'consolidate_operations'],
|
|
47
62
|
OVERLAPPING_TEXT_EDITS: ['batch-compilation', 'source_conflict', 'consolidate_operations'],
|
|
48
63
|
REVISION_ORDER_CONFLICT: ['batch-compilation', 'source_conflict', 'split_or_consolidate_operations'],
|
|
64
|
+
LIST_FORMAT_COMPOSITION_CONFLICT: ['batch-compilation', 'source_conflict', 'split_text_and_list_changes'],
|
|
49
65
|
TARGET_CONSUMED_BY_OPERATION: ['batch-execution', 'source_conflict', 'use_created_content_dependency'],
|
|
50
66
|
CAPTURE_FANOUT_CONFLICT: ['batch-compilation', 'source_conflict', 'split_or_chain_capture_consumers'],
|
|
51
67
|
DUPLICATE_CAPTURE_KEY: ['batch-compilation', 'request_fixable', 'rename_capture'],
|
|
@@ -54,6 +70,7 @@ const RULES = Object.freeze({
|
|
|
54
70
|
AMBIGUOUS_CAPTURE_SELECTION: ['batch-compilation', 'candidate_selection_required', 'choose_candidate'],
|
|
55
71
|
CAPTURE_STALE: ['batch-execution', 'source_conflict', 'replan_batch'],
|
|
56
72
|
GENERATED_OOXML_INVALID: ['validation', 'library_or_builder_failure', 'report_library_failure'],
|
|
73
|
+
REVISION_FIDELITY_VIOLATION: ['verification', 'library_or_builder_failure', 'narrow_or_change_operation'],
|
|
57
74
|
DOCUMENT_SERIALIZATION_FAILED: ['serialization', 'library_or_builder_failure', 'report_library_failure'],
|
|
58
75
|
PACKAGE_OPERATION_FAILED: ['package', 'library_or_builder_failure', 'report_library_failure'],
|
|
59
76
|
PACKAGE_VALIDATION: ['package-validation', 'library_or_builder_failure', 'report_library_failure'],
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compares two `inspectDocumentParts` results by what a reader and reviewer see:
|
|
3
|
+
* paragraph text, table position, list label, review segments and comments.
|
|
4
|
+
*
|
|
5
|
+
* A host that writes a prepared package uses this to verify what it committed
|
|
6
|
+
* against what was prepared. Revision ids, dates, rsids, paragraph ids and
|
|
7
|
+
* run boundaries are ignored, because hosts regenerate them on write.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const commentIdentity = comment => [comment.author ?? null, comment.text ?? '', comment.anchoredText || ''];
|
|
11
|
+
|
|
12
|
+
// Adjacent review segments of one kind and author are one change, however the
|
|
13
|
+
// host split the runs.
|
|
14
|
+
function reviewSegments(segments = []) {
|
|
15
|
+
return segments.filter(segment => segment.kind !== 'baseline').reduce((result, segment) => {
|
|
16
|
+
const next = {
|
|
17
|
+
kind: segment.kind, author: segment.author || null, text: segment.text,
|
|
18
|
+
acceptedStart: segment.acceptedStart ?? null, rejectedStart: segment.rejectedStart ?? null
|
|
19
|
+
};
|
|
20
|
+
const previous = result.at(-1);
|
|
21
|
+
const adjacent = key => (previous[key] == null && next[key] == null)
|
|
22
|
+
|| (previous[key] != null && previous[key] + previous.text.length === next[key]);
|
|
23
|
+
if (previous && previous.kind === next.kind && previous.author === next.author
|
|
24
|
+
&& adjacent('acceptedStart') && adjacent('rejectedStart')) previous.text += next.text;
|
|
25
|
+
else result.push(next);
|
|
26
|
+
return result;
|
|
27
|
+
}, []);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The host-independent shape of one inspection.
|
|
32
|
+
* @param {{ paragraphs: Array<object>, comments?: Array<object> }} inspection
|
|
33
|
+
*/
|
|
34
|
+
export function inspectionReviewShape(inspection) {
|
|
35
|
+
const comments = inspection?.comments || [];
|
|
36
|
+
return {
|
|
37
|
+
paragraphs: (inspection?.paragraphs || []).map(paragraph => ({
|
|
38
|
+
text: paragraph.exactText,
|
|
39
|
+
inTable: paragraph.inTable === true,
|
|
40
|
+
table: paragraph.table ? [paragraph.table.rowIndex, paragraph.table.cellIndex] : null,
|
|
41
|
+
list: paragraph.list ? [paragraph.list.format, paragraph.list.level, paragraph.list.label] : null,
|
|
42
|
+
review: {
|
|
43
|
+
present: paragraph.hasRevisions === true,
|
|
44
|
+
authors: [...(paragraph.revisionAuthors || [])].sort(),
|
|
45
|
+
segments: reviewSegments(paragraph.segments)
|
|
46
|
+
}
|
|
47
|
+
})),
|
|
48
|
+
comments: comments.map(comment => {
|
|
49
|
+
const parent = comment.parentCommentId != null
|
|
50
|
+
? comments.find(item => String(item.id) === String(comment.parentCommentId))
|
|
51
|
+
: null;
|
|
52
|
+
return [...commentIdentity(comment), comment.done === true,
|
|
53
|
+
parent ? commentIdentity(parent)
|
|
54
|
+
: (comment.parentCommentId != null || comment.parentParaId ? 'unresolved parent' : null)];
|
|
55
|
+
}).map(item => JSON.stringify(item)).sort()
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {{ paragraphs: Array<object>, comments?: Array<object> }} expected - e.g. inspection of the prepared package
|
|
61
|
+
* @param {{ paragraphs: Array<object>, comments?: Array<object> }} actual - e.g. inspection of what the host committed
|
|
62
|
+
* @returns {{ matches: boolean, differences: Array<{ path: string, expected: any, actual: any }> }}
|
|
63
|
+
* `differences` lists at most the first ten mismatches.
|
|
64
|
+
*/
|
|
65
|
+
export function compareDocumentInspections(expected, actual) {
|
|
66
|
+
const left = inspectionReviewShape(expected);
|
|
67
|
+
const right = inspectionReviewShape(actual);
|
|
68
|
+
const differences = [];
|
|
69
|
+
const note = (path, a, b) => {
|
|
70
|
+
if (differences.length < 10 && JSON.stringify(a) !== JSON.stringify(b)) differences.push({ path, expected: a, actual: b });
|
|
71
|
+
};
|
|
72
|
+
note('paragraphs.length', left.paragraphs.length, right.paragraphs.length);
|
|
73
|
+
const count = Math.max(left.paragraphs.length, right.paragraphs.length);
|
|
74
|
+
for (let index = 0; index < count; index++) {
|
|
75
|
+
const a = left.paragraphs[index] ?? null;
|
|
76
|
+
const b = right.paragraphs[index] ?? null;
|
|
77
|
+
if (!a || !b) { note(`paragraphs[${index}]`, a, b); continue; }
|
|
78
|
+
for (const key of ['text', 'inTable', 'table', 'list', 'review']) note(`paragraphs[${index}].${key}`, a[key], b[key]);
|
|
79
|
+
}
|
|
80
|
+
note('comments', left.comments, right.comments);
|
|
81
|
+
return { matches: differences.length === 0, differences };
|
|
82
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interim list-conversion safety diagnostics (canonical list operations plan, WP-1).
|
|
3
|
+
*
|
|
4
|
+
* These detect requests that the text-reconciliation path cannot honour as list structure and
|
|
5
|
+
* report them instead of returning a silent `ok`:
|
|
6
|
+
* - `LIST_MARKER_NOT_BOUND` / `UNSUPPORTED_LIST_CONVERSION`: a plain paragraph was edited so its
|
|
7
|
+
* text starts with a list marker, but the marker was written as literal text (no `w:numPr`).
|
|
8
|
+
* - `LIST_FORMAT_NOT_APPLIED` / `UNSUPPORTED_LIST_FORMAT_CHANGE`: a list item was asked to change
|
|
9
|
+
* kind or numbering format, but the paragraph keeps its source numbering definition.
|
|
10
|
+
* - `UNKNOWN_PARAGRAPH_FORMAT_PROPERTY`: a `paragraph-format` key that is not implemented.
|
|
11
|
+
*
|
|
12
|
+
* They are independent of the Reject All verifier (`REVISION_FIDELITY_VIOLATION`). The pure
|
|
13
|
+
* detectors below take plain data; policy resolution decides warning versus error.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
17
|
+
import { parseListItem } from '../pipeline/list-markers.js';
|
|
18
|
+
import { parseTypedListMarker } from './list-format.js';
|
|
19
|
+
|
|
20
|
+
export const LIST_MARKER_NOT_BOUND = 'LIST_MARKER_NOT_BOUND';
|
|
21
|
+
export const UNSUPPORTED_LIST_CONVERSION = 'UNSUPPORTED_LIST_CONVERSION';
|
|
22
|
+
export const LIST_FORMAT_NOT_APPLIED = 'LIST_FORMAT_NOT_APPLIED';
|
|
23
|
+
export const UNSUPPORTED_LIST_FORMAT_CHANGE = 'UNSUPPORTED_LIST_FORMAT_CHANGE';
|
|
24
|
+
export const UNKNOWN_PARAGRAPH_FORMAT_PROPERTY = 'UNKNOWN_PARAGRAPH_FORMAT_PROPERTY';
|
|
25
|
+
export const INVALID_PARAGRAPH_NUMBERING = 'INVALID_PARAGRAPH_NUMBERING';
|
|
26
|
+
export const PARAGRAPH_NUMBERING_NOT_FOUND = 'PARAGRAPH_NUMBERING_NOT_FOUND';
|
|
27
|
+
export const PARAGRAPH_FORMAT_REVISION_CONFLICT = 'PARAGRAPH_FORMAT_REVISION_CONFLICT';
|
|
28
|
+
|
|
29
|
+
/** Keys `paragraph-format.properties` implements today (closed set). */
|
|
30
|
+
export const PARAGRAPH_FORMAT_PROPERTY_KEYS = Object.freeze([
|
|
31
|
+
'alignment', 'keepNext', 'keepLines', 'pageBreakBefore', 'style', 'numbering'
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const NUMBERING_STYLE_TO_NUM_FMT = Object.freeze({
|
|
35
|
+
bullet: 'bullet',
|
|
36
|
+
decimal: 'decimal',
|
|
37
|
+
lowerAlpha: 'lowerLetter',
|
|
38
|
+
upperAlpha: 'upperLetter',
|
|
39
|
+
lowerRoman: 'lowerRoman',
|
|
40
|
+
upperRoman: 'upperRoman'
|
|
41
|
+
});
|
|
42
|
+
const COMPARABLE_NUM_FMTS = new Set(['bullet', 'decimal', 'lowerLetter', 'upperLetter', 'lowerRoman', 'upperRoman']);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `listMarkers: 'literal'` is the only accepted value; it suppresses these diagnostics for
|
|
46
|
+
* callers that intentionally want literal marker text.
|
|
47
|
+
*/
|
|
48
|
+
export function isLiteralListMarkerOptOut(value) {
|
|
49
|
+
return value === 'literal';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolves diagnostic policy: `off` (opt-out), `error` (strict profile) or `warn` (default).
|
|
54
|
+
* Strict is requested explicitly with `strictListConversion` (the CLI agent profile sets it) or
|
|
55
|
+
* implied by an atomic batch.
|
|
56
|
+
*/
|
|
57
|
+
export function resolveListDiagnosticPolicy(options = {}) {
|
|
58
|
+
if (isLiteralListMarkerOptOut(options.listMarkers)) return 'off';
|
|
59
|
+
const strict = options.strictListConversion !== undefined
|
|
60
|
+
? options.strictListConversion === true
|
|
61
|
+
: options.atomic === true;
|
|
62
|
+
return strict ? 'error' : 'warn';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function excerpt(text, limit = 80) {
|
|
66
|
+
const value = String(text ?? '');
|
|
67
|
+
return value.length > limit ? `${value.slice(0, limit)}...` : value;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Parses single-line text as one list marker plus content, or `null`. */
|
|
71
|
+
export function parseLiteralListMarker(text) {
|
|
72
|
+
const raw = String(text ?? '');
|
|
73
|
+
if (!raw.trim() || /[\r\n]/.test(raw)) return null;
|
|
74
|
+
const parsed = parseListItem(raw, { allowZeroSpaceAfterMarker: false, indentSpaces: 2 });
|
|
75
|
+
if (!parsed || !parsed.marker) return null;
|
|
76
|
+
// A bare number or number-dot-number ("7 days", "2.1 Scope") is far more often prose than a list
|
|
77
|
+
// marker; require an explicit terminator for numbered markers so the diagnostics stay low-noise.
|
|
78
|
+
if (parsed.markerType !== 'bullet' && !/[.)]$/.test(parsed.marker)) return null;
|
|
79
|
+
return {
|
|
80
|
+
marker: parsed.marker,
|
|
81
|
+
kind: parsed.markerType === 'bullet' ? 'bullet' : 'numbered',
|
|
82
|
+
numberingStyle: parsed.numberingStyle,
|
|
83
|
+
numFmt: NUMBERING_STYLE_TO_NUM_FMT[parsed.numberingStyle] || 'decimal',
|
|
84
|
+
text: String(parsed.text || '').trim()
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Detects a plain (non-list) paragraph whose edited text starts a list marker the original
|
|
90
|
+
* did not carry. Returns the requested marker, or `null` when there is nothing to report.
|
|
91
|
+
*/
|
|
92
|
+
export function detectUnboundListMarker({ originalText, modifiedText, targetIsListItem }) {
|
|
93
|
+
if (targetIsListItem) return null;
|
|
94
|
+
const requested = parseLiteralListMarker(modifiedText);
|
|
95
|
+
if (!requested) return null;
|
|
96
|
+
const current = parseLiteralListMarker(originalText);
|
|
97
|
+
if (current && current.kind === requested.kind && current.numFmt === requested.numFmt) return null;
|
|
98
|
+
return requested;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The `list-format` operation that performs the conversion a text edit could not: bind the paragraph as a list
|
|
103
|
+
* item in the requested format starting at the typed value, leaving the (unmarked) text alone. Only offered when
|
|
104
|
+
* the original paragraph has no marker of its own, because `strip: "none"` would keep that marker as text.
|
|
105
|
+
*/
|
|
106
|
+
function suggestedListFormatOperation(requested, originalHasMarker) {
|
|
107
|
+
if (originalHasMarker) return null;
|
|
108
|
+
const format = requested.numFmt === 'bullet' ? 'bullet' : requested.numFmt;
|
|
109
|
+
const list = { mode: 'convert-plain', format, strip: 'none' };
|
|
110
|
+
if (format !== 'bullet') {
|
|
111
|
+
const parsed = parseTypedListMarker(`${requested.marker} x`);
|
|
112
|
+
const value = parsed?.candidates?.find(candidate => candidate.format === format)?.value;
|
|
113
|
+
if (Number.isInteger(value) && value > 1) list.start = value;
|
|
114
|
+
}
|
|
115
|
+
return { type: 'list-format', list };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Builds the structured diagnostic (warning or error payload) for an unbound marker. */
|
|
119
|
+
export function buildUnboundListMarkerDiagnostic(requested, { policy, targetText, operationIndex } = {}) {
|
|
120
|
+
const isError = policy === 'error';
|
|
121
|
+
const code = isError ? UNSUPPORTED_LIST_CONVERSION : LIST_MARKER_NOT_BOUND;
|
|
122
|
+
const message = isError
|
|
123
|
+
? `Converting a plain paragraph to a list item is not supported by text edits: the new text begins with the list marker "${requested.marker}", which would be written as literal text with no list binding. Use a list-format convert-plain operation on the paragraph (capability list-format-v1), or pass listMarkers: "literal" to keep the marker as text.`
|
|
124
|
+
: `Modified text begins with the list marker "${requested.marker}", but the marker was written as literal text; no list binding was created. A list-format convert-plain operation creates the list binding.`;
|
|
125
|
+
const suggestedOperation = suggestedListFormatOperation(requested, !!parseLiteralListMarker(targetText));
|
|
126
|
+
return {
|
|
127
|
+
code,
|
|
128
|
+
severity: isError ? 'error' : 'warning',
|
|
129
|
+
message,
|
|
130
|
+
listMarker: requested.marker,
|
|
131
|
+
requestedList: { kind: requested.kind, format: requested.numFmt },
|
|
132
|
+
...(targetText ? { targetExcerpt: excerpt(targetText) } : {}),
|
|
133
|
+
...(Number.isInteger(operationIndex) ? { operationIndex } : {}),
|
|
134
|
+
recovery: {
|
|
135
|
+
action: 'use_list_format_convert_plain',
|
|
136
|
+
hint: suggestedOperation
|
|
137
|
+
? 'Keep the paragraph text as it is and send a list-format operation on the same target (recovery.suggestedOperation plus your target), or pass listMarkers: "literal" to write the marker as text intentionally.'
|
|
138
|
+
: 'Edit the text with a redline that starts with the typed marker, then convert it with a list-format convert-plain operation (strip: "marker"), or pass listMarkers: "literal" to write the marker as text intentionally.',
|
|
139
|
+
...(suggestedOperation ? { suggestedOperation } : {}),
|
|
140
|
+
field: 'listMarkers',
|
|
141
|
+
allowedValue: 'literal'
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function levelDefinition(numberingDoc, numId, ilvl) {
|
|
147
|
+
const byTag = tag => Array.from(numberingDoc.getElementsByTagNameNS('*', tag));
|
|
148
|
+
const attr = (node, name) => node?.getAttribute?.(`w:${name}`) ?? node?.getAttribute?.(name) ?? null;
|
|
149
|
+
const child = (node, tag) => Array.from(node?.childNodes || []).find(c => c.nodeType === 1 && c.localName === tag) || null;
|
|
150
|
+
const num = byTag('num').find(node => String(attr(node, 'numId')) === String(numId));
|
|
151
|
+
if (!num) return null;
|
|
152
|
+
const override = Array.from(num.childNodes).filter(c => c.nodeType === 1 && c.localName === 'lvlOverride')
|
|
153
|
+
.find(c => Number(attr(c, 'ilvl') || 0) === ilvl);
|
|
154
|
+
const overrideFmt = attr(child(child(override, 'lvl'), 'numFmt'), 'val');
|
|
155
|
+
if (overrideFmt) return { numFmt: overrideFmt };
|
|
156
|
+
const abstractId = attr(child(num, 'abstractNumId'), 'val');
|
|
157
|
+
const abstract = byTag('abstractNum').find(node => String(attr(node, 'abstractNumId')) === String(abstractId));
|
|
158
|
+
if (!abstract) return null;
|
|
159
|
+
const lvl = Array.from(abstract.childNodes).filter(c => c.nodeType === 1 && c.localName === 'lvl')
|
|
160
|
+
.find(c => Number(attr(c, 'ilvl') || 0) === ilvl);
|
|
161
|
+
if (!lvl) return null;
|
|
162
|
+
return { numFmt: attr(child(lvl, 'numFmt'), 'val') || 'decimal' };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Reads `{ numFmt, kind }` of a numbering level from the source numbering part, or `null`. */
|
|
166
|
+
export function readSourceListLevel(sourceNumberingXml, numId, ilvl = 0) {
|
|
167
|
+
if (!String(sourceNumberingXml || '').trim() || numId == null) return null;
|
|
168
|
+
const parsed = parseOoxmlSafe(String(sourceNumberingXml));
|
|
169
|
+
if (!parsed?.doc) return null;
|
|
170
|
+
const level = levelDefinition(parsed.doc, numId, Number(ilvl) || 0);
|
|
171
|
+
if (!level) return null;
|
|
172
|
+
return { numFmt: level.numFmt, kind: level.numFmt === 'bullet' ? 'bullet' : 'numbered' };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Compares the format a list edit asked for with the source level definition. Returns the
|
|
177
|
+
* mismatch description or `null` when it matches, is unknown, or cannot be compared safely.
|
|
178
|
+
*/
|
|
179
|
+
export function detectListFormatMismatch({ requested, sourceLevel }) {
|
|
180
|
+
if (!requested || !sourceLevel) return null;
|
|
181
|
+
const requestedKind = requested.kind === 'bullet' ? 'bullet' : 'numbered';
|
|
182
|
+
if (requestedKind !== sourceLevel.kind) return { requested, source: sourceLevel };
|
|
183
|
+
if (
|
|
184
|
+
requestedKind === 'numbered'
|
|
185
|
+
&& COMPARABLE_NUM_FMTS.has(requested.numFmt)
|
|
186
|
+
&& COMPARABLE_NUM_FMTS.has(sourceLevel.numFmt)
|
|
187
|
+
&& requested.numFmt !== sourceLevel.numFmt
|
|
188
|
+
) {
|
|
189
|
+
return { requested, source: sourceLevel };
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function buildListFormatDiagnostic(mismatch, { policy, numId, level, marker, targetText, operationIndex } = {}) {
|
|
195
|
+
const isError = policy === 'error';
|
|
196
|
+
const code = isError ? UNSUPPORTED_LIST_FORMAT_CHANGE : LIST_FORMAT_NOT_APPLIED;
|
|
197
|
+
const describe = (kind, format) => (kind === 'bullet' ? 'a bullet list' : `a numbered list${format ? ` (${format})` : ''}`);
|
|
198
|
+
const requestedFormat = mismatch.requested.kind === 'bullet' ? 'bullet' : (mismatch.requested.numFmt || mismatch.requested.format);
|
|
199
|
+
const requestedText = describe(mismatch.requested.kind, requestedFormat);
|
|
200
|
+
const sourceText = describe(mismatch.source.kind, mismatch.source.numFmt);
|
|
201
|
+
const suggestedOperation = COMPARABLE_NUM_FMTS.has(requestedFormat)
|
|
202
|
+
? { type: 'list-format', list: { mode: 'change-format', format: requestedFormat, level: Number(level) || 0, scope: 'list' } }
|
|
203
|
+
: null;
|
|
204
|
+
return {
|
|
205
|
+
code,
|
|
206
|
+
severity: isError ? 'error' : 'warning',
|
|
207
|
+
message: `${isError ? 'Changing the list format is not supported by text edits' : 'The requested list format was not applied'}: the list item keeps its source numbering definition (numId ${numId}, ${sourceText}); the edit asked for ${requestedText}${marker ? ` ("${marker}")` : ''}. A list-format change-format operation changes the list's format (capability list-format-change-v1).`,
|
|
208
|
+
requestedList: { kind: mismatch.requested.kind, format: requestedFormat },
|
|
209
|
+
sourceList: { numId: String(numId), level: Number(level) || 0, kind: mismatch.source.kind, format: mismatch.source.numFmt },
|
|
210
|
+
...(targetText ? { targetExcerpt: excerpt(targetText) } : {}),
|
|
211
|
+
...(Number.isInteger(operationIndex) ? { operationIndex } : {}),
|
|
212
|
+
recovery: {
|
|
213
|
+
action: 'use_list_format_change_format',
|
|
214
|
+
hint: suggestedOperation
|
|
215
|
+
? 'Text edits cannot change a list\'s kind or numbering format. Send recovery.suggestedOperation with a target on one item of the list as its own operation (scope "list" rebinds every item; scope "range" only a range that runs to the end of the list). Make any text edit in a separate redline without the new markers, targeting a different paragraph or a separate call. Or pass listMarkers: "literal" to accept the current result.'
|
|
216
|
+
: 'Text edits cannot change a list\'s kind or numbering format. Use a list-format change-format operation, or pass listMarkers: "literal" to accept the current result.',
|
|
217
|
+
...(suggestedOperation ? { suggestedOperation } : {}),
|
|
218
|
+
field: 'listMarkers',
|
|
219
|
+
allowedValue: 'literal'
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Validates the shape of `paragraph-format.properties.numbering`: `null` (remove the binding) or
|
|
226
|
+
* `{ numId: positive integer, level?: integer 0-8 }`. Returns an error message or null.
|
|
227
|
+
*/
|
|
228
|
+
export function validateParagraphNumberingShape(numbering) {
|
|
229
|
+
if (numbering === null) return null;
|
|
230
|
+
if (typeof numbering !== 'object' || Array.isArray(numbering)) {
|
|
231
|
+
return 'numbering must be null (remove the list binding) or an object { numId, level }.';
|
|
232
|
+
}
|
|
233
|
+
const extra = Object.keys(numbering).filter(key => key !== 'numId' && key !== 'level');
|
|
234
|
+
if (extra.length > 0) return `numbering has unsupported ${extra.length === 1 ? 'key' : 'keys'}: ${extra.join(', ')}. Use only numId and level.`;
|
|
235
|
+
const numId = numbering.numId;
|
|
236
|
+
const numIdOk = (Number.isInteger(numId) && numId >= 1) || (typeof numId === 'string' && /^[1-9]\d*$/.test(numId));
|
|
237
|
+
if (!numIdOk) return 'numbering.numId must be a positive integer (numId 0 is not a list; pass numbering: null to remove the binding).';
|
|
238
|
+
const level = numbering.level;
|
|
239
|
+
if (level !== undefined && !(Number.isInteger(level) && level >= 0 && level <= 8)) {
|
|
240
|
+
return 'numbering.level must be an integer from 0 to 8.';
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Finds `paragraph-format.properties` keys that are not implemented. */
|
|
246
|
+
export function findUnknownParagraphFormatKeys(properties) {
|
|
247
|
+
if (!properties || typeof properties !== 'object') return [];
|
|
248
|
+
return Object.keys(properties).filter(key => !PARAGRAPH_FORMAT_PROPERTY_KEYS.includes(key));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function buildUnknownParagraphFormatDiagnostic(keys) {
|
|
252
|
+
const list = keys.map(key => `"${key}"`).join(', ');
|
|
253
|
+
return {
|
|
254
|
+
code: UNKNOWN_PARAGRAPH_FORMAT_PROPERTY,
|
|
255
|
+
severity: 'warning',
|
|
256
|
+
message: `paragraph-format ignored unsupported ${keys.length === 1 ? 'property' : 'properties'} ${list}. Supported: ${PARAGRAPH_FORMAT_PROPERTY_KEYS.join(', ')}.`,
|
|
257
|
+
properties: keys,
|
|
258
|
+
recovery: { action: 'change_request', hint: `Use only: ${PARAGRAPH_FORMAT_PROPERTY_KEYS.join(', ')}.` }
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** `CODE: message` string form used in `warnings` arrays. */
|
|
263
|
+
export function diagnosticWarningText(diagnostic) {
|
|
264
|
+
return `${diagnostic.code}: ${diagnostic.message}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function normalizeLine(text) {
|
|
268
|
+
return String(text ?? '').replace(/[\s\u00a0]+/g, ' ').trim();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Compares the list markers in a multi-line edit with the list items it edits.
|
|
273
|
+
*
|
|
274
|
+
* `sourceItems` are `{ text, numId, ilvl }` for the existing list paragraphs. A modified line
|
|
275
|
+
* that keeps a source item's text and carries a marker states the format requested for that
|
|
276
|
+
* item. Inserted marker lines are not compared: the insertion heuristics deliberately add
|
|
277
|
+
* nested bullets under numbered items and the reverse.
|
|
278
|
+
*/
|
|
279
|
+
export function detectRequestedListFormatMismatch({ modifiedText, sourceItems, sourceNumberingXml }) {
|
|
280
|
+
if (!Array.isArray(sourceItems) || sourceItems.length === 0) return null;
|
|
281
|
+
const lines = String(modifiedText ?? '').split(/\r?\n/).filter(line => line.trim());
|
|
282
|
+
const parsed = lines.map(line => ({ line, item: parseLiteralListMarker(line) }));
|
|
283
|
+
const retained = new Set();
|
|
284
|
+
for (const source of sourceItems) {
|
|
285
|
+
const sourceText = normalizeLine(source.text);
|
|
286
|
+
const match = parsed.find((entry, index) => !retained.has(index)
|
|
287
|
+
&& normalizeLine(entry.item ? entry.item.text : entry.line) === sourceText);
|
|
288
|
+
if (!match) continue;
|
|
289
|
+
retained.add(parsed.indexOf(match));
|
|
290
|
+
if (!match.item) continue;
|
|
291
|
+
const sourceLevel = readSourceListLevel(sourceNumberingXml, source.numId, source.ilvl);
|
|
292
|
+
const mismatch = detectListFormatMismatch({ requested: match.item, sourceLevel });
|
|
293
|
+
if (mismatch) return { mismatch, marker: match.item.marker, numId: source.numId, level: source.ilvl, targetText: source.text };
|
|
294
|
+
}
|
|
295
|
+
return null;
|
|
296
|
+
}
|