@superdoc/sdk 2.6.0 → 2.7.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/dist/agent/actions.cjs +1869 -160
- package/dist/agent/actions.d.ts +77 -10
- package/dist/agent/actions.js +1870 -161
- package/dist/agent/catalog.cjs +102 -9
- package/dist/agent/catalog.d.ts +243 -0
- package/dist/agent/catalog.js +99 -9
- package/dist/agent/doc-snapshot.cjs +200 -2
- package/dist/agent/doc-snapshot.d.ts +91 -0
- package/dist/agent/doc-snapshot.js +199 -2
- package/dist/agent/runtime.cjs +9 -1
- package/dist/agent/runtime.d.ts +8 -0
- package/dist/agent/runtime.js +9 -1
- package/dist/generated/client.cjs +754 -770
- package/dist/generated/client.d.ts +9 -9
- package/dist/generated/client.js +754 -770
- package/dist/generated/contract.cjs +16170 -272
- package/dist/generated/contract.d.ts +38 -0
- package/dist/generated/contract.js +17411 -1510
- package/dist/index.cjs +5 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +5 -4
- package/dist/introspection.cjs +59 -0
- package/dist/introspection.d.ts +3 -0
- package/dist/introspection.js +53 -0
- package/dist/runtime/document-rpc.cjs +179 -40
- package/dist/runtime/document-rpc.d.ts +13 -4
- package/dist/runtime/document-rpc.js +175 -40
- package/dist/runtime/embedded-cli.cjs +5 -68
- package/dist/runtime/embedded-cli.js +5 -67
- package/dist/runtime/embedded-document-host.cjs +28 -0
- package/dist/runtime/embedded-document-host.d.ts +1 -0
- package/dist/runtime/embedded-document-host.js +23 -0
- package/dist/runtime/embedded-platform.cjs +102 -0
- package/dist/runtime/embedded-platform.d.ts +5 -0
- package/dist/runtime/embedded-platform.js +93 -0
- package/dist/runtime/host.cjs +70 -19
- package/dist/runtime/host.d.ts +2 -0
- package/dist/runtime/host.js +70 -20
- package/dist/runtime/process.cjs +30 -9
- package/dist/runtime/process.d.ts +9 -0
- package/dist/runtime/process.js +29 -9
- package/dist/runtime/transport-common.cjs +1 -0
- package/dist/runtime/transport-common.d.ts +28 -10
- package/dist/runtime/transport-common.js +1 -1
- package/package.json +6 -6
- package/tools/__pycache__/__init__.cpython-311.pyc +0 -0
- package/tools/__pycache__/intent_dispatch_generated.cpython-311.pyc +0 -0
- package/tools/tools-policy.json +1 -1
package/dist/agent/actions.cjs
CHANGED
|
@@ -57,14 +57,14 @@ const ACTION_NAMES = [
|
|
|
57
57
|
* case; the compiler enforces all but the group, which a unit test covers.
|
|
58
58
|
*/
|
|
59
59
|
const ACTION_HINTS = {
|
|
60
|
-
insert_paragraphs:
|
|
60
|
+
insert_paragraphs: "texts[] (or text for a single paragraph), placement?, headingLevel? (first item as heading 1-6), changeMode? — THE way to ADD a new paragraph/clause; never replace_text/rewrite_block an existing paragraph to append content. Each texts entry is a plain string OR a STYLED object {runs:[{text,marks}], marks?} (marks: bold/italic/underline/strikethrough/fontFamily/fontSize(points)/color(hex)/highlight). MATCH THE SECTION'S PATTERN: when siblings share intra-paragraph formatting (e.g. lettered clauses with a bold '(m)' lead-in), inspect a narrow block window with includeBlockRuns:true, read the sibling runs, and reproduce the pattern with runs on the new text IN THIS CALL — e.g. runs:[{text:'(n)',marks:{bold:true}},{text:' rest of the clause…'}]. Do not add plain and format_text afterward",
|
|
61
61
|
insert_heading: 'text, level, placement?, changeMode?',
|
|
62
|
-
replace_text: 'edits[{find,replace}], optional selector to scope replacements to one inspected block, caseSensitive?, changeMode?',
|
|
62
|
+
replace_text: 'edits[{find,replace}], optional selector to scope replacements to one inspected block, caseSensitive?, changeMode? — for EDITING existing wording only; the replaced span is rewritten as PLAIN text (intra-span bold/italic is dropped), so keep find/replace spans MINIMAL (the words that change, not the whole paragraph) and NEVER use it to append a new sentence/clause/paragraph (use insert_paragraphs)',
|
|
63
63
|
delete_text: "finds[], optional selector to scope deletions to ONE inspected block (required to delete stray whitespace — an unscoped whitespace find matches document-wide), caseSensitive?, changeMode? — deletes TEXT ONLY, leaving the block (and a list item's bullet/number) in place. To remove a whole list item, paragraph or heading use delete_blocks",
|
|
64
64
|
delete_blocks: 'selectors[] (each resolving to ONE inspected block: list item, paragraph or heading), changeMode? — THE way to DELETE a whole LIST ITEM, paragraph or heading, bullet/number included. Use for "remove the first item under Article II" / "delete that clause". delete_text only strikes the text and leaves an empty numbered item behind. Deletes several blocks in ONE call; use delete_table for a whole table and delete_text to clear a table cell (a cell must keep a paragraph)',
|
|
65
65
|
append_list: 'items[], kind?: ordered|bullet, headingText?, headingLevel?, placement? {at:"after"|"before",selector} builds the list at that block instead of document end',
|
|
66
|
-
create_table: 'rows, columns, cellTexts
|
|
67
|
-
rewrite_block:
|
|
66
|
+
create_table: 'rows, columns, cellTexts? (2D: rows of cells; each cell is a plain string OR a STYLED object {runs:[{text,marks}], marks?} to format the cell — marks: bold/italic/underline/strikethrough/fontFamily/fontSize(points)/color(hex)/highlight), placement?, changeMode? — style cells HERE, do not format_text afterward; changeMode:"tracked" makes the table insertion itself a tracked change',
|
|
67
|
+
rewrite_block: "selector, text, changeMode? — REPLACES the whole block text; the block's intra-paragraph run pattern is preserved best-effort over the unchanged prefix/suffix. For appending a NEW paragraph/clause use insert_paragraphs, and for small wording edits prefer replace_text with a minimal find span",
|
|
68
68
|
fill_placeholders: 'values[] and/or fields[{label?,value}], changeMode?',
|
|
69
69
|
move_range: 'fromText (text in the FIRST block of the range to move), toText? (text in the LAST block — omit to auto-extend across the whole VISUAL SECTION: from fromText up to the next heading-like/ALL-CAPS/bold title), then exactly ONE destination: afterText OR beforeText (text in the block to land after/before). Direct-only today: changeMode:"tracked" fails with no mutation because block-range deletion cannot be tracked. Moves plain paragraph/heading text only; a range containing a table, list, or image is refused with nothing changed. THE way to MOVE a contiguous block range or a visual "section" identified BY TEXT (works on styled-paragraph sections like PREAMBLE / SCHEDULE A that are NOT Word heading nodes). afterText on a heading-like block lands the range after that block\'s WHOLE section.',
|
|
70
70
|
comment_paragraphs: 'commentText, scope?: all|body, excludeBlockQuotes?',
|
|
@@ -79,8 +79,8 @@ const ACTION_HINTS = {
|
|
|
79
79
|
apply_style: 'selector (block to restyle), then ONE of: styleId (e.g. "Heading2"), headingLevel (1-6), or likeText (text inside another block whose style AND effective look to copy) — THE way to restyle an EXISTING block ("make Summary match the Parties heading"). Never delete-and-recreate a block to change its style',
|
|
80
80
|
apply_letter_spacing: 'selector, letterSpacing, changeMode?',
|
|
81
81
|
insert_toc: 'title?, placement?, changeMode?',
|
|
82
|
-
insert_table_row:
|
|
83
|
-
insert_table_column: 'tableOrdinal?, columnIndex?, position
|
|
82
|
+
insert_table_row: "tableOrdinal?, rowIndex?, position?: before|after|above|below, cellTexts? (one per column; each a plain string OR a STYLED {runs, marks?} cell to reproduce the table's cell formatting), changeMode?, dryRun? — to match existing cell styling, inspect the table with includeTableCellRuns:true first, then style each new cell HERE. PLACEMENT: when the user does not say WHERE the row goes, append it after the LAST existing row (rowIndex = last row's index, position: below); never pick a middle position on your own",
|
|
83
|
+
insert_table_column: 'tableOrdinal?, columnIndex?, position?: left|right, headerText? OR cellTexts? (one per row; each a plain string OR a STYLED {runs, marks?} cell), changeMode? — style the new column cells HERE, not with format_text',
|
|
84
84
|
delete_table_row: 'tableOrdinal?, rowIndex, changeMode?',
|
|
85
85
|
delete_table_column: 'tableOrdinal?, columnIndex, changeMode?',
|
|
86
86
|
split_table: 'tableOrdinal?, rowIndex, separatorText?, changeMode?',
|
|
@@ -89,7 +89,11 @@ const ACTION_HINTS = {
|
|
|
89
89
|
undo_changes: 'untilMarker? rendered marker to restore e.g. "2.1.", steps? 1-25 — deterministic revert that steps document history back until the marker reappears',
|
|
90
90
|
redo_changes: 'steps? 1-25 (default 1) — the inverse of undo_changes: steps history FORWARD to re-apply edits a prior undo removed. THE recovery when an undo overshot. Only works right after an undo, before any new edit.',
|
|
91
91
|
attach_numbering: 'anchorText|nodeId, likeMarker e.g. "10.", changeMode? — make an existing block a numbered clause at the same scheme/level as the sibling rendering likeMarker; changeMode:"tracked" records the former unnumbered state as a w:pPrChange',
|
|
92
|
-
add_list_items:
|
|
92
|
+
add_list_items: 'THE way to ADD items into an EXISTING list (reusing its numbering + markers) — NOT append_list (which starts a brand-new list). Locate the list with anchorText (text inside an existing item) or listOrdinal (from inspect). Each item in entries/items is EITHER a plain string / {text, level?}, OR a STYLED object {runs, marks?, level?}. ' +
|
|
93
|
+
'STYLING IN THIS ONE CALL — if any part of the new item needs inline formatting (bold/italic/underline/color/font/size on some or all of it), you MUST express it with `runs` and/or `marks` HERE. Do NOT add the item plain and then call format_text/format_paragraph on it afterward — that is the wrong tool for a just-added item and risks matching stray text elsewhere. ' +
|
|
94
|
+
'`runs` = ordered segments [{text, marks?}] whose texts concatenate to the item text; put the pattern on each segment (marks: bold/italic/underline/strikethrough/fontFamily/fontSize(points)/color(hex e.g. #1a1a1a)/highlight). `marks` on the item = a whole-item default (e.g. Arial 16pt) applied under the per-run marks. Example — pattern \'first word bold, rest italic\': runs:[{text:"Term:",marks:{bold:true}},{text:" the rest",marks:{italic:true}}]. ' +
|
|
95
|
+
"MATCHING THE LIST'S FORMATTING IS THE DEFAULT EXPECTATION, and it means per-run inline formatting — NOT numbering (numbering is always reused automatically). superdoc_inspect returns each list item's `runs` (its inline segments with marks) by default: READ the sibling items' runs, infer the rule (which segment is bold/italic/etc., the font/size), and REPRODUCE that run structure with `runs` on the new item's OWN words IN THIS CALL. If the siblings are uniformly styled, a plain add already inherits their font/size/colour, so no marks are needed; but if any sibling has intra-item formatting, you MUST supply `runs`. " +
|
|
96
|
+
'level is RELATIVE to the anchor (0 = same level, positive nests deeper, NEGATIVE promotes toward the top e.g. anchor "12(e)" + level:-1 → new top-level item 13); only anchorText can set explicit level (listOrdinal appends at the trailing level). PLACEMENT: anchorText inserts the new item RIGHT AFTER the item containing that text — this is how you position within a list. One Word list often spans SEVERAL visual groups (the same numbering id continues past e.g. "Label: value" entries into unrelated paragraphs), so when the new item belongs to a visible GROUP of similar siblings, anchor the LAST item OF THAT GROUP — tail-append (listOrdinal, or anchoring the list\'s final item) can land it in the wrong region. Only when there is no such group and the user gives no placement signal append at the END of the list. The receipt\'s note/addedItems echo the rendered marker AND the immediate neighbor items — verify placement from the receipt; do NOT re-inspect wide block windows after a successful add. receipt.formattingMatched reports what was applied.',
|
|
93
97
|
format_paragraph: 'selector (block to align), alignment: left|center|right|justify, changeMode? — THE way to set paragraph alignment; changeMode:"tracked" records the former alignment as a w:pPrChange so accept/reject toggles it (never apply alignment untracked when tracking is on)',
|
|
94
98
|
move_text: 'text (exact clause/phrase to relocate), afterText? (relocate to immediately after this text), changeMode? — THE way to MOVE a text span. Direct by default (physically relocates; REQUIRES afterText). changeMode:"tracked" records it as a redline (tracked delete of the source + tracked insert at the destination; accept keeps the move, reject restores the order) and may omit afterText (lands right after the struck source).',
|
|
95
99
|
style_table: 'tableOrdinal? (1-based, default 1), accentColor? (header fill hex, default dark navy) — THE way to make a table look professional: filled accent header row with white bold text, bold first-column labels, banded rows, clean borders. Use after create_table or on any existing table.',
|
|
@@ -187,7 +191,7 @@ const ACTION_ARGS = {
|
|
|
187
191
|
move_range: ['fromText', 'toText', 'afterText', 'beforeText', 'changeMode'],
|
|
188
192
|
insert_toc: ['title', 'placement', 'changeMode'],
|
|
189
193
|
insert_table_row: ['tableOrdinal', 'rowIndex', 'position', 'cellTexts', 'changeMode', 'dryRun'],
|
|
190
|
-
insert_table_column: ['tableOrdinal', 'columnIndex', 'position', 'headerText', 'changeMode'],
|
|
194
|
+
insert_table_column: ['tableOrdinal', 'columnIndex', 'position', 'headerText', 'cellTexts', 'changeMode'],
|
|
191
195
|
delete_table_row: ['tableOrdinal', 'rowIndex', 'changeMode'],
|
|
192
196
|
delete_table_column: ['tableOrdinal', 'columnIndex', 'changeMode'],
|
|
193
197
|
split_table: ['tableOrdinal', 'rowIndex', 'separatorText', 'changeMode'],
|
|
@@ -227,6 +231,30 @@ function isActionName(value) {
|
|
|
227
231
|
function isRecord(value) {
|
|
228
232
|
return value != null && typeof value === 'object' && !Array.isArray(value);
|
|
229
233
|
}
|
|
234
|
+
/** The message to put in a receipt when something best-effort did not happen. */
|
|
235
|
+
function reasonOf(err) {
|
|
236
|
+
return err instanceof Error ? err.message : String(err);
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Run a best-effort polish step: never throw, and report WHY it did nothing.
|
|
240
|
+
*
|
|
241
|
+
* Contextual formatting must not fail an edit that already landed — but a
|
|
242
|
+
* silently swallowed failure leaves the caller believing the look was matched.
|
|
243
|
+
* Returns the reason, so the receipt can carry it.
|
|
244
|
+
*/
|
|
245
|
+
async function bestEffort(step) {
|
|
246
|
+
try {
|
|
247
|
+
await step();
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
return reasonOf(err);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/** A NO_OP means "already in the requested state" — the goal, not a failure. */
|
|
255
|
+
function isNoOp(err) {
|
|
256
|
+
return err?.code === 'NO_OP';
|
|
257
|
+
}
|
|
230
258
|
function asRecord(value) {
|
|
231
259
|
return isRecord(value) ? value : null;
|
|
232
260
|
}
|
|
@@ -485,7 +513,7 @@ function revisionVerification(preRevision, postRevision, expectChanged) {
|
|
|
485
513
|
};
|
|
486
514
|
}
|
|
487
515
|
function failedReceipt(intent, err, preSnapshot) {
|
|
488
|
-
const message =
|
|
516
|
+
const message = reasonOf(err);
|
|
489
517
|
return {
|
|
490
518
|
status: 'failed',
|
|
491
519
|
intent,
|
|
@@ -557,15 +585,19 @@ function flattenCellTexts(cellTexts) {
|
|
|
557
585
|
for (let rowIndex = 0; rowIndex < cellTexts.length; rowIndex += 1) {
|
|
558
586
|
const row = cellTexts[rowIndex] ?? [];
|
|
559
587
|
for (let columnIndex = 0; columnIndex < row.length; columnIndex += 1) {
|
|
560
|
-
flattened.push(
|
|
561
|
-
rowIndex,
|
|
562
|
-
columnIndex,
|
|
563
|
-
text: row[columnIndex] ?? '',
|
|
564
|
-
});
|
|
588
|
+
flattened.push(cellTextFromSpec(row[columnIndex], rowIndex, columnIndex));
|
|
565
589
|
}
|
|
566
590
|
}
|
|
567
591
|
return flattened;
|
|
568
592
|
}
|
|
593
|
+
/** Normalize a cell input (string or object) into a positioned TableCellText. */
|
|
594
|
+
function cellTextFromSpec(cell, rowIndex, columnIndex) {
|
|
595
|
+
if (typeof cell === 'string' || cell == null) {
|
|
596
|
+
return { rowIndex, columnIndex, text: cell ?? '' };
|
|
597
|
+
}
|
|
598
|
+
const spec = normalizeItemSpec(cell) ?? {};
|
|
599
|
+
return { rowIndex, columnIndex, text: itemSpecText(spec), runs: spec.runs, marks: spec.marks };
|
|
600
|
+
}
|
|
569
601
|
function findExtractedCellBlock(blocks, rowIndex, columnIndex) {
|
|
570
602
|
return (blocks.find((block) => block.tableContext?.rowIndex === rowIndex && block.tableContext?.columnIndex === columnIndex) ??
|
|
571
603
|
blocks.find((block) => {
|
|
@@ -785,7 +817,14 @@ async function executeCommentCreate(doc, snapshot, commentText, blockId) {
|
|
|
785
817
|
target,
|
|
786
818
|
});
|
|
787
819
|
}
|
|
788
|
-
async function applyTableCellTexts(doc, tableNodeId, tableOrdinal, cellTexts, changeMode
|
|
820
|
+
async function applyTableCellTexts(doc, tableNodeId, tableOrdinal, cellTexts, changeMode,
|
|
821
|
+
/**
|
|
822
|
+
* Force the cell fill to run as a direct edit even under `changeMode:'tracked'`.
|
|
823
|
+
* Set only by the column-insert caller, whose new cells land in pre-existing
|
|
824
|
+
* rows that carry no tracked-insertion marker for the adapter to absorb them
|
|
825
|
+
* into. See the tracked-mode note below.
|
|
826
|
+
*/
|
|
827
|
+
forceDirectFill = false) {
|
|
789
828
|
const nonEmptyCells = cellTexts.filter((cell) => cell.text.trim().length > 0);
|
|
790
829
|
if (nonEmptyCells.length === 0)
|
|
791
830
|
return [];
|
|
@@ -820,36 +859,170 @@ async function applyTableCellTexts(doc, tableNodeId, tableOrdinal, cellTexts, ch
|
|
|
820
859
|
},
|
|
821
860
|
];
|
|
822
861
|
});
|
|
823
|
-
|
|
862
|
+
// Prefer tables.setCellText: it emits a `table.cell.setText` STRUCTURAL delta
|
|
863
|
+
// that survives collaboration. The legacy `text.rewrite` path is a generic
|
|
864
|
+
// block rewrite the v2 collab backend silently drops for freshly-inserted
|
|
865
|
+
// cells (rows/columns land empty), so it is only a fallback for non-collab or
|
|
866
|
+
// older hosts without the op.
|
|
867
|
+
//
|
|
868
|
+
// Tracked mode: the adapter accepts a tracked cell fill only where the target
|
|
869
|
+
// cell's row carries a same-author `<w:trPr><w:ins/>` marker, which already
|
|
870
|
+
// owns the accept/reject fate of everything inside it. A tracked row insert
|
|
871
|
+
// and a tracked new table both produce such rows, so their fills go through
|
|
872
|
+
// tracked and the transport now lets them reach the adapter
|
|
873
|
+
// (supportsConditionalTrackedMode, SD-4035).
|
|
874
|
+
//
|
|
875
|
+
// A tracked COLUMN insert is the exception: its new cells are added to
|
|
876
|
+
// PRE-EXISTING rows, which carry no marker, so the adapter fails closed with
|
|
877
|
+
// CAPABILITY_UNAVAILABLE and the column would land empty. Those fills stay
|
|
878
|
+
// direct — the table-level insertion still owns the redline.
|
|
879
|
+
const cellFillChangeMode = forceDirectFill && changeMode === 'tracked' ? undefined : changeMode;
|
|
880
|
+
const setCellFn = maybeMethod(doc, ['tables', 'setCellText']);
|
|
881
|
+
const legacySteps = [];
|
|
882
|
+
const resolved = [];
|
|
824
883
|
for (const cell of nonEmptyCells) {
|
|
825
884
|
const block = findExtractedCellBlock(blocks, cell.rowIndex, cell.columnIndex);
|
|
826
885
|
if (!block?.nodeId) {
|
|
827
886
|
throw new errors.SuperDocCliError('Unable to locate a paragraph block for the requested table cell.', {
|
|
828
887
|
code: 'INVALID_ARGUMENT',
|
|
829
|
-
details: {
|
|
830
|
-
tableNodeId,
|
|
831
|
-
rowIndex: cell.rowIndex,
|
|
832
|
-
columnIndex: cell.columnIndex,
|
|
833
|
-
},
|
|
888
|
+
details: { tableNodeId, rowIndex: cell.rowIndex, columnIndex: cell.columnIndex },
|
|
834
889
|
});
|
|
835
890
|
}
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
891
|
+
resolved.push({ ...cell, nodeId: block.nodeId });
|
|
892
|
+
if (setCellFn) {
|
|
893
|
+
await setCellFn({
|
|
894
|
+
target: { kind: 'block', nodeType: 'table', nodeId: tableNodeId },
|
|
895
|
+
rowIndex: cell.rowIndex,
|
|
896
|
+
columnIndex: cell.columnIndex,
|
|
897
|
+
text: cell.text,
|
|
898
|
+
...(cellFillChangeMode ? { changeMode: cellFillChangeMode } : {}),
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
else {
|
|
902
|
+
legacySteps.push({
|
|
903
|
+
id: `set-table-cell-${cell.rowIndex}-${cell.columnIndex}`,
|
|
904
|
+
op: 'text.rewrite',
|
|
905
|
+
where: { by: 'block', nodeType: block.type ?? 'paragraph', nodeId: block.nodeId },
|
|
906
|
+
args: { replacement: { text: cell.text } },
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
if (legacySteps.length > 0)
|
|
911
|
+
await executeMutations(doc, legacySteps, cellFillChangeMode);
|
|
912
|
+
return resolved;
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Sample a block's leading inline look through `query.match`, which is the only
|
|
916
|
+
* read that reports run styling (`blocks.list` omits it and `getNodeById`
|
|
917
|
+
* carries no run props). Returns null when nothing is worth copying.
|
|
918
|
+
*/
|
|
919
|
+
async function inlineLookFromBlockText(doc, text, blockId) {
|
|
920
|
+
const queryFn = maybeMethod(doc, ['query', 'match']);
|
|
921
|
+
if (!queryFn || !text.trim())
|
|
922
|
+
return null;
|
|
923
|
+
const result = asRecord(await queryFn({ select: { type: 'text', pattern: text } }));
|
|
924
|
+
const items = Array.isArray(result?.items) ? result.items : [];
|
|
925
|
+
for (const item of items) {
|
|
926
|
+
const blocks = Array.isArray(asRecord(item)?.blocks) ? asRecord(item).blocks : [];
|
|
927
|
+
for (const block of blocks) {
|
|
928
|
+
const rec = asRecord(block);
|
|
929
|
+
if (asString(rec?.blockId) !== blockId)
|
|
930
|
+
continue;
|
|
931
|
+
const runs = Array.isArray(rec?.runs) ? rec.runs : [];
|
|
932
|
+
if (runs.length === 0)
|
|
933
|
+
continue;
|
|
934
|
+
// Same reader the list path uses, so a table cell inherits COLOUR too —
|
|
935
|
+
// a white-on-blue header cell that keeps its shading but loses its white
|
|
936
|
+
// text reads as an empty cell.
|
|
937
|
+
const inline = inlineFromMatchStyle(matchRunStyle(runs[0]));
|
|
938
|
+
return Object.keys(inline).length > 0 ? inline : null;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
return null;
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* Make freshly filled cells look like the cells they were inserted next to.
|
|
945
|
+
*
|
|
946
|
+
* Cell text written into a NEW row or column lands with no run properties at
|
|
947
|
+
* all (under collaboration for rows; always for columns, whose cells live in
|
|
948
|
+
* pre-existing rows), so the new cells render at the default face while their
|
|
949
|
+
* neighbours are 10pt bold — the table equivalent of the list-item base layer.
|
|
950
|
+
* Only cells the agent did not style explicitly are touched; explicit
|
|
951
|
+
* runs/marks always win.
|
|
952
|
+
*
|
|
953
|
+
* `reference` names the cell each new cell should copy: the same column in the
|
|
954
|
+
* displaced ROW for a row insert, the same row in the adjacent COLUMN for a
|
|
955
|
+
* column insert. Returning null skips that cell.
|
|
956
|
+
*
|
|
957
|
+
* Best-effort: never throws. Reports how many cells were matched AND why any
|
|
958
|
+
* were not — one cell that fails must neither abort the others nor pass for
|
|
959
|
+
* "matched the neighbours" in the receipt.
|
|
960
|
+
*/
|
|
961
|
+
async function matchTableCellsToNeighbour(doc, tableOrdinal, cells, reference) {
|
|
962
|
+
const candidates = cells.filter((c) => !(c.runs && c.runs.length > 0) && c.marks == null);
|
|
963
|
+
if (candidates.length === 0)
|
|
964
|
+
return { matched: 0 };
|
|
965
|
+
const extractFn = maybeMethod(doc, ['extract']);
|
|
966
|
+
const formatFn = maybeMethod(doc, ['format', 'apply']);
|
|
967
|
+
if (!extractFn || !formatFn)
|
|
968
|
+
return { matched: 0 };
|
|
969
|
+
let blocks = [];
|
|
970
|
+
const readFailure = await bestEffort(async () => {
|
|
971
|
+
const extracted = asRecord(await extractFn({}));
|
|
972
|
+
blocks = Array.isArray(extracted?.blocks) ? extracted.blocks : [];
|
|
973
|
+
});
|
|
974
|
+
if (readFailure)
|
|
975
|
+
return { matched: 0, skipped: readFailure };
|
|
976
|
+
let matched = 0;
|
|
977
|
+
const failures = [];
|
|
978
|
+
for (const cell of candidates) {
|
|
979
|
+
const failure = await bestEffort(async () => {
|
|
980
|
+
const ref = reference(cell);
|
|
981
|
+
if (!ref || ref.rowIndex < 0 || ref.columnIndex < 0)
|
|
982
|
+
return;
|
|
983
|
+
const neighbour = blocks
|
|
984
|
+
.map((b) => asRecord(b))
|
|
985
|
+
.find((b) => {
|
|
986
|
+
const ctx = asRecord(b?.tableContext);
|
|
987
|
+
return (asNumber(ctx?.tableOrdinal, -1) === tableOrdinal - 1 &&
|
|
988
|
+
asNumber(ctx?.rowIndex, -1) === ref.rowIndex &&
|
|
989
|
+
asNumber(ctx?.columnIndex, -1) === ref.columnIndex);
|
|
990
|
+
});
|
|
991
|
+
const neighbourId = asString(neighbour?.nodeId);
|
|
992
|
+
const neighbourText = asString(neighbour?.text, '');
|
|
993
|
+
if (!neighbourId || !neighbourText)
|
|
994
|
+
return;
|
|
995
|
+
const inline = await inlineLookFromBlockText(doc, neighbourText, neighbourId);
|
|
996
|
+
if (!inline)
|
|
997
|
+
return;
|
|
998
|
+
try {
|
|
999
|
+
await formatFn({ blockId: cell.nodeId, start: 0, end: Math.max(cell.text.length, 1), inline });
|
|
1000
|
+
matched += 1;
|
|
1001
|
+
}
|
|
1002
|
+
catch (err) {
|
|
1003
|
+
if (!isNoOp(err))
|
|
1004
|
+
throw err;
|
|
1005
|
+
}
|
|
847
1006
|
});
|
|
1007
|
+
if (failure)
|
|
1008
|
+
failures.push(`cell ${cell.rowIndex},${cell.columnIndex}: ${failure}`);
|
|
848
1009
|
}
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
1010
|
+
return { matched, ...(failures.length > 0 ? { skipped: failures.join('; ') } : {}) };
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Apply per-run / whole-cell styling to table cells that requested it, after
|
|
1014
|
+
* their text has been written. Reuses the list-item styling pass (format.apply
|
|
1015
|
+
* per run range on each cell's paragraph nodeId). Best-effort; returns the
|
|
1016
|
+
* number of cells styled.
|
|
1017
|
+
*/
|
|
1018
|
+
async function styleTableCells(doc, cells, changeMode) {
|
|
1019
|
+
const styled = cells.filter((c) => (c.runs && c.runs.length > 0) || c.marks != null);
|
|
1020
|
+
if (styled.length === 0)
|
|
1021
|
+
return 0;
|
|
1022
|
+
const nodeIds = styled.map((c) => c.nodeId);
|
|
1023
|
+
const specs = styled.map((c) => ({ text: c.text, runs: c.runs, marks: c.marks }));
|
|
1024
|
+
await applyItemStyling(doc, nodeIds, specs, changeMode);
|
|
1025
|
+
return styled.length;
|
|
853
1026
|
}
|
|
854
1027
|
async function runInsertParagraphs(doc, args) {
|
|
855
1028
|
const pre = await docSnapshot.buildDocumentSnapshot(doc, { includeDomains: ['blocks'] });
|
|
@@ -885,6 +1058,8 @@ async function runInsertParagraphs(doc, args) {
|
|
|
885
1058
|
? { kind: 'after', target: { kind: 'block', nodeType: last.nodeType, nodeId: last.nodeId } }
|
|
886
1059
|
: { kind: 'documentEnd' };
|
|
887
1060
|
};
|
|
1061
|
+
const styledNodeIds = [];
|
|
1062
|
+
const styledSpecs = [];
|
|
888
1063
|
for (let i = 0; i < texts.length; i += 1) {
|
|
889
1064
|
const text = texts[i];
|
|
890
1065
|
const isFirst = i === 0;
|
|
@@ -895,6 +1070,16 @@ async function runInsertParagraphs(doc, args) {
|
|
|
895
1070
|
operationId: isFirst && headingFirst ? 'doc.create.heading' : 'doc.create.paragraph',
|
|
896
1071
|
result,
|
|
897
1072
|
});
|
|
1073
|
+
// Remember the created block for the post-insert styling pass when this
|
|
1074
|
+
// paragraph carries a runs/marks spec.
|
|
1075
|
+
const spec = args.textSpecs?.[i];
|
|
1076
|
+
if (spec && ((spec.runs && spec.runs.length > 0) || spec.marks != null)) {
|
|
1077
|
+
const created = createdBlockTarget(result);
|
|
1078
|
+
if (created?.nodeId) {
|
|
1079
|
+
styledNodeIds.push(created.nodeId);
|
|
1080
|
+
styledSpecs.push({ ...spec, text });
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
898
1083
|
currentPlacement = await advanceAfter(result);
|
|
899
1084
|
// New-document drafts get a blank paragraph after each item for spacing;
|
|
900
1085
|
// inserts into an existing document do not (would scatter blank lines).
|
|
@@ -904,6 +1089,14 @@ async function runInsertParagraphs(doc, args) {
|
|
|
904
1089
|
currentPlacement = await advanceAfter(spacer);
|
|
905
1090
|
}
|
|
906
1091
|
}
|
|
1092
|
+
if (styledNodeIds.length > 0) {
|
|
1093
|
+
const styling = await applyItemStyling(doc, styledNodeIds, styledSpecs, args.changeMode);
|
|
1094
|
+
executedOperations.push({
|
|
1095
|
+
operationId: 'doc.format.apply',
|
|
1096
|
+
result: styling,
|
|
1097
|
+
rationale: `Styled ${styledNodeIds.length} inserted paragraph(s)`,
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
907
1100
|
const postIdentity = await readDocumentIdentity(doc);
|
|
908
1101
|
const verification = [revisionVerification(pre.revision, postIdentity.revision, true)];
|
|
909
1102
|
return {
|
|
@@ -956,6 +1149,11 @@ async function runReplaceText(doc, args) {
|
|
|
956
1149
|
const selectedTargets = [];
|
|
957
1150
|
let skippedEdits = [];
|
|
958
1151
|
let steps;
|
|
1152
|
+
// Blocks whose text this call rewrites, with their pre-mutation run layout:
|
|
1153
|
+
// the engine replace lane renders the replaced range as uniform runs, so a
|
|
1154
|
+
// whole-paragraph find/replace flattens intra-paragraph formatting (bold
|
|
1155
|
+
// lead-ins). Restore it positionally after the mutation.
|
|
1156
|
+
const preserveTargets = [];
|
|
959
1157
|
if (args.selector) {
|
|
960
1158
|
const target = selectorToBlockTarget(args.selector, pre);
|
|
961
1159
|
if (!target) {
|
|
@@ -993,6 +1191,13 @@ async function runReplaceText(doc, args) {
|
|
|
993
1191
|
},
|
|
994
1192
|
];
|
|
995
1193
|
selectedTargets.push({ selector: args.selector, matched: [target.nodeId] });
|
|
1194
|
+
preserveTargets.push({
|
|
1195
|
+
nodeId: target.nodeId,
|
|
1196
|
+
nodeType: target.nodeType ?? 'paragraph',
|
|
1197
|
+
oldText: target.text,
|
|
1198
|
+
newText: rewrittenText,
|
|
1199
|
+
runs: await captureBlockRuns(doc, target.nodeId, target.text),
|
|
1200
|
+
});
|
|
996
1201
|
}
|
|
997
1202
|
else {
|
|
998
1203
|
const matchingEdits = args.edits.length === 1
|
|
@@ -1015,26 +1220,104 @@ async function runReplaceText(doc, args) {
|
|
|
1015
1220
|
],
|
|
1016
1221
|
};
|
|
1017
1222
|
}
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1223
|
+
// Rewrite affected BODY blocks with block-targeted rewrites. The select
|
|
1224
|
+
// (matched-range) rewrite lane flattens the WHOLE paragraph's runs in
|
|
1225
|
+
// collaborative sessions and leaves format.apply inert on the block
|
|
1226
|
+
// afterwards, so per-block rewrites + the preserve pass below are the
|
|
1227
|
+
// only path that keeps intra-paragraph formatting alive. Bounded; edits
|
|
1228
|
+
// that touch no scanned body block (e.g. header/footer text) fall back
|
|
1229
|
+
// to a select-rewrite step.
|
|
1230
|
+
// Tracked mode keeps the matched-range lane. A block rewrite asks the
|
|
1231
|
+
// engine to delete and re-insert the WHOLE paragraph, so a reviewer sees
|
|
1232
|
+
// the entire block struck and re-added instead of the words that changed,
|
|
1233
|
+
// and each block contributes its own replacement/deletion pair. The
|
|
1234
|
+
// per-block lane exists to keep intra-paragraph formatting alive, which
|
|
1235
|
+
// is a direct-mode concern; under tracked mode the revision itself is the
|
|
1236
|
+
// reviewable unit and must stay scoped to the edit.
|
|
1237
|
+
const scanBlocks = args.changeMode === 'tracked'
|
|
1238
|
+
? []
|
|
1239
|
+
: pre.blocks.length > 0
|
|
1240
|
+
? pre.blocks
|
|
1241
|
+
: (await docSnapshot.buildDocumentSnapshot(doc, { includeDomains: ['blocks'] })).blocks;
|
|
1242
|
+
// An edit counts as covered only when EVERY block holding it got a
|
|
1243
|
+
// rewrite target. The per-block lane is bounded (run capture costs a read
|
|
1244
|
+
// each), so a find present in more blocks than the cap must fall through
|
|
1245
|
+
// to the select lane below, which carries `require: 'all'`. Marking such
|
|
1246
|
+
// an edit covered after its first match would replace only the first
|
|
1247
|
+
// PRESERVE_TARGET_CAP blocks and still report success.
|
|
1248
|
+
const blocksHoldingEdit = matchingEdits.map((edit) => scanBlocks.filter((block) => block.nodeId && block.text && textIncludes(block.text, edit.find, caseSensitive))
|
|
1249
|
+
.length);
|
|
1250
|
+
const blocksCoveredForEdit = Array.from({ length: matchingEdits.length }, () => 0);
|
|
1251
|
+
for (const block of scanBlocks) {
|
|
1252
|
+
if (preserveTargets.length >= PRESERVE_TARGET_CAP)
|
|
1253
|
+
break;
|
|
1254
|
+
if (!block.nodeId || !block.text)
|
|
1255
|
+
continue;
|
|
1256
|
+
let newText = block.text;
|
|
1257
|
+
const touched = [];
|
|
1258
|
+
matchingEdits.forEach((edit, editIndex) => {
|
|
1259
|
+
if (textIncludes(newText, edit.find, caseSensitive)) {
|
|
1260
|
+
newText = replaceAllText(newText, edit.find, edit.replace, caseSensitive);
|
|
1261
|
+
touched.push(editIndex);
|
|
1262
|
+
}
|
|
1263
|
+
});
|
|
1264
|
+
if (newText === block.text)
|
|
1265
|
+
continue;
|
|
1266
|
+
preserveTargets.push({
|
|
1267
|
+
nodeId: block.nodeId,
|
|
1268
|
+
nodeType: block.nodeType ?? 'paragraph',
|
|
1269
|
+
oldText: block.text,
|
|
1270
|
+
newText,
|
|
1271
|
+
runs: await captureBlockRuns(doc, block.nodeId, block.text),
|
|
1272
|
+
});
|
|
1273
|
+
for (const editIndex of touched)
|
|
1274
|
+
blocksCoveredForEdit[editIndex] += 1;
|
|
1275
|
+
}
|
|
1276
|
+
const coveredEdits = new Set(matchingEdits
|
|
1277
|
+
.map((_edit, editIndex) => editIndex)
|
|
1278
|
+
.filter((editIndex) => blocksHoldingEdit[editIndex] > 0 && blocksCoveredForEdit[editIndex] >= blocksHoldingEdit[editIndex]));
|
|
1279
|
+
steps = [
|
|
1280
|
+
...preserveTargets.map((entry, index) => ({
|
|
1281
|
+
id: `replace-block-${index + 1}`,
|
|
1282
|
+
op: 'text.rewrite',
|
|
1283
|
+
where: { by: 'block', nodeType: entry.nodeType, nodeId: entry.nodeId },
|
|
1284
|
+
args: {
|
|
1285
|
+
replacement: { text: entry.newText },
|
|
1286
|
+
style: preserveRewriteStyle(),
|
|
1028
1287
|
},
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1288
|
+
})),
|
|
1289
|
+
// Edits that touched no scanned body block (header/footer text, or a
|
|
1290
|
+
// doc larger than the scan window) keep the original select-rewrite.
|
|
1291
|
+
...matchingEdits
|
|
1292
|
+
.map((edit, editIndex) => ({ edit, editIndex }))
|
|
1293
|
+
.filter(({ editIndex }) => !coveredEdits.has(editIndex))
|
|
1294
|
+
.map(({ edit, editIndex }) => ({
|
|
1295
|
+
id: `replace-${editIndex + 1}`,
|
|
1296
|
+
op: 'text.rewrite',
|
|
1297
|
+
where: {
|
|
1298
|
+
by: 'select',
|
|
1299
|
+
select: {
|
|
1300
|
+
type: 'text',
|
|
1301
|
+
pattern: edit.find,
|
|
1302
|
+
mode: 'contains',
|
|
1303
|
+
caseSensitive,
|
|
1304
|
+
},
|
|
1305
|
+
require: 'all',
|
|
1306
|
+
},
|
|
1307
|
+
args: {
|
|
1308
|
+
replacement: { text: edit.replace },
|
|
1309
|
+
style: preserveRewriteStyle(),
|
|
1310
|
+
},
|
|
1311
|
+
})),
|
|
1312
|
+
];
|
|
1036
1313
|
}
|
|
1037
1314
|
const result = await executeMutations(doc, steps, args.changeMode);
|
|
1315
|
+
const preserved = [];
|
|
1316
|
+
for (const entry of preserveTargets) {
|
|
1317
|
+
const restored = await preserveRunPatternAfterRewrite(doc, entry.nodeId, entry.oldText, entry.runs, entry.newText);
|
|
1318
|
+
if (restored)
|
|
1319
|
+
preserved.push({ nodeId: entry.nodeId, ...restored });
|
|
1320
|
+
}
|
|
1038
1321
|
if (args.selector && selectedTargets[0]) {
|
|
1039
1322
|
const post = await docSnapshot.buildDocumentSnapshot(doc, { includeDomains: selectorDomains ?? ['blocks'] });
|
|
1040
1323
|
const blockId = selectedTargets[0].matched[0];
|
|
@@ -1063,7 +1346,10 @@ async function runReplaceText(doc, args) {
|
|
|
1063
1346
|
preSnapshot: { revision: pre.revision, counts: pre.counts },
|
|
1064
1347
|
postSnapshot: { revision: post.revision, counts: post.counts },
|
|
1065
1348
|
selectedTargets,
|
|
1066
|
-
executedOperations: [
|
|
1349
|
+
executedOperations: [
|
|
1350
|
+
{ operationId: 'doc.mutations.apply', result },
|
|
1351
|
+
...(preserved.length > 0 ? [{ operationId: 'doc.format.apply', result: { preserved } }] : []),
|
|
1352
|
+
],
|
|
1067
1353
|
verification,
|
|
1068
1354
|
};
|
|
1069
1355
|
}
|
|
@@ -1087,6 +1373,7 @@ async function runReplaceText(doc, args) {
|
|
|
1087
1373
|
...(skippedEditRationale ? { rationale: skippedEditRationale } : {}),
|
|
1088
1374
|
result,
|
|
1089
1375
|
},
|
|
1376
|
+
...(preserved.length > 0 ? [{ operationId: 'doc.format.apply', result: { preserved } }] : []),
|
|
1090
1377
|
],
|
|
1091
1378
|
verification,
|
|
1092
1379
|
};
|
|
@@ -1337,7 +1624,7 @@ async function runDeleteBlocks(doc, args) {
|
|
|
1337
1624
|
catch (err) {
|
|
1338
1625
|
errors$1.push({
|
|
1339
1626
|
code: 'ACTION_FAILED',
|
|
1340
|
-
message: `blocks.delete failed for ${target.nodeType} ${target.nodeId}: ${
|
|
1627
|
+
message: `blocks.delete failed for ${target.nodeType} ${target.nodeId}: ${reasonOf(err)}`,
|
|
1341
1628
|
recovery: { kind: 'reinspect' },
|
|
1342
1629
|
});
|
|
1343
1630
|
}
|
|
@@ -1386,18 +1673,33 @@ async function runAppendList(doc, args) {
|
|
|
1386
1673
|
if (args.items.length === 0) {
|
|
1387
1674
|
return failedReceipt('append_list', new Error('items must be non-empty'), pre);
|
|
1388
1675
|
}
|
|
1676
|
+
// The published schema accepts styled objects here, so normalize them
|
|
1677
|
+
// rather than handing the text-only workflow an object it rejects with
|
|
1678
|
+
// INVALID_ARGUMENT. Text goes in; marks come back as a post-insert pass.
|
|
1679
|
+
const specs = args.items.map((item) => (typeof item === 'string' ? { text: item } : item));
|
|
1680
|
+
const anyStyled = specs.some((spec) => spec.marks != null || (spec.runs && spec.runs.length > 0));
|
|
1389
1681
|
const workflowResult = await listTransform.runSuperdocListTransformWorkflow({
|
|
1390
1682
|
documentHandle: doc,
|
|
1391
1683
|
args: {
|
|
1392
1684
|
action: 'append_new_list',
|
|
1393
|
-
items:
|
|
1685
|
+
items: specs.map((spec) => itemSpecText(spec)),
|
|
1394
1686
|
kind: args.kind ?? 'ordered',
|
|
1395
1687
|
headingText: args.headingText,
|
|
1396
1688
|
headingLevel: args.headingLevel,
|
|
1397
1689
|
changeMode: args.changeMode,
|
|
1398
1690
|
},
|
|
1399
1691
|
});
|
|
1400
|
-
|
|
1692
|
+
const receipt = await receiptFromWorkflowResult(doc, 'append_list', pre, workflowResult);
|
|
1693
|
+
if (anyStyled) {
|
|
1694
|
+
const insertedNodeIds = Array.isArray(workflowResult.output?.execution?.insertedNodeIds)
|
|
1695
|
+
? workflowResult.output.execution.insertedNodeIds
|
|
1696
|
+
: [];
|
|
1697
|
+
if (insertedNodeIds.length > 0) {
|
|
1698
|
+
const formattingMatched = await applyItemStyling(doc, insertedNodeIds, specs, args.changeMode);
|
|
1699
|
+
return { ...receipt, formattingMatched };
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
return receipt;
|
|
1401
1703
|
}
|
|
1402
1704
|
catch (err) {
|
|
1403
1705
|
return failedReceipt('append_list', err, pre);
|
|
@@ -1426,7 +1728,19 @@ async function runInsertListItems(doc, args) {
|
|
|
1426
1728
|
changeMode: args.changeMode,
|
|
1427
1729
|
},
|
|
1428
1730
|
});
|
|
1429
|
-
|
|
1731
|
+
const receipt = await receiptFromWorkflowResult(doc, 'add_list_items', pre, workflowResult);
|
|
1732
|
+
// The list workflow is text-only; apply any requested per-run / whole-item
|
|
1733
|
+
// styling as a post-pass on the freshly inserted items (aligned by index).
|
|
1734
|
+
if (args.styled && args.styled.length > 0) {
|
|
1735
|
+
const insertedNodeIds = Array.isArray(workflowResult.output?.execution?.insertedNodeIds)
|
|
1736
|
+
? workflowResult.output.execution.insertedNodeIds
|
|
1737
|
+
: [];
|
|
1738
|
+
if (insertedNodeIds.length > 0) {
|
|
1739
|
+
const formattingMatched = await applyItemStyling(doc, insertedNodeIds, args.styled, args.changeMode);
|
|
1740
|
+
return { ...receipt, formattingMatched };
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
return receipt;
|
|
1430
1744
|
}
|
|
1431
1745
|
catch (err) {
|
|
1432
1746
|
return failedReceipt('add_list_items', err, pre);
|
|
@@ -1436,14 +1750,35 @@ async function runAddListItems(doc, args, opts) {
|
|
|
1436
1750
|
const pre = await docSnapshot.buildDocumentSnapshot(doc, { includeDomains: ['blocks'] });
|
|
1437
1751
|
try {
|
|
1438
1752
|
const needle = args.anchorText?.trim();
|
|
1439
|
-
|
|
1440
|
-
|
|
1753
|
+
const anchorNodeId = args.anchorNodeId?.trim();
|
|
1754
|
+
if (!needle && !anchorNodeId) {
|
|
1755
|
+
return failedReceipt('add_list_items', new Error('anchorText or anchorNodeId is required'), pre);
|
|
1756
|
+
}
|
|
1441
1757
|
if (!args.entries || args.entries.length === 0) {
|
|
1442
1758
|
return failedReceipt('add_list_items', new Error('entries must be non-empty'), pre);
|
|
1443
1759
|
}
|
|
1444
1760
|
const rows = await listBlockRows(doc);
|
|
1445
|
-
//
|
|
1446
|
-
|
|
1761
|
+
// `blocks.list` rows carry only a TRUNCATED textPreview (~78 chars), while
|
|
1762
|
+
// the agent quotes anchorText out of the FULL block text that inspect
|
|
1763
|
+
// reports. Matching the preview alone misses any anchor past the truncation
|
|
1764
|
+
// point — and a missed anchor is not a visible failure: the caller falls
|
|
1765
|
+
// through to the text-only path, which silently discards every run mark the
|
|
1766
|
+
// agent asked for. Match full snapshot text first, preview second.
|
|
1767
|
+
const needleLower = (needle ?? '').toLowerCase();
|
|
1768
|
+
const fullTextById = new Map();
|
|
1769
|
+
for (const block of pre.blocks ?? []) {
|
|
1770
|
+
if (block.nodeId && typeof block.text === 'string')
|
|
1771
|
+
fullTextById.set(block.nodeId, block.text);
|
|
1772
|
+
}
|
|
1773
|
+
const rowMatchesNeedle = (r) => {
|
|
1774
|
+
const rowText = fullTextById.get(r.nodeId ?? '') ?? r.textPreview ?? '';
|
|
1775
|
+
return rowText.toLowerCase().includes(needleLower);
|
|
1776
|
+
};
|
|
1777
|
+
// The list to extend: prefer a direct nodeId anchor (from a resolved
|
|
1778
|
+
// listOrdinal), else a numbered/bulleted item whose text matches anchorText.
|
|
1779
|
+
const anchor = anchorNodeId
|
|
1780
|
+
? rows.find((r) => r.nodeId === anchorNodeId && blockNumbering(r))
|
|
1781
|
+
: rows.find((r) => blockNumbering(r) && rowMatchesNeedle(r));
|
|
1447
1782
|
if (!anchor || !anchor.nodeId) {
|
|
1448
1783
|
// The caller can retry via the ghost/ordinal path (imported list-looking
|
|
1449
1784
|
// paragraphs carry no numbering, so no NUMBERED anchor matches here).
|
|
@@ -1487,6 +1822,27 @@ async function runAddListItems(doc, args, opts) {
|
|
|
1487
1822
|
// else: non-numbered paragraph (a continuation) — skip and keep scanning
|
|
1488
1823
|
}
|
|
1489
1824
|
}
|
|
1825
|
+
// A new item SHALLOWER than its anchor cannot sit inside a nested run of
|
|
1826
|
+
// sub-items without cutting that run in two: anchored on the middle of a
|
|
1827
|
+
// sub-list, a promoted top-level item lands between two sub-items and
|
|
1828
|
+
// orphans the ones after it. Carry it past every deeper item first, so it
|
|
1829
|
+
// arrives at the end of the group it now belongs to. (The agent quotes
|
|
1830
|
+
// whichever sibling text it noticed; "after this sub-item" and "a top-level
|
|
1831
|
+
// item here" are contradictory instructions, and outline level wins.)
|
|
1832
|
+
const firstEntryLevel = args.entries[0]?.level;
|
|
1833
|
+
const firstRelative = typeof firstEntryLevel === 'number' ? Math.floor(firstEntryLevel) : 0;
|
|
1834
|
+
if (firstRelative < 0) {
|
|
1835
|
+
const firstTargetLevel = Math.max(0, anchorLevel + firstRelative);
|
|
1836
|
+
const startIdx = rows.findIndex((r) => r.nodeId === subtreeEnd.nodeId);
|
|
1837
|
+
for (let i = startIdx + 1; i >= 1 && i < rows.length; i += 1) {
|
|
1838
|
+
const level = levelOfRow(rows[i]);
|
|
1839
|
+
if (level == null)
|
|
1840
|
+
continue; // non-numbered continuation paragraph
|
|
1841
|
+
if (level <= firstTargetLevel)
|
|
1842
|
+
break;
|
|
1843
|
+
subtreeEnd = rows[i];
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1490
1846
|
const attachFn = maybeMethod(doc, ['lists', 'attach']);
|
|
1491
1847
|
if (!attachFn) {
|
|
1492
1848
|
throw new errors.SuperDocCliError('doc.lists.attach is not available on the document handle.', {
|
|
@@ -1504,7 +1860,10 @@ async function runAddListItems(doc, args, opts) {
|
|
|
1504
1860
|
};
|
|
1505
1861
|
const createdItems = [];
|
|
1506
1862
|
for (const entry of args.entries) {
|
|
1507
|
-
|
|
1863
|
+
// Styled items carry ordered `runs`; a plain item is a single run. The
|
|
1864
|
+
// paragraph is created with the concatenated text, then styled per-run.
|
|
1865
|
+
const runs = entry?.runs && entry.runs.length > 0 ? entry.runs : [{ text: entry?.text ?? '' }];
|
|
1866
|
+
const text = itemSpecText(entry);
|
|
1508
1867
|
// `level` is RELATIVE to the anchor and MAY BE NEGATIVE: positive nests
|
|
1509
1868
|
// deeper, 0 = same level as the anchor, negative promotes toward the top
|
|
1510
1869
|
// (e.g. anchor on a sub-item, level:-1 → a top-level sibling). The final
|
|
@@ -1530,11 +1889,112 @@ async function runAddListItems(doc, args, opts) {
|
|
|
1530
1889
|
result: compactOpResult(attachResult),
|
|
1531
1890
|
rationale: `level ${targetLevel}`,
|
|
1532
1891
|
});
|
|
1533
|
-
createdItems.push({ nodeId: target.nodeId, targetLevel, text });
|
|
1892
|
+
createdItems.push({ nodeId: target.nodeId, targetLevel, text, runs, itemMarks: entry?.marks });
|
|
1534
1893
|
// After attach the block is a listItem (same nodeId) — the next placement
|
|
1535
1894
|
// must address it as such or the create resolver rejects the anchor.
|
|
1536
1895
|
placement = { kind: 'after', target: { kind: 'block', nodeType: 'listItem', nodeId: target.nodeId } };
|
|
1537
1896
|
}
|
|
1897
|
+
// A new item is created next to the ANCHOR, so it inherits the anchor's
|
|
1898
|
+
// direct indentation — correct at the same level, wrong at any other one.
|
|
1899
|
+
// `lists.attach` sets the numbering level (`w:ilvl`) but leaves that direct
|
|
1900
|
+
// `w:ind` alone, so a level-changing add renders with a sub-item's indent
|
|
1901
|
+
// under a top-level number: e.g. an item promoted to level 0 kept
|
|
1902
|
+
// left=1800 while its "1./2./3." siblings sit at 1080. Copy the indentation
|
|
1903
|
+
// from an EXISTING sibling at the item's own level. Best-effort: an
|
|
1904
|
+
// unmatched indent is cosmetic and must not fail an item that landed.
|
|
1905
|
+
const createdIds = new Set(createdItems.map((ci) => ci.nodeId));
|
|
1906
|
+
const anchorKind = blockNumbering(anchor)?.kind ?? null;
|
|
1907
|
+
// Search OUTWARD from the anchor, nearest first, and only accept items
|
|
1908
|
+
// of the anchor's own numbering kind: a document holds several lists,
|
|
1909
|
+
// and the first level-0 item in document order usually belongs to a
|
|
1910
|
+
// different one (here: a bullet list far above the ordered list being
|
|
1911
|
+
// extended), whose indentation is unrelated.
|
|
1912
|
+
const findSiblingAt = (level) => {
|
|
1913
|
+
const usable = (r) => !!r?.nodeId &&
|
|
1914
|
+
!createdIds.has(r.nodeId) &&
|
|
1915
|
+
levelOfRow(r) === level &&
|
|
1916
|
+
(blockNumbering(r)?.kind ?? null) === anchorKind;
|
|
1917
|
+
for (let delta = 1; delta <= rows.length; delta += 1) {
|
|
1918
|
+
const before = anchorIdx >= 0 ? rows[anchorIdx - delta] : undefined;
|
|
1919
|
+
if (usable(before))
|
|
1920
|
+
return before;
|
|
1921
|
+
const after = anchorIdx >= 0 ? rows[anchorIdx + delta] : undefined;
|
|
1922
|
+
if (usable(after))
|
|
1923
|
+
return after;
|
|
1924
|
+
}
|
|
1925
|
+
return undefined;
|
|
1926
|
+
};
|
|
1927
|
+
/** The existing item a new one should look like: same level, nearest first. */
|
|
1928
|
+
const lookSourceFor = (targetLevel) => targetLevel === anchorLevel ? anchor : findSiblingAt(targetLevel);
|
|
1929
|
+
// Most Word documents keep a list's look in a paragraph STYLE, not in direct
|
|
1930
|
+
// properties: "MB Agr 2", "Quick 1.", even Heading3 carry the numbering, the
|
|
1931
|
+
// indentation and the face. A created paragraph does not get the sibling's
|
|
1932
|
+
// style — it gets the anchor's `next` style (Heading3 → BodyText), a default
|
|
1933
|
+
// (ListParagraph, whose contextualSpacing changes the spacing), or the
|
|
1934
|
+
// anchor's own style when the new item belongs to a DIFFERENT level (keeping
|
|
1935
|
+
// "MB Agr 3" on an item whose numbering now says level 2). Reconcile it with
|
|
1936
|
+
// the sibling at the item's own level.
|
|
1937
|
+
//
|
|
1938
|
+
// setStyleRef, not setStyle: setStyle clears direct run formatting, which
|
|
1939
|
+
// would wipe the sampled run look applied below.
|
|
1940
|
+
const styledItems = [];
|
|
1941
|
+
const styleSkipped = await bestEffort(async () => {
|
|
1942
|
+
const setStyleRefFn = maybeMethod(doc, ['styles', 'paragraph', 'setStyleRef']);
|
|
1943
|
+
const clearStyleFn = maybeMethod(doc, ['styles', 'paragraph', 'clearStyle']);
|
|
1944
|
+
if (!setStyleRefFn && !clearStyleFn)
|
|
1945
|
+
return;
|
|
1946
|
+
let current = null;
|
|
1947
|
+
for (const ci of createdItems) {
|
|
1948
|
+
const source = lookSourceFor(ci.targetLevel);
|
|
1949
|
+
if (!source)
|
|
1950
|
+
continue;
|
|
1951
|
+
const wanted = source.styleId ?? null;
|
|
1952
|
+
current ??= await listBlockRows(doc);
|
|
1953
|
+
const created = current.find((r) => r.nodeId === ci.nodeId);
|
|
1954
|
+
if ((created?.styleId ?? null) === wanted)
|
|
1955
|
+
continue;
|
|
1956
|
+
const target = { kind: 'block', nodeType: created?.nodeType ?? 'listItem', nodeId: ci.nodeId };
|
|
1957
|
+
if (wanted && setStyleRefFn)
|
|
1958
|
+
await setStyleRefFn({ target, styleId: wanted });
|
|
1959
|
+
else if (!wanted && clearStyleFn)
|
|
1960
|
+
await clearStyleFn({ target });
|
|
1961
|
+
else
|
|
1962
|
+
continue;
|
|
1963
|
+
styledItems.push({ nodeId: ci.nodeId, styleId: wanted });
|
|
1964
|
+
}
|
|
1965
|
+
});
|
|
1966
|
+
const indentedItems = [];
|
|
1967
|
+
const indentSkipped = await bestEffort(async () => {
|
|
1968
|
+
const setIndentFn = maybeMethod(doc, ['format', 'paragraph', 'setIndentation']);
|
|
1969
|
+
const getNodeFn = maybeMethod(doc, ['getNodeById']);
|
|
1970
|
+
if (!setIndentFn || !getNodeFn)
|
|
1971
|
+
return;
|
|
1972
|
+
for (const ci of createdItems) {
|
|
1973
|
+
const sibling = lookSourceFor(ci.targetLevel);
|
|
1974
|
+
if (!sibling?.nodeId)
|
|
1975
|
+
continue;
|
|
1976
|
+
const node = (await getNodeFn({ id: sibling.nodeId }));
|
|
1977
|
+
const indent = paragraphIndentOf(node);
|
|
1978
|
+
if (!indent)
|
|
1979
|
+
continue;
|
|
1980
|
+
// COMPARE rather than assume: a same-level item usually inherits the
|
|
1981
|
+
// anchor's indentation on creation, but not always — one real document
|
|
1982
|
+
// produced items with no `w:ind` at all beside siblings indented 851.
|
|
1983
|
+
const created = paragraphIndentOf(await getNodeFn({ id: ci.nodeId }));
|
|
1984
|
+
if (created && ['left', 'right', 'firstLine', 'hanging'].every((k) => created[k] === indent[k])) {
|
|
1985
|
+
continue;
|
|
1986
|
+
}
|
|
1987
|
+
await setIndentFn({
|
|
1988
|
+
target: { kind: 'block', nodeType: 'listItem', nodeId: ci.nodeId },
|
|
1989
|
+
// The read side reports indentation in POINTS while setIndentation
|
|
1990
|
+
// takes TWIPS — passing the sampled numbers through unconverted
|
|
1991
|
+
// indents by a twentieth of the intended amount, which reads as
|
|
1992
|
+
// "the copy silently did nothing".
|
|
1993
|
+
...indent,
|
|
1994
|
+
});
|
|
1995
|
+
indentedItems.push({ nodeId: ci.nodeId, from: sibling.nodeId });
|
|
1996
|
+
}
|
|
1997
|
+
});
|
|
1538
1998
|
// Report WHERE each item actually landed (rendered marker + outline level),
|
|
1539
1999
|
// so the caller can trust the result instead of re-deriving it and undoing
|
|
1540
2000
|
// correct work when the level looked ambiguous.
|
|
@@ -1543,63 +2003,220 @@ async function runAddListItems(doc, args, opts) {
|
|
|
1543
2003
|
try {
|
|
1544
2004
|
afterRows = await listBlockRows(doc);
|
|
1545
2005
|
landed = createdItems.map((ci) => {
|
|
1546
|
-
const
|
|
2006
|
+
const idx = afterRows.findIndex((r) => r.nodeId === ci.nodeId);
|
|
2007
|
+
const row = idx >= 0 ? afterRows[idx] : undefined;
|
|
1547
2008
|
const num = row ? blockNumbering(row) : null;
|
|
1548
2009
|
const p = num?.path;
|
|
1549
|
-
|
|
2010
|
+
const snippet = (r) => (r?.textPreview ? r.textPreview.slice(0, 60) : null);
|
|
2011
|
+
return {
|
|
2012
|
+
marker: num?.marker ?? null,
|
|
2013
|
+
level: Array.isArray(p) ? Math.max(0, p.length - 1) : ci.targetLevel,
|
|
2014
|
+
// Only a read-back that FOUND the created node counts as verified;
|
|
2015
|
+
// otherwise the level is the requested one, not an observed one.
|
|
2016
|
+
verified: idx >= 0,
|
|
2017
|
+
// Immediate neighboring BLOCKS (may be continuation paragraphs, or —
|
|
2018
|
+
// for multi-entry adds — the other new items) so the caller can
|
|
2019
|
+
// verify PLACEMENT from the receipt instead of re-inspecting a wide
|
|
2020
|
+
// block window.
|
|
2021
|
+
previousBlock: idx > 0 ? snippet(afterRows[idx - 1]) : null,
|
|
2022
|
+
nextBlock: idx >= 0 && idx + 1 < afterRows.length ? snippet(afterRows[idx + 1]) : null,
|
|
2023
|
+
};
|
|
1550
2024
|
});
|
|
1551
2025
|
}
|
|
1552
2026
|
catch {
|
|
1553
2027
|
// best-effort read-back; the mutation already applied
|
|
1554
2028
|
}
|
|
1555
|
-
//
|
|
1556
|
-
//
|
|
1557
|
-
//
|
|
1558
|
-
//
|
|
1559
|
-
//
|
|
1560
|
-
|
|
2029
|
+
// Format each new item in four layers, later layers overriding earlier on
|
|
2030
|
+
// overlapping keys (successive format.apply calls):
|
|
2031
|
+
// 1. BASE — the sampled look of the existing item at the same level, over
|
|
2032
|
+
// the whole item, so an item added with no explicit styling still
|
|
2033
|
+
// matches its siblings' font/size/colour (fixes the plain-insert
|
|
2034
|
+
// mismatch where the new item fell back to style defaults instead of
|
|
2035
|
+
// the list's direct run formatting).
|
|
2036
|
+
// 2. LEAD — the siblings' "bold label: plain sentence" convention over
|
|
2037
|
+
// the new item's own label span, when the item requested no styling of
|
|
2038
|
+
// its own. Without it a plain add reads as the odd one out even though
|
|
2039
|
+
// every other property matches: the agent is only told the item TEXT of
|
|
2040
|
+
// its siblings, never that they open with a bold lead-in.
|
|
2041
|
+
// 3. ITEM — whole-item `marks` the agent supplied (e.g. Arial 16pt on the
|
|
2042
|
+
// entire item).
|
|
2043
|
+
// 4. RUN — per-segment `marks` over each run's char range, so the agent
|
|
2044
|
+
// can replicate an intra-item pattern (e.g. second word italic).
|
|
2045
|
+
// Best-effort: a format.apply failure must NEVER fail an otherwise-landed
|
|
2046
|
+
// add, so the items still exist.
|
|
2047
|
+
let formattingMatched = { skipped: 'no formatting applied' };
|
|
2048
|
+
let leadInMatched = 0;
|
|
2049
|
+
// What the base layer actually asked for, sampled look included — a receipt
|
|
2050
|
+
// reading `base: {}` hides the run sample the row read cannot see.
|
|
2051
|
+
let reportedBase = null;
|
|
1561
2052
|
try {
|
|
1562
|
-
|
|
2053
|
+
// The BASE layer must come from the row the item will sit beside, not
|
|
2054
|
+
// from the anchor: a promoted or demoted item crosses levels, and the
|
|
2055
|
+
// anchor's font/size/colour/emphasis is then the wrong level's look.
|
|
2056
|
+
// `lookSourceFor(targetLevel)` already picks the destination sibling for
|
|
2057
|
+
// style and indent, so the look follows the same source. Falls back to
|
|
2058
|
+
// the anchor only when the destination level has no sibling to sample.
|
|
2059
|
+
const anchorBase = inlineLookFromRow(anchor);
|
|
1563
2060
|
const formatFn = maybeMethod(doc, ['format', 'apply']);
|
|
1564
|
-
if (
|
|
2061
|
+
if (formatFn) {
|
|
2062
|
+
const applied = [];
|
|
2063
|
+
let redundantLayers = 0;
|
|
2064
|
+
const applyRange = async (blockId, start, end, inline) => {
|
|
2065
|
+
if (Object.keys(inline).length === 0 || end <= start)
|
|
2066
|
+
return;
|
|
2067
|
+
// Styling runs DIRECT even in tracked mode: these ranges only ever
|
|
2068
|
+
// target items created in THIS call, whose tracked insertion already
|
|
2069
|
+
// owns their accept/reject fate. Tracked format.apply DOES land the
|
|
2070
|
+
// styling, but stacks an rPrChange revision on top of the insertion,
|
|
2071
|
+
// so the reviewer sees a redundant "formatting changed" card.
|
|
2072
|
+
const params = { blockId, start, end, inline };
|
|
2073
|
+
try {
|
|
2074
|
+
await formatFn(params);
|
|
2075
|
+
applied.push({ range: [start, end], inline });
|
|
2076
|
+
}
|
|
2077
|
+
catch (err) {
|
|
2078
|
+
// The layers deliberately overlap, so a later layer often requests
|
|
2079
|
+
// formatting a new item ALREADY has (it inherits the anchor's look
|
|
2080
|
+
// on creation) — format.apply rejects that with NO_OP. A redundant
|
|
2081
|
+
// layer means "nothing left to do here", never "stop styling": each
|
|
2082
|
+
// layer must be isolated, or one NO_OP cancels every layer after it
|
|
2083
|
+
// and the agent's explicit per-run marks silently vanish.
|
|
2084
|
+
if (!isNoOp(err))
|
|
2085
|
+
throw err;
|
|
2086
|
+
redundantLayers += 1;
|
|
2087
|
+
}
|
|
2088
|
+
};
|
|
1565
2089
|
for (const ci of createdItems) {
|
|
1566
|
-
const
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
//
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
await
|
|
2090
|
+
const len = Math.max(ci.text.length, 1);
|
|
2091
|
+
// An item the agent styled itself is left alone by layers 1–2: its
|
|
2092
|
+
// marks describe the whole intent, and sampling would fight them.
|
|
2093
|
+
const unstyled = ci.itemMarks == null && !ci.runs.some((r) => r.marks);
|
|
2094
|
+
const source = unstyled ? lookSourceFor(ci.targetLevel) : undefined;
|
|
2095
|
+
const sourceText = source?.nodeId ? fullTextById.get(source.nodeId) : undefined;
|
|
2096
|
+
const pattern = source?.nodeId && sourceText ? await listItemRunPattern(doc, sourceText, source.nodeId) : null;
|
|
2097
|
+
// 1. sampled base over the whole item (row fields first, run-level
|
|
2098
|
+
// sample second: the row read omits run styling entirely).
|
|
2099
|
+
const destinationBase = source ? inlineLookFromRow(source) : anchorBase;
|
|
2100
|
+
const merged = { ...destinationBase, ...pattern?.body };
|
|
2101
|
+
reportedBase = reportedBase ?? merged;
|
|
2102
|
+
await applyRange(ci.nodeId, 0, len, merged);
|
|
2103
|
+
// 2. the siblings' label look over this item's own label span
|
|
2104
|
+
if (pattern?.lead) {
|
|
2105
|
+
const span = leadInSpanOf(ci.text, pattern.lead.shape);
|
|
2106
|
+
if (span) {
|
|
2107
|
+
await applyRange(ci.nodeId, span.start, span.end, pattern.lead.look);
|
|
2108
|
+
leadInMatched += 1;
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
// 3. whole-item marks
|
|
2112
|
+
await applyRange(ci.nodeId, 0, len, marksToInline(ci.itemMarks));
|
|
2113
|
+
// 4. per-run marks over each run's char range, each relative to the
|
|
2114
|
+
// baseline layers 1–3 established, so an unmarked run reads plain
|
|
2115
|
+
// instead of keeping emphasis it inherited.
|
|
2116
|
+
const runBaseline = { ...merged, ...marksToInline(ci.itemMarks) };
|
|
2117
|
+
// Only an EXPLICIT pattern clears inherited marks. A plain item is
|
|
2118
|
+
// stored as one synthesized, unmarked run — clearing there would undo
|
|
2119
|
+
// the very look layer 1 just sampled from the sibling.
|
|
2120
|
+
const explicitRunPattern = ci.runs.length > 1 || ci.runs.some((r) => r.marks != null);
|
|
2121
|
+
let offset = 0;
|
|
2122
|
+
for (const run of ci.runs) {
|
|
2123
|
+
const runLen = run.text.length;
|
|
2124
|
+
const runInline = explicitRunPattern
|
|
2125
|
+
? runInlineAgainstBaseline(run.marks, runBaseline)
|
|
2126
|
+
: marksToInline(run.marks);
|
|
2127
|
+
if (Object.keys(runInline).length > 0)
|
|
2128
|
+
await applyRange(ci.nodeId, offset, offset + runLen, runInline);
|
|
2129
|
+
offset += runLen;
|
|
2130
|
+
}
|
|
1579
2131
|
}
|
|
1580
|
-
formattingMatched =
|
|
2132
|
+
formattingMatched =
|
|
2133
|
+
applied.length > 0
|
|
2134
|
+
? {
|
|
2135
|
+
base: reportedBase ?? {},
|
|
2136
|
+
layers: applied.length,
|
|
2137
|
+
...(leadInMatched > 0 ? { leadInMatched } : {}),
|
|
2138
|
+
...(redundantLayers > 0 ? { alreadyMatched: redundantLayers } : {}),
|
|
2139
|
+
}
|
|
2140
|
+
: { skipped: 'no formatting applied' };
|
|
1581
2141
|
}
|
|
1582
2142
|
}
|
|
1583
2143
|
catch (err) {
|
|
1584
|
-
formattingMatched = { skipped:
|
|
1585
|
-
}
|
|
2144
|
+
formattingMatched = { skipped: reasonOf(err) };
|
|
2145
|
+
}
|
|
2146
|
+
// Word paints the LIST NUMBER from the paragraph mark's run properties
|
|
2147
|
+
// (w:pPr/w:rPr), not from the item's text runs — so an item whose text is
|
|
2148
|
+
// styled correctly still shows a default-black "4." next to bold blue
|
|
2149
|
+
// "1./2./3." siblings. Mirror the item's leading run onto the mark, which
|
|
2150
|
+
// is the relationship those siblings already encode. Best-effort and never
|
|
2151
|
+
// fatal: the item and its text styling have already landed.
|
|
2152
|
+
let markStyledItems = 0;
|
|
2153
|
+
const markerSkipped = await bestEffort(async () => {
|
|
2154
|
+
const setMarkFn = maybeMethod(doc, ['format', 'paragraph', 'setMarkRunProps']);
|
|
2155
|
+
const getNodeFn = maybeMethod(doc, ['getNodeById']);
|
|
2156
|
+
if (!setMarkFn)
|
|
2157
|
+
return;
|
|
2158
|
+
for (const ci of createdItems) {
|
|
2159
|
+
// Explicit marks describe the intent — the whole-item ones AND the
|
|
2160
|
+
// leading run's, merged in that order, so a marker never loses the
|
|
2161
|
+
// item's colour just because the first run also asked for bold.
|
|
2162
|
+
//
|
|
2163
|
+
// With no marks at all, COPY the sibling's own paragraph mark instead of
|
|
2164
|
+
// deriving one from the text runs: a marker must not gain bold its
|
|
2165
|
+
// siblings' markers don't have (bullet lists routinely carry a bare mark
|
|
2166
|
+
// next to bold-lead-in text), and must gain it where they do. A
|
|
2167
|
+
// same-level add already inherits the anchor's mark on creation; this
|
|
2168
|
+
// covers the level-CHANGING add, whose inherited mark came from the
|
|
2169
|
+
// wrong level. Reads nothing today: `getNodeById` reports
|
|
2170
|
+
// indent/alignment/spacing but not `markRunProps`, so an unstyled
|
|
2171
|
+
// promotion keeps the plain marker until that read lands.
|
|
2172
|
+
let markProps = markRunPropsFromMarks({ ...ci.itemMarks, ...ci.runs[0]?.marks });
|
|
2173
|
+
if (!markProps && getNodeFn) {
|
|
2174
|
+
const source = lookSourceFor(ci.targetLevel);
|
|
2175
|
+
if (source?.nodeId)
|
|
2176
|
+
markProps = paragraphMarkRunPropsOf(await getNodeFn({ id: source.nodeId }));
|
|
2177
|
+
}
|
|
2178
|
+
if (!markProps)
|
|
2179
|
+
continue;
|
|
2180
|
+
await setMarkFn({
|
|
2181
|
+
target: { kind: 'block', nodeType: 'paragraph', nodeId: ci.nodeId },
|
|
2182
|
+
markRunProps: markProps,
|
|
2183
|
+
});
|
|
2184
|
+
markStyledItems += 1;
|
|
2185
|
+
}
|
|
2186
|
+
});
|
|
1586
2187
|
const postIdentity = await readDocumentIdentity(doc);
|
|
1587
2188
|
const verification = [revisionVerification(pre.revision, postIdentity.revision, true)];
|
|
1588
2189
|
return {
|
|
1589
2190
|
status: verification.every((v) => v.passed) ? 'ok' : 'failed',
|
|
1590
2191
|
intent: `add_list_items: ${args.entries.length} into list @ ${anchor.nodeId}`,
|
|
2192
|
+
...(styledItems.length ? { styleMatched: styledItems.map((i) => i.styleId ?? '(none)') } : {}),
|
|
2193
|
+
...(styleSkipped ? { styleSkipped } : {}),
|
|
2194
|
+
...(indentedItems.length ? { indentMatchedFrom: indentedItems.map((i) => i.from) } : {}),
|
|
2195
|
+
...(indentSkipped ? { indentSkipped } : {}),
|
|
2196
|
+
...(markStyledItems > 0 ? { markerStyled: markStyledItems } : {}),
|
|
2197
|
+
...(markerSkipped ? { markerSkipped } : {}),
|
|
1591
2198
|
...(landed.length
|
|
1592
2199
|
? {
|
|
1593
2200
|
note: `landed at ${landed
|
|
1594
|
-
.map((l) =>
|
|
1595
|
-
.
|
|
2201
|
+
.map((l) => {
|
|
2202
|
+
const pos = l.marker ? `${l.marker} (level ${l.level})` : `level ${l.level}`;
|
|
2203
|
+
const between = l.previousBlock || l.nextBlock
|
|
2204
|
+
? ` between ${JSON.stringify(l.previousBlock ?? '(list start)')} and ${JSON.stringify(l.nextBlock ?? '(list end)')}`
|
|
2205
|
+
: '';
|
|
2206
|
+
return pos + between;
|
|
2207
|
+
})
|
|
2208
|
+
.join(', ')}${landed.every((l) => l.verified)
|
|
2209
|
+
? ' — placement is verified; re-inspect only if this looks wrong'
|
|
2210
|
+
: ' — read-back incomplete; verify with a NARROW inspect window around the anchor'}`,
|
|
1596
2211
|
addedItems: landed,
|
|
1597
2212
|
}
|
|
1598
2213
|
: {}),
|
|
1599
2214
|
formattingMatched,
|
|
1600
2215
|
preSnapshot: { revision: pre.revision, counts: pre.counts },
|
|
1601
2216
|
postSnapshot: postIdentity,
|
|
1602
|
-
selectedTargets: [
|
|
2217
|
+
selectedTargets: [
|
|
2218
|
+
{ selector: { kind: 'textSearch', terms: [needle ?? anchor.textPreview ?? ''] }, matched: [anchor.nodeId] },
|
|
2219
|
+
],
|
|
1603
2220
|
executedOperations: executed,
|
|
1604
2221
|
verification,
|
|
1605
2222
|
};
|
|
@@ -1684,6 +2301,13 @@ async function runCreateTable(doc, args) {
|
|
|
1684
2301
|
rationale: `Populated ${appliedCells.length} table cells.`,
|
|
1685
2302
|
});
|
|
1686
2303
|
}
|
|
2304
|
+
const styledCount = await styleTableCells(doc, appliedCells, args.changeMode);
|
|
2305
|
+
if (styledCount > 0) {
|
|
2306
|
+
executedOperations.push({
|
|
2307
|
+
operationId: 'doc.format.apply',
|
|
2308
|
+
rationale: `Styled ${styledCount} table cell(s).`,
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
1687
2311
|
}
|
|
1688
2312
|
const post = await docSnapshot.buildDocumentSnapshot(doc);
|
|
1689
2313
|
const verification = evaluateChecks(pre, post, [
|
|
@@ -1855,7 +2479,7 @@ async function runResolveComments(doc, args) {
|
|
|
1855
2479
|
result = await patchFn({ commentId: c.id, status: newStatus });
|
|
1856
2480
|
}
|
|
1857
2481
|
catch (e) {
|
|
1858
|
-
const msg =
|
|
2482
|
+
const msg = reasonOf(e);
|
|
1859
2483
|
if (/unknown field|commentId/i.test(msg)) {
|
|
1860
2484
|
result = await patchFn({ id: c.id, status: newStatus });
|
|
1861
2485
|
}
|
|
@@ -1967,6 +2591,11 @@ async function runRewriteBlock(doc, args) {
|
|
|
1967
2591
|
return failedReceipt('rewrite_block', new Error('selector did not resolve to a body block'), pre);
|
|
1968
2592
|
}
|
|
1969
2593
|
const normalizedText = normalizeTitleLikeRewriteText(target.text, args.text);
|
|
2594
|
+
// The engine rewrites the whole block as ONE uniform run — losing an
|
|
2595
|
+
// intra-paragraph pattern (bold "(n)" lead-in), or smearing the first
|
|
2596
|
+
// run's marks across the entire text. Capture the current run layout so
|
|
2597
|
+
// it can be re-applied over the unchanged prefix/suffix afterward.
|
|
2598
|
+
const preRuns = await captureBlockRuns(doc, target.nodeId, target.text);
|
|
1970
2599
|
const steps = [
|
|
1971
2600
|
{
|
|
1972
2601
|
id: 'rewrite-block-1',
|
|
@@ -1979,6 +2608,7 @@ async function runRewriteBlock(doc, args) {
|
|
|
1979
2608
|
},
|
|
1980
2609
|
];
|
|
1981
2610
|
const result = await executeMutations(doc, steps, args.changeMode);
|
|
2611
|
+
const preservation = await preserveRunPatternAfterRewrite(doc, target.nodeId, target.text, preRuns, normalizedText);
|
|
1982
2612
|
const post = await docSnapshot.buildDocumentSnapshot(doc);
|
|
1983
2613
|
const rewrittenBlock = findSnapshotTextByNodeId(post, target.nodeId);
|
|
1984
2614
|
const verification = [
|
|
@@ -1996,7 +2626,10 @@ async function runRewriteBlock(doc, args) {
|
|
|
1996
2626
|
preSnapshot: { revision: pre.revision, counts: pre.counts },
|
|
1997
2627
|
postSnapshot: { revision: post.revision, counts: post.counts },
|
|
1998
2628
|
selectedTargets: [{ selector: args.selector, matched: [target.nodeId] }],
|
|
1999
|
-
executedOperations: [
|
|
2629
|
+
executedOperations: [
|
|
2630
|
+
{ operationId: 'doc.mutations.apply', result },
|
|
2631
|
+
...(preservation ? [{ operationId: 'doc.format.apply', result: preservation }] : []),
|
|
2632
|
+
],
|
|
2000
2633
|
verification,
|
|
2001
2634
|
errors: changed && rewritten
|
|
2002
2635
|
? undefined
|
|
@@ -2014,6 +2647,238 @@ async function runRewriteBlock(doc, args) {
|
|
|
2014
2647
|
return failedReceipt('rewrite_block', err, pre);
|
|
2015
2648
|
}
|
|
2016
2649
|
}
|
|
2650
|
+
const RUN_BOOL_MARKS = ['bold', 'italic', 'underline', 'strike'];
|
|
2651
|
+
/**
|
|
2652
|
+
* The format.apply inline bag reproducing a SnapshotRun's marks. Boolean marks
|
|
2653
|
+
* listed in `normalize` are always emitted (true OR explicit false) so ranges
|
|
2654
|
+
* that were plain actively CLEAR a mark the engine smeared across the block.
|
|
2655
|
+
*/
|
|
2656
|
+
function inlineFromSnapshotRun(run, normalize) {
|
|
2657
|
+
const inline = {};
|
|
2658
|
+
for (const key of RUN_BOOL_MARKS) {
|
|
2659
|
+
if (run[key])
|
|
2660
|
+
inline[key] = true;
|
|
2661
|
+
else if (normalize.has(key))
|
|
2662
|
+
inline[key] = false;
|
|
2663
|
+
}
|
|
2664
|
+
if (run.fontFamily)
|
|
2665
|
+
inline.fontFamily = run.fontFamily;
|
|
2666
|
+
if (run.fontSize)
|
|
2667
|
+
inline.fontSize = run.fontSize;
|
|
2668
|
+
if (run.color)
|
|
2669
|
+
inline.color = run.color;
|
|
2670
|
+
return inline;
|
|
2671
|
+
}
|
|
2672
|
+
/**
|
|
2673
|
+
* format.apply that tolerates the engine's "produced no change" rejection —
|
|
2674
|
+
* for these restore/pattern passes a no-op range (marks already in the
|
|
2675
|
+
* requested state) is success, and throwing would abandon the REMAINING
|
|
2676
|
+
* ranges of the pass.
|
|
2677
|
+
*/
|
|
2678
|
+
async function applyInlineQuiet(formatFn, params) {
|
|
2679
|
+
try {
|
|
2680
|
+
await formatFn(params);
|
|
2681
|
+
return true;
|
|
2682
|
+
}
|
|
2683
|
+
catch (err) {
|
|
2684
|
+
const message = err instanceof Error ? err.message : String(err?.message ?? err);
|
|
2685
|
+
if (/produced no change/i.test(message))
|
|
2686
|
+
return false;
|
|
2687
|
+
throw err;
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
/** Best-effort read of a block's current run layout (null when unavailable). */
|
|
2691
|
+
async function captureBlockRuns(doc, nodeId, text) {
|
|
2692
|
+
const queryFn = maybeMethod(doc, ['query', 'match']);
|
|
2693
|
+
const trimmed = text.trim();
|
|
2694
|
+
if (!queryFn || !trimmed || text.includes('\n'))
|
|
2695
|
+
return null;
|
|
2696
|
+
try {
|
|
2697
|
+
const runs = await docSnapshot.matchRunsForBlock(queryFn, trimmed, nodeId);
|
|
2698
|
+
return runs.length > 0 ? runs : null;
|
|
2699
|
+
}
|
|
2700
|
+
catch {
|
|
2701
|
+
return null;
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
/**
|
|
2705
|
+
* Re-apply a block's pre-rewrite run pattern after a whole-block text.rewrite.
|
|
2706
|
+
* The engine renders the rewritten block as a single uniform run, which both
|
|
2707
|
+
* drops intra-paragraph formatting AND can smear the first run's marks over
|
|
2708
|
+
* everything. Marks are restored positionally over the parts of the text the
|
|
2709
|
+
* rewrite did NOT change (the common prefix and suffix of old vs new text),
|
|
2710
|
+
* and the changed middle takes the marks of the run it continues from —
|
|
2711
|
+
* Word's "typing continues formatting" rule. Best-effort: never fails the
|
|
2712
|
+
* rewrite.
|
|
2713
|
+
*/
|
|
2714
|
+
/**
|
|
2715
|
+
* Span-aware run-pattern restore for scoped replaces. The generic
|
|
2716
|
+
* preserveRunPatternAfterRewrite diffs prefix/suffix and treats everything
|
|
2717
|
+
* between the FIRST and LAST change as one modified middle — with two
|
|
2718
|
+
* separated replacements that flattens independently-styled runs between
|
|
2719
|
+
* them. Here the exact spans are known, so every untouched interval keeps its
|
|
2720
|
+
* original run formatting at the offset-mapped position, and each replacement
|
|
2721
|
+
* inherits the marks of the run it starts in.
|
|
2722
|
+
* `spans` are FULL-TEXT coordinates, ascending, non-overlapping.
|
|
2723
|
+
*/
|
|
2724
|
+
async function preserveScopedRunPattern(doc, nodeId, oldText, oldRuns, spans) {
|
|
2725
|
+
if (!oldRuns || oldRuns.length === 0 || spans.length === 0)
|
|
2726
|
+
return null;
|
|
2727
|
+
if (!oldRuns.some((run) => Object.keys(inlineFromSnapshotRun(run, new Set())).length > 0))
|
|
2728
|
+
return null;
|
|
2729
|
+
const formatFn = maybeMethod(doc, ['format', 'apply']);
|
|
2730
|
+
if (!formatFn)
|
|
2731
|
+
return null;
|
|
2732
|
+
const normalize = new Set(RUN_BOOL_MARKS.filter((key) => oldRuns.some((run) => run[key])));
|
|
2733
|
+
// Run offsets (captureBlockRuns) are relative to the TRIMMED text; spans are
|
|
2734
|
+
// full-text. Convert spans to trimmed coordinates and clamp.
|
|
2735
|
+
const oldTrimStart = oldText.length - oldText.trimStart().length;
|
|
2736
|
+
const oldTrimLen = oldText.trim().length;
|
|
2737
|
+
const trimSpans = spans
|
|
2738
|
+
.map((s) => ({
|
|
2739
|
+
start: Math.max(0, s.start - oldTrimStart),
|
|
2740
|
+
end: Math.min(oldTrimLen, s.end - oldTrimStart),
|
|
2741
|
+
replaceLen: (s.replace ?? '').length,
|
|
2742
|
+
}))
|
|
2743
|
+
.filter((s) => s.end > s.start || s.replaceLen > 0);
|
|
2744
|
+
if (trimSpans.length === 0)
|
|
2745
|
+
return null;
|
|
2746
|
+
const newText = spans.reduceRight((text, span) => text.slice(0, span.start) + span.replace + text.slice(span.end), oldText);
|
|
2747
|
+
const newTrimStart = newText.length - newText.trimStart().length;
|
|
2748
|
+
// Cumulative old→new offset shift for positions AT OR AFTER a given old offset.
|
|
2749
|
+
const deltaBefore = (oldPos) => {
|
|
2750
|
+
let d = 0;
|
|
2751
|
+
for (const s of trimSpans) {
|
|
2752
|
+
if (s.end <= oldPos)
|
|
2753
|
+
d += s.replaceLen - (s.end - s.start);
|
|
2754
|
+
else
|
|
2755
|
+
break;
|
|
2756
|
+
}
|
|
2757
|
+
return d;
|
|
2758
|
+
};
|
|
2759
|
+
let applied = 0;
|
|
2760
|
+
const applyRange = async (start, end, inline) => {
|
|
2761
|
+
if (end <= start || Object.keys(inline).length === 0)
|
|
2762
|
+
return;
|
|
2763
|
+
// Always direct, even under tracked mode. This pass RESTORES the marks the
|
|
2764
|
+
// rewrite flattened; it is not new authorial formatting, and the text
|
|
2765
|
+
// revision already owns the span's accept/reject fate. Applying it tracked
|
|
2766
|
+
// adds a second reviewable change per rewrite (SD-4309's conformance matrix
|
|
2767
|
+
// counts one per action), and rejecting that change alone would strand the
|
|
2768
|
+
// text in the flattened look the rewrite produced.
|
|
2769
|
+
const changed = await applyInlineQuiet(formatFn, {
|
|
2770
|
+
blockId: nodeId,
|
|
2771
|
+
start: newTrimStart + start,
|
|
2772
|
+
end: newTrimStart + end,
|
|
2773
|
+
inline,
|
|
2774
|
+
});
|
|
2775
|
+
if (changed)
|
|
2776
|
+
applied += 1;
|
|
2777
|
+
};
|
|
2778
|
+
try {
|
|
2779
|
+
// Untouched intervals between/around the spans, in old trimmed coords.
|
|
2780
|
+
const intervals = [];
|
|
2781
|
+
let cursor = 0;
|
|
2782
|
+
for (const s of trimSpans) {
|
|
2783
|
+
if (s.start > cursor)
|
|
2784
|
+
intervals.push({ start: cursor, end: s.start });
|
|
2785
|
+
cursor = Math.max(cursor, s.end);
|
|
2786
|
+
}
|
|
2787
|
+
if (cursor < oldTrimLen)
|
|
2788
|
+
intervals.push({ start: cursor, end: oldTrimLen });
|
|
2789
|
+
for (const run of oldRuns) {
|
|
2790
|
+
const inline = inlineFromSnapshotRun(run, normalize);
|
|
2791
|
+
for (const iv of intervals) {
|
|
2792
|
+
const a = Math.max(run.start, iv.start);
|
|
2793
|
+
const b = Math.min(run.end, iv.end);
|
|
2794
|
+
if (b <= a)
|
|
2795
|
+
continue;
|
|
2796
|
+
const d = deltaBefore(a); // constant across an untouched interval
|
|
2797
|
+
await applyRange(a + d, b + d, inline);
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
// Each replacement span inherits the marks of the run it starts in.
|
|
2801
|
+
for (const s of trimSpans) {
|
|
2802
|
+
if (s.replaceLen === 0)
|
|
2803
|
+
continue;
|
|
2804
|
+
const anchorRun = oldRuns.find((run) => s.start >= run.start && s.start < run.end) ?? oldRuns[oldRuns.length - 1];
|
|
2805
|
+
const newStart = s.start + deltaBefore(s.start);
|
|
2806
|
+
await applyRange(newStart, newStart + s.replaceLen, inlineFromSnapshotRun(anchorRun, normalize));
|
|
2807
|
+
}
|
|
2808
|
+
return applied > 0 ? { restoredRunRanges: applied } : null;
|
|
2809
|
+
}
|
|
2810
|
+
catch (err) {
|
|
2811
|
+
return { skipped: reasonOf(err) };
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2814
|
+
async function preserveRunPatternAfterRewrite(doc, nodeId, oldText, oldRuns, newText) {
|
|
2815
|
+
// Nothing to restore when the block was uniformly plain; a single STYLED run
|
|
2816
|
+
// still needs restoring (whole-block replaces drop even uniform marks).
|
|
2817
|
+
if (!oldRuns || oldRuns.length === 0)
|
|
2818
|
+
return null;
|
|
2819
|
+
if (!oldRuns.some((run) => Object.keys(inlineFromSnapshotRun(run, new Set())).length > 0))
|
|
2820
|
+
return null;
|
|
2821
|
+
const formatFn = maybeMethod(doc, ['format', 'apply']);
|
|
2822
|
+
if (!formatFn)
|
|
2823
|
+
return null;
|
|
2824
|
+
// Boolean marks any old run carried: plain ranges must explicitly clear
|
|
2825
|
+
// these, because the rewritten single run may have smeared them everywhere.
|
|
2826
|
+
const normalize = new Set(RUN_BOOL_MARKS.filter((key) => oldRuns.some((run) => run[key])));
|
|
2827
|
+
// query.match run offsets are relative to the trimmed text used to match.
|
|
2828
|
+
const oldTrim = oldText.trim();
|
|
2829
|
+
const newTrimStart = newText.length - newText.trimStart().length;
|
|
2830
|
+
const newTrim = newText.trim();
|
|
2831
|
+
let prefix = 0;
|
|
2832
|
+
const maxCommon = Math.min(oldTrim.length, newTrim.length);
|
|
2833
|
+
while (prefix < maxCommon && oldTrim[prefix] === newTrim[prefix])
|
|
2834
|
+
prefix += 1;
|
|
2835
|
+
let suffix = 0;
|
|
2836
|
+
while (suffix < maxCommon - prefix && oldTrim[oldTrim.length - 1 - suffix] === newTrim[newTrim.length - 1 - suffix])
|
|
2837
|
+
suffix += 1;
|
|
2838
|
+
const delta = newTrim.length - oldTrim.length;
|
|
2839
|
+
let applied = 0;
|
|
2840
|
+
const applyRange = async (start, end, inline) => {
|
|
2841
|
+
if (end <= start || Object.keys(inline).length === 0)
|
|
2842
|
+
return;
|
|
2843
|
+
// Always direct, even under tracked mode. This pass RESTORES the marks the
|
|
2844
|
+
// rewrite flattened; it is not new authorial formatting, and the text
|
|
2845
|
+
// revision already owns the span's accept/reject fate. Applying it tracked
|
|
2846
|
+
// adds a second reviewable change per rewrite (SD-4309's conformance matrix
|
|
2847
|
+
// counts one per action), and rejecting that change alone would strand the
|
|
2848
|
+
// text in the flattened look the rewrite produced.
|
|
2849
|
+
const changed = await applyInlineQuiet(formatFn, {
|
|
2850
|
+
blockId: nodeId,
|
|
2851
|
+
start: newTrimStart + start,
|
|
2852
|
+
end: newTrimStart + end,
|
|
2853
|
+
inline,
|
|
2854
|
+
});
|
|
2855
|
+
if (changed)
|
|
2856
|
+
applied += 1;
|
|
2857
|
+
};
|
|
2858
|
+
try {
|
|
2859
|
+
for (const run of oldRuns) {
|
|
2860
|
+
const inline = inlineFromSnapshotRun(run, normalize);
|
|
2861
|
+
// Portion of the run inside the unchanged prefix.
|
|
2862
|
+
await applyRange(run.start, Math.min(run.end, prefix), inline);
|
|
2863
|
+
// Portion inside the unchanged suffix, shifted by the length delta.
|
|
2864
|
+
const sufStart = Math.max(run.start, oldTrim.length - suffix);
|
|
2865
|
+
if (run.end > sufStart)
|
|
2866
|
+
await applyRange(sufStart + delta, run.end + delta, inline);
|
|
2867
|
+
}
|
|
2868
|
+
// The CHANGED middle keeps whatever the engine smeared; give it the marks
|
|
2869
|
+
// of the run the change continues from (the one containing the last
|
|
2870
|
+
// unchanged prefix character).
|
|
2871
|
+
const middleEnd = newTrim.length - suffix;
|
|
2872
|
+
if (middleEnd > prefix) {
|
|
2873
|
+
const anchorRun = oldRuns.find((run) => prefix > run.start && prefix <= run.end) ?? oldRuns[oldRuns.length - 1];
|
|
2874
|
+
await applyRange(prefix, middleEnd, inlineFromSnapshotRun(anchorRun, normalize));
|
|
2875
|
+
}
|
|
2876
|
+
return applied > 0 ? { restoredRunRanges: applied } : null;
|
|
2877
|
+
}
|
|
2878
|
+
catch (err) {
|
|
2879
|
+
return { skipped: reasonOf(err) };
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2017
2882
|
const NAMED_COLORS = {
|
|
2018
2883
|
black: '000000',
|
|
2019
2884
|
blue: '0000FF',
|
|
@@ -3354,7 +4219,7 @@ async function runInsertTableRow(doc, args) {
|
|
|
3354
4219
|
];
|
|
3355
4220
|
if (args.dryRun !== true && args.cellTexts && args.cellTexts.length > 0) {
|
|
3356
4221
|
const insertedRowIndex = position === 'below' ? rowIndex + 1 : rowIndex;
|
|
3357
|
-
const cells = args.cellTexts.map((
|
|
4222
|
+
const cells = args.cellTexts.map((cell, columnIndex) => cellTextFromSpec(cell, insertedRowIndex, columnIndex));
|
|
3358
4223
|
const applied = await applyTableCellTexts(doc, table.nodeId, table.ordinal, cells, args.changeMode);
|
|
3359
4224
|
if (applied.length > 0) {
|
|
3360
4225
|
executed.push({
|
|
@@ -3362,6 +4227,28 @@ async function runInsertTableRow(doc, args) {
|
|
|
3362
4227
|
rationale: `Populated ${applied.length} cell(s) in new row`,
|
|
3363
4228
|
});
|
|
3364
4229
|
}
|
|
4230
|
+
// Inherit the neighbouring row's look BEFORE explicit styling, so an
|
|
4231
|
+
// agent-supplied mark still overrides it (same layering as list items).
|
|
4232
|
+
// The neighbour is the TARGET row at its post-insert index: inserting
|
|
4233
|
+
// above shifts it down by one, and sampling `rowIndex` there would read
|
|
4234
|
+
// the new (still unstyled) row itself and copy nothing.
|
|
4235
|
+
const neighbourRowIndex = position === 'above' ? rowIndex + 1 : rowIndex;
|
|
4236
|
+
const match = await matchTableCellsToNeighbour(doc, table.ordinal, applied, (cell) => ({
|
|
4237
|
+
rowIndex: neighbourRowIndex,
|
|
4238
|
+
columnIndex: cell.columnIndex,
|
|
4239
|
+
}));
|
|
4240
|
+
if (match.matched > 0 || match.skipped) {
|
|
4241
|
+
executed.push({
|
|
4242
|
+
operationId: 'doc.format.apply',
|
|
4243
|
+
rationale: match.skipped
|
|
4244
|
+
? `Matched ${match.matched} cell(s) to row ${neighbourRowIndex}; skipped: ${match.skipped}`
|
|
4245
|
+
: `Matched ${match.matched} cell(s) to row ${neighbourRowIndex}`,
|
|
4246
|
+
});
|
|
4247
|
+
}
|
|
4248
|
+
const styledCount = await styleTableCells(doc, applied, args.changeMode);
|
|
4249
|
+
if (styledCount > 0) {
|
|
4250
|
+
executed.push({ operationId: 'doc.format.apply', rationale: `Styled ${styledCount} cell(s) in new row` });
|
|
4251
|
+
}
|
|
3365
4252
|
}
|
|
3366
4253
|
const postIdentity = await readDocumentIdentity(doc);
|
|
3367
4254
|
const postTable = await getTableShape(doc, table.nodeId);
|
|
@@ -3424,11 +4311,45 @@ async function runInsertTableColumn(doc, args) {
|
|
|
3424
4311
|
const executed = [
|
|
3425
4312
|
{ operationId: 'doc.tables.insertColumn', result: insertResult },
|
|
3426
4313
|
];
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
4314
|
+
const newColumnIndex = position === 'right' ? columnIndex + 1 : columnIndex;
|
|
4315
|
+
// Per-row styled cells for the new column, or a single (optionally styled)
|
|
4316
|
+
// header cell at row 0.
|
|
4317
|
+
let columnCells = [];
|
|
4318
|
+
if (args.cellTexts && args.cellTexts.length > 0) {
|
|
4319
|
+
columnCells = args.cellTexts.map((cell, rowIndex) => cellTextFromSpec(cell, rowIndex, newColumnIndex));
|
|
4320
|
+
}
|
|
4321
|
+
else if (args.headerText) {
|
|
4322
|
+
columnCells = [{ rowIndex: 0, columnIndex: newColumnIndex, text: args.headerText }];
|
|
4323
|
+
}
|
|
4324
|
+
if (columnCells.length > 0) {
|
|
4325
|
+
// forceDirectFill: a new column's cells live in pre-existing rows, which
|
|
4326
|
+
// carry no tracked-insertion marker, so a tracked fill fails closed.
|
|
4327
|
+
const applied = await applyTableCellTexts(doc, table.nodeId, table.ordinal, columnCells, args.changeMode, true);
|
|
3430
4328
|
if (applied.length > 0) {
|
|
3431
|
-
executed.push({
|
|
4329
|
+
executed.push({
|
|
4330
|
+
operationId: 'doc.mutations.apply',
|
|
4331
|
+
rationale: `Populated ${applied.length} cell(s) in new column`,
|
|
4332
|
+
});
|
|
4333
|
+
}
|
|
4334
|
+
// Inherit the ADJACENT COLUMN's look per row before explicit styling, so a
|
|
4335
|
+
// header cell stays a header cell: a new column's cells are written into
|
|
4336
|
+
// pre-existing rows and arrive with no run properties, which loses the
|
|
4337
|
+
// header row's bold/colour and the body rows' face.
|
|
4338
|
+
const match = await matchTableCellsToNeighbour(doc, table.ordinal, applied, (cell) => ({
|
|
4339
|
+
rowIndex: cell.rowIndex,
|
|
4340
|
+
columnIndex: columnIndex,
|
|
4341
|
+
}));
|
|
4342
|
+
if (match.matched > 0 || match.skipped) {
|
|
4343
|
+
executed.push({
|
|
4344
|
+
operationId: 'doc.format.apply',
|
|
4345
|
+
rationale: match.skipped
|
|
4346
|
+
? `Matched ${match.matched} cell(s) to column ${columnIndex}; skipped: ${match.skipped}`
|
|
4347
|
+
: `Matched ${match.matched} cell(s) to column ${columnIndex}`,
|
|
4348
|
+
});
|
|
4349
|
+
}
|
|
4350
|
+
const styledCount = await styleTableCells(doc, applied, args.changeMode);
|
|
4351
|
+
if (styledCount > 0) {
|
|
4352
|
+
executed.push({ operationId: 'doc.format.apply', rationale: `Styled ${styledCount} cell(s) in new column` });
|
|
3432
4353
|
}
|
|
3433
4354
|
}
|
|
3434
4355
|
const postIdentity = await readDocumentIdentity(doc);
|
|
@@ -3762,24 +4683,70 @@ async function runScopedReplace(doc, args) {
|
|
|
3762
4683
|
planned.push(span);
|
|
3763
4684
|
lastEnd = span.end;
|
|
3764
4685
|
}
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
4686
|
+
// The v2 engine resolves `where` by block/select/ref only — a
|
|
4687
|
+
// selection-targeted step ("by: 'target'") dies in resolveStepRef with
|
|
4688
|
+
// "requires a ref produced by query.match/find".
|
|
4689
|
+
const expected = planned.reduceRight((text, span) => text.slice(0, span.start) + span.replace + text.slice(span.end), block.text);
|
|
4690
|
+
const tracked = (args.changeMode ?? 'direct') === 'tracked';
|
|
4691
|
+
// Tracked mode: redline ONLY the spans. A whole-block rewrite in tracked
|
|
4692
|
+
// mode records the entire paragraph as deleted+reinserted, which buries a
|
|
4693
|
+
// one-word edit in a full-paragraph redline. Each span becomes a
|
|
4694
|
+
// select-scoped rewrite pinned to this block via `within`; duplicate find
|
|
4695
|
+
// strings would re-resolve ambiguously against tracked-visible deleted
|
|
4696
|
+
// text, so those fall back to the whole-block path below.
|
|
4697
|
+
let result = null;
|
|
4698
|
+
// Dedupe on the same basis the selector will match on: under a
|
|
4699
|
+
// case-sensitive request `Foo` and `foo` are distinct targets, and folding
|
|
4700
|
+
// them together would push a legitimately unique pair onto the fallback.
|
|
4701
|
+
const uniqueFinds = new Set(planned.map((s) => (caseSensitive ? s.find : s.find.toLowerCase())));
|
|
4702
|
+
if (tracked && uniqueFinds.size === planned.length) {
|
|
4703
|
+
const spanSteps = planned.map((span, index) => ({
|
|
4704
|
+
id: `scoped-replace-${index + 1}`,
|
|
4705
|
+
op: 'text.rewrite',
|
|
4706
|
+
where: {
|
|
4707
|
+
by: 'select',
|
|
4708
|
+
// The planner honours caseSensitive, so the selector must match on the
|
|
4709
|
+
// same terms. Without it a case-sensitive request can redline a
|
|
4710
|
+
// differently cased occurrence and still verify, because verification
|
|
4711
|
+
// only looks for the replacement text somewhere in the block.
|
|
4712
|
+
select: { type: 'text', pattern: span.find, mode: 'contains', caseSensitive },
|
|
4713
|
+
require: 'first',
|
|
4714
|
+
within: { kind: 'block', nodeType: block.nodeType, nodeId },
|
|
3774
4715
|
},
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
4716
|
+
args: { replacement: { text: span.replace } },
|
|
4717
|
+
}));
|
|
4718
|
+
// Select rewrites flatten the block's runs, so the tracked lane needs the
|
|
4719
|
+
// same restoration the whole-block lane below performs. The preserve pass
|
|
4720
|
+
// applies directly, so restoring marks adds no second reviewable change.
|
|
4721
|
+
const trackedPreRuns = await captureBlockRuns(doc, nodeId, block.text);
|
|
4722
|
+
try {
|
|
4723
|
+
result = await executeMutations(doc, spanSteps, args.changeMode);
|
|
4724
|
+
if (result != null)
|
|
4725
|
+
await preserveScopedRunPattern(doc, nodeId, block.text, trackedPreRuns, planned);
|
|
4726
|
+
}
|
|
4727
|
+
catch {
|
|
4728
|
+
result = null; // select lane unavailable (e.g. case mismatch) — fall back
|
|
4729
|
+
}
|
|
4730
|
+
}
|
|
4731
|
+
if (result == null) {
|
|
4732
|
+
// Direct mode (or tracked fallback): rewrite the whole block once, then
|
|
4733
|
+
// restore the intra-paragraph run pattern span-aware — every interval the
|
|
4734
|
+
// spans did NOT touch keeps its original run formatting at the mapped
|
|
4735
|
+
// offsets, and each replacement inherits the marks of the run it starts in.
|
|
4736
|
+
const preRuns = await captureBlockRuns(doc, nodeId, block.text);
|
|
4737
|
+
const steps = [
|
|
4738
|
+
{
|
|
4739
|
+
id: 'scoped-replace-1',
|
|
4740
|
+
op: 'text.rewrite',
|
|
4741
|
+
where: { by: 'block', nodeType: block.nodeType, nodeId },
|
|
4742
|
+
args: { replacement: { text: expected }, style: preserveRewriteStyle() },
|
|
4743
|
+
},
|
|
4744
|
+
];
|
|
4745
|
+
result = await executeMutations(doc, steps, args.changeMode);
|
|
4746
|
+
await preserveScopedRunPattern(doc, nodeId, block.text, preRuns, planned);
|
|
4747
|
+
}
|
|
3779
4748
|
const post = await docSnapshot.buildDocumentSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
|
|
3780
4749
|
const postBlock = findTextTarget(post, nodeId);
|
|
3781
|
-
const expected = planned.reduceRight((text, span) => text.slice(0, span.start) + span.replace + text.slice(span.end), block.text);
|
|
3782
|
-
const tracked = (args.changeMode ?? 'direct') === 'tracked';
|
|
3783
4750
|
// In tracked mode the deleted text is still present in the block, so only
|
|
3784
4751
|
// require the inserted text to be visible.
|
|
3785
4752
|
const passed = postBlock != null &&
|
|
@@ -3823,6 +4790,301 @@ async function runScopedReplace(doc, args) {
|
|
|
3823
4790
|
* so both copy the SAME set of run properties in one place. Only emits keys the
|
|
3824
4791
|
* row actually carries — an empty result means "nothing to match".
|
|
3825
4792
|
*/
|
|
4793
|
+
/** Points → twips: the node read side reports points, mutations take twips. */
|
|
4794
|
+
const TWIPS_PER_POINT = 20;
|
|
4795
|
+
/**
|
|
4796
|
+
* Build `format.paragraph.setMarkRunProps` input from agent run marks.
|
|
4797
|
+
*
|
|
4798
|
+
* The mark's colour is a discriminated model object, not the plain hex string
|
|
4799
|
+
* `format.apply` takes, and the hex is stored without the leading `#`.
|
|
4800
|
+
* Returns null when the marks say nothing about the mark's appearance.
|
|
4801
|
+
*/
|
|
4802
|
+
function markRunPropsFromMarks(marks) {
|
|
4803
|
+
if (!marks)
|
|
4804
|
+
return null;
|
|
4805
|
+
const props = {};
|
|
4806
|
+
if (typeof marks.bold === 'boolean')
|
|
4807
|
+
props.bold = marks.bold;
|
|
4808
|
+
if (typeof marks.italic === 'boolean')
|
|
4809
|
+
props.italic = marks.italic;
|
|
4810
|
+
if (typeof marks.fontFamily === 'string' && marks.fontFamily)
|
|
4811
|
+
props.fontFamily = marks.fontFamily;
|
|
4812
|
+
if (typeof marks.fontSize === 'number' && Number.isFinite(marks.fontSize))
|
|
4813
|
+
props.fontSize = marks.fontSize;
|
|
4814
|
+
if (typeof marks.color === 'string' && marks.color) {
|
|
4815
|
+
props.color = { model: 'rgb', value: marks.color.replace(/^#/, '') };
|
|
4816
|
+
}
|
|
4817
|
+
return Object.keys(props).length > 0 ? props : null;
|
|
4818
|
+
}
|
|
4819
|
+
/**
|
|
4820
|
+
* Pull a paragraph's MARK run properties (`w:pPr/w:rPr`) out of a `getNodeById`
|
|
4821
|
+
* payload, ready to hand straight back to `format.paragraph.setMarkRunProps`
|
|
4822
|
+
* (same internal shape, so the values round-trip). Node-shape tolerant like
|
|
4823
|
+
* paragraphIndentOf. Returns null when the paragraph mark carries nothing.
|
|
4824
|
+
*/
|
|
4825
|
+
function paragraphMarkRunPropsOf(payload) {
|
|
4826
|
+
const node = isRecord(payload) ? payload.node : null;
|
|
4827
|
+
if (!isRecord(node))
|
|
4828
|
+
return null;
|
|
4829
|
+
const candidates = [isRecord(node.paragraph) ? node.paragraph.props : null, node.props];
|
|
4830
|
+
for (const props of candidates) {
|
|
4831
|
+
const mark = isRecord(props) ? props.markRunProps : null;
|
|
4832
|
+
if (isRecord(mark) && Object.keys(mark).length > 0)
|
|
4833
|
+
return mark;
|
|
4834
|
+
}
|
|
4835
|
+
return null;
|
|
4836
|
+
}
|
|
4837
|
+
/**
|
|
4838
|
+
* Delimiters that separate a list item's lead-in label from its body text.
|
|
4839
|
+
* Spaced dashes are listed separately from bare ones because the label run
|
|
4840
|
+
* usually stops BEFORE the space ("JavaScript / TypeScript" + " — SuperDoc…").
|
|
4841
|
+
*/
|
|
4842
|
+
/**
|
|
4843
|
+
* How many blocks the per-block replace lane will capture runs for. Each target
|
|
4844
|
+
* costs a `query.match` read, so the lane is bounded; edits reaching past it
|
|
4845
|
+
* fall through to the select lane rather than being silently truncated.
|
|
4846
|
+
*/
|
|
4847
|
+
const PRESERVE_TARGET_CAP = 20;
|
|
4848
|
+
const LEAD_IN_DELIMITERS = [':', ' — ', ' – ', ' - ', '—', '–'];
|
|
4849
|
+
/** A lead-in is a LABEL, not a sentence — longer spans are prose, not a pattern. */
|
|
4850
|
+
const LEAD_IN_MAX_CHARS = 90;
|
|
4851
|
+
/** Sentence characters sampled past the label, to read the body's own look. */
|
|
4852
|
+
const LEAD_IN_BODY_SAMPLE_CHARS = 40;
|
|
4853
|
+
/**
|
|
4854
|
+
* Where an item's lead-in label ends.
|
|
4855
|
+
*
|
|
4856
|
+
* The SIBLINGS' delimiter wins whenever the item uses it: with a dash-style
|
|
4857
|
+
* list, "Rust: Advanced — details" splits at the dash, not at the colon inside
|
|
4858
|
+
* the label. Falling back to the earliest known delimiter is only for items
|
|
4859
|
+
* that do not use the siblings' one at all.
|
|
4860
|
+
*/
|
|
4861
|
+
function leadInEndOf(text, preferred) {
|
|
4862
|
+
const at = (delimiter) => {
|
|
4863
|
+
const index = text.indexOf(delimiter);
|
|
4864
|
+
return index > 0 && index <= LEAD_IN_MAX_CHARS ? index : null;
|
|
4865
|
+
};
|
|
4866
|
+
if (preferred) {
|
|
4867
|
+
const preferredAt = at(preferred);
|
|
4868
|
+
if (preferredAt != null)
|
|
4869
|
+
return preferredAt;
|
|
4870
|
+
}
|
|
4871
|
+
const found = LEAD_IN_DELIMITERS.map(at).filter((index) => index != null);
|
|
4872
|
+
return found.length > 0 ? Math.min(...found) : null;
|
|
4873
|
+
}
|
|
4874
|
+
/** Quote characters a defined term is wrapped in ("Deliverables" means …). */
|
|
4875
|
+
const LEAD_IN_QUOTES = ['"', '\u201c', '\u2018', "'"];
|
|
4876
|
+
/**
|
|
4877
|
+
* Delimiters allowed to sit INSIDE the label run. Wider than the set used to
|
|
4878
|
+
* split at a delimiter that follows the label, because a sentence period only
|
|
4879
|
+
* counts as a label boundary when the reference's own emphasised run ends with
|
|
4880
|
+
* it — "1.7. Records. " — which is evidence no bare text scan can provide.
|
|
4881
|
+
*/
|
|
4882
|
+
const LEAD_IN_TAIL_DELIMITERS = ['. ', ': ', ...LEAD_IN_DELIMITERS];
|
|
4883
|
+
/**
|
|
4884
|
+
* Read the label shape a reference block uses, from its text and its label run.
|
|
4885
|
+
*
|
|
4886
|
+
* The delimiter has to sit at the label's edge: immediately AFTER it ("Label:
|
|
4887
|
+
* sentence"), or at its own tail ("1.7. Records. " — a numbered clause whose
|
|
4888
|
+
* marker and label share one run). That adjacency is what distinguishes a label
|
|
4889
|
+
* from an ordinary emphasised opening clause.
|
|
4890
|
+
*/
|
|
4891
|
+
function leadInShapeOf(blockText, labelText, quote) {
|
|
4892
|
+
if (quote)
|
|
4893
|
+
return { kind: 'quoted', quote };
|
|
4894
|
+
const after = LEAD_IN_DELIMITERS.find((d) => blockText.startsWith(d, labelText.length));
|
|
4895
|
+
if (after)
|
|
4896
|
+
return { kind: 'before', delimiter: after };
|
|
4897
|
+
const trimmed = labelText.trimEnd();
|
|
4898
|
+
const inside = LEAD_IN_TAIL_DELIMITERS.find((d) => trimmed.endsWith(d.trimEnd()) && trimmed.length > d.trimEnd().length);
|
|
4899
|
+
if (inside) {
|
|
4900
|
+
const occurrence = labelText.split(inside).length - 1;
|
|
4901
|
+
if (occurrence > 0)
|
|
4902
|
+
return { kind: 'inside', delimiter: inside, occurrence };
|
|
4903
|
+
}
|
|
4904
|
+
return null;
|
|
4905
|
+
}
|
|
4906
|
+
/** The span of `text` that carries the label look, or null when it has none. */
|
|
4907
|
+
function leadInSpanOf(text, shape) {
|
|
4908
|
+
if (shape.kind === 'quoted') {
|
|
4909
|
+
const opener = text.slice(0, 2).indexOf(shape.quote);
|
|
4910
|
+
if (opener < 0)
|
|
4911
|
+
return null;
|
|
4912
|
+
const close = text.indexOf(shape.quote, opener + 1);
|
|
4913
|
+
if (close < 0 || close - opener - 1 <= 0 || close > LEAD_IN_MAX_CHARS)
|
|
4914
|
+
return null;
|
|
4915
|
+
return { start: opener + 1, end: close };
|
|
4916
|
+
}
|
|
4917
|
+
if (shape.kind === 'inside') {
|
|
4918
|
+
// Mirror the reference's STRUCTURE: "1.7. Records. " ends at its second
|
|
4919
|
+
// ". ", so "1.8. Review. The parties…" must end at its second one too —
|
|
4920
|
+
// stopping at the first would emphasise the number alone.
|
|
4921
|
+
let at = -1;
|
|
4922
|
+
for (let seen = 0; seen < shape.occurrence; seen += 1) {
|
|
4923
|
+
at = text.indexOf(shape.delimiter, at + 1);
|
|
4924
|
+
if (at < 0)
|
|
4925
|
+
return null;
|
|
4926
|
+
}
|
|
4927
|
+
if (at <= 0 || at > LEAD_IN_MAX_CHARS)
|
|
4928
|
+
return null;
|
|
4929
|
+
return { start: 0, end: at + shape.delimiter.length };
|
|
4930
|
+
}
|
|
4931
|
+
const end = leadInEndOf(text, shape.delimiter);
|
|
4932
|
+
return end == null ? null : { start: 0, end };
|
|
4933
|
+
}
|
|
4934
|
+
/**
|
|
4935
|
+
* Cut `text` at `end` without splitting a surrogate pair: half an emoji is not
|
|
4936
|
+
* document text, and `query.match` finds nothing when handed a lone surrogate —
|
|
4937
|
+
* which reads as "the siblings have no pattern" and drops the styling.
|
|
4938
|
+
*/
|
|
4939
|
+
function sliceWholeChars(text, end) {
|
|
4940
|
+
if (end >= text.length)
|
|
4941
|
+
return text;
|
|
4942
|
+
const code = text.charCodeAt(end - 1);
|
|
4943
|
+
const splitsPair = code >= 0xd800 && code <= 0xdbff;
|
|
4944
|
+
return text.slice(0, splitsPair ? end - 1 : end);
|
|
4945
|
+
}
|
|
4946
|
+
function matchRunStyle(run) {
|
|
4947
|
+
const styles = asRecord(asRecord(run)?.styles);
|
|
4948
|
+
const effective = asRecord(styles?.effective);
|
|
4949
|
+
const sizePt = asNumber(styles?.fontSizePt);
|
|
4950
|
+
const fontFamily = asString(styles?.fontFamily);
|
|
4951
|
+
const color = asString(styles?.color);
|
|
4952
|
+
return {
|
|
4953
|
+
bold: effective?.bold === true,
|
|
4954
|
+
italic: effective?.italic === true,
|
|
4955
|
+
...(sizePt != null && Number.isFinite(sizePt) ? { fontSize: sizePt } : {}),
|
|
4956
|
+
...(fontFamily ? { fontFamily } : {}),
|
|
4957
|
+
...(color ? { color } : {}),
|
|
4958
|
+
};
|
|
4959
|
+
}
|
|
4960
|
+
/**
|
|
4961
|
+
* A sampled style as a `format.apply` `inline` payload.
|
|
4962
|
+
*
|
|
4963
|
+
* A toggle that is OFF is dropped rather than sent as an explicit `false`: new
|
|
4964
|
+
* text starts unformatted, so "not bold" needs no write — and writing it would
|
|
4965
|
+
* stamp an explicit `w:b w:val="0"` the sampled siblings themselves don't carry.
|
|
4966
|
+
* The exception is a toggle that must be CLEARED because an earlier layer
|
|
4967
|
+
* (`against`) turned it on.
|
|
4968
|
+
*/
|
|
4969
|
+
function inlineFromMatchStyle(style, against) {
|
|
4970
|
+
const inline = {};
|
|
4971
|
+
if (style.bold || against?.bold === true)
|
|
4972
|
+
inline.bold = style.bold;
|
|
4973
|
+
if (style.italic || against?.italic === true)
|
|
4974
|
+
inline.italic = style.italic;
|
|
4975
|
+
if (style.fontSize != null)
|
|
4976
|
+
inline.fontSize = style.fontSize;
|
|
4977
|
+
if (style.fontFamily)
|
|
4978
|
+
inline.fontFamily = style.fontFamily;
|
|
4979
|
+
if (style.color)
|
|
4980
|
+
inline.color = style.color;
|
|
4981
|
+
return inline;
|
|
4982
|
+
}
|
|
4983
|
+
/**
|
|
4984
|
+
* Read an existing list item's RUN PATTERN — the body look plus the "bold
|
|
4985
|
+
* label: plain sentence" lead-in convention its siblings share — through
|
|
4986
|
+
* `query.match`, the only read that reports run styling.
|
|
4987
|
+
*
|
|
4988
|
+
* Why the pattern and not just one look: real lists carry the emphasis INSIDE
|
|
4989
|
+
* the item ("Format Painter Feature Request: Joey requested…" — first run bold,
|
|
4990
|
+
* rest plain). Copying a single sampled look either bolds the whole new item or
|
|
4991
|
+
* none of it; both read as "the agent's item doesn't match the list".
|
|
4992
|
+
*
|
|
4993
|
+
* A LABEL is the first run when a delimiter sits immediately after it and its
|
|
4994
|
+
* style differs from the body's; the body look then comes from the run right
|
|
4995
|
+
* AFTER the label (the sentence it introduces), so neither the bold label nor a
|
|
4996
|
+
* later emphasized phrase becomes the whole item's look.
|
|
4997
|
+
*
|
|
4998
|
+
* Only the head of the item is sampled, not all of it: run decomposition
|
|
4999
|
+
* collapses to a single empty-text run once the matched range spans a
|
|
5000
|
+
* HYPERLINK (these items end in "(00:31:34)" timestamp links), and a window
|
|
5001
|
+
* that stops before the links keeps the label/body split readable.
|
|
5002
|
+
*/
|
|
5003
|
+
async function listItemRunPattern(doc, text, blockId) {
|
|
5004
|
+
const queryFn = maybeMethod(doc, ['query', 'match']);
|
|
5005
|
+
if (!queryFn || !text.trim())
|
|
5006
|
+
return null;
|
|
5007
|
+
// Window: through the label's delimiter plus enough of the sentence to sample
|
|
5008
|
+
// it, or a plain head when the item carries no label at all.
|
|
5009
|
+
const delimiterAt = leadInEndOf(text);
|
|
5010
|
+
const windowEnd = delimiterAt != null ? delimiterAt + LEAD_IN_BODY_SAMPLE_CHARS : LEAD_IN_BODY_SAMPLE_CHARS;
|
|
5011
|
+
const pattern = sliceWholeChars(text, windowEnd);
|
|
5012
|
+
const result = asRecord(await queryFn({ select: { type: 'text', pattern } }));
|
|
5013
|
+
const items = Array.isArray(result?.items) ? result.items : [];
|
|
5014
|
+
for (const item of items) {
|
|
5015
|
+
const blocks = Array.isArray(asRecord(item)?.blocks) ? asRecord(item).blocks : [];
|
|
5016
|
+
for (const block of blocks) {
|
|
5017
|
+
const rec = asRecord(block);
|
|
5018
|
+
if (asString(rec?.blockId) !== blockId)
|
|
5019
|
+
continue;
|
|
5020
|
+
const runTextOf = (r) => asString(asRecord(r)?.text, '') ?? '';
|
|
5021
|
+
const runs = (Array.isArray(rec?.runs) ? rec.runs : []).filter((r) => runTextOf(r).length > 0);
|
|
5022
|
+
if (runs.length === 0)
|
|
5023
|
+
continue;
|
|
5024
|
+
const blockText = asString(rec?.text, '') ?? '';
|
|
5025
|
+
// A quoted defined term keeps its opening quote in a run of its own —
|
|
5026
|
+
// `"` + **Deliverables** + `" means …` — so the label is the SECOND run
|
|
5027
|
+
// and the quotes stay unemphasised.
|
|
5028
|
+
const opener = runTextOf(runs[0]).trim();
|
|
5029
|
+
const quote = LEAD_IN_QUOTES.find((q) => opener === q);
|
|
5030
|
+
const labelIndex = quote && runs.length > 2 ? 1 : 0;
|
|
5031
|
+
const label = runs[labelIndex];
|
|
5032
|
+
const labelText = runTextOf(label);
|
|
5033
|
+
const leadStyle = matchRunStyle(label);
|
|
5034
|
+
// A source block that is ONLY a short emphasised label (a heading-ish "LLM
|
|
5035
|
+
// Integration", a signature-block name) has no body look to copy — and
|
|
5036
|
+
// copying its label look over a whole new block would emphasise the
|
|
5037
|
+
// entire thing. Treat it as a lead-in whose span the new text decides.
|
|
5038
|
+
if (runs.length === 1) {
|
|
5039
|
+
const labelOnly = labelText.length <= LEAD_IN_MAX_CHARS && (leadStyle.bold || leadStyle.italic);
|
|
5040
|
+
return labelOnly
|
|
5041
|
+
? { body: {}, lead: { look: inlineFromMatchStyle(leadStyle), shape: { kind: 'before', delimiter: null } } }
|
|
5042
|
+
: { body: inlineFromMatchStyle(leadStyle) };
|
|
5043
|
+
}
|
|
5044
|
+
// The body is the run the label INTRODUCES, not the longest run in the
|
|
5045
|
+
// block: a later emphasised phrase is part of the prose, and sampling it
|
|
5046
|
+
// would stamp its emphasis across the whole new block.
|
|
5047
|
+
const bodyStyle = matchRunStyle(runs[labelIndex + 1]);
|
|
5048
|
+
const differs = ['bold', 'italic', 'fontSize', 'fontFamily', 'color'].some((k) => leadStyle[k] !== bodyStyle[k]);
|
|
5049
|
+
const shape = leadInShapeOf(blockText, labelText, labelIndex > 0 ? quote : undefined);
|
|
5050
|
+
const isLabel = differs && shape != null && labelText.length > 0 && labelText.length <= LEAD_IN_MAX_CHARS;
|
|
5051
|
+
return {
|
|
5052
|
+
body: inlineFromMatchStyle(bodyStyle),
|
|
5053
|
+
...(isLabel ? { lead: { look: inlineFromMatchStyle(leadStyle, bodyStyle), shape: shape } } : {}),
|
|
5054
|
+
};
|
|
5055
|
+
}
|
|
5056
|
+
}
|
|
5057
|
+
return null;
|
|
5058
|
+
}
|
|
5059
|
+
/**
|
|
5060
|
+
* Pull a paragraph's direct indentation out of a `getNodeById` payload and
|
|
5061
|
+
* convert it to the twips that `format.paragraph.setIndentation` expects.
|
|
5062
|
+
*
|
|
5063
|
+
* The node shape nests the properties under the block kind
|
|
5064
|
+
* (`node.paragraph.props.indent`); older/other shapes put them at
|
|
5065
|
+
* `node.props.indent`. Both are accepted so a shape change degrades to "no
|
|
5066
|
+
* indent copied" rather than a thrown read.
|
|
5067
|
+
*/
|
|
5068
|
+
function paragraphIndentOf(payload) {
|
|
5069
|
+
const node = isRecord(payload) ? payload.node : null;
|
|
5070
|
+
if (!isRecord(node))
|
|
5071
|
+
return null;
|
|
5072
|
+
const candidates = [isRecord(node.paragraph) ? node.paragraph.props : null, node.props];
|
|
5073
|
+
for (const props of candidates) {
|
|
5074
|
+
const indent = isRecord(props) ? props.indent : null;
|
|
5075
|
+
if (!isRecord(indent))
|
|
5076
|
+
continue;
|
|
5077
|
+
const twips = {};
|
|
5078
|
+
for (const key of ['left', 'right', 'firstLine', 'hanging']) {
|
|
5079
|
+
const value = indent[key];
|
|
5080
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
5081
|
+
twips[key] = Math.round(value * TWIPS_PER_POINT);
|
|
5082
|
+
}
|
|
5083
|
+
if (Object.keys(twips).length > 0)
|
|
5084
|
+
return twips;
|
|
5085
|
+
}
|
|
5086
|
+
return null;
|
|
5087
|
+
}
|
|
3826
5088
|
function inlineLookFromRow(row) {
|
|
3827
5089
|
const inline = {};
|
|
3828
5090
|
if (row.fontFamily)
|
|
@@ -3838,6 +5100,229 @@ function inlineLookFromRow(row) {
|
|
|
3838
5100
|
inline.italic = true;
|
|
3839
5101
|
return inline;
|
|
3840
5102
|
}
|
|
5103
|
+
/**
|
|
5104
|
+
* Build a `doc.format.apply` `inline` payload from agent-supplied run marks.
|
|
5105
|
+
* Same key set + semantics as inlineLookFromRow (so anchor auto-match and
|
|
5106
|
+
* explicit per-run styling stack cleanly), plus underline/strike/highlight.
|
|
5107
|
+
* Booleans are emitted whether true or false so an explicit `false` can CLEAR a
|
|
5108
|
+
* mark the anchor/whole-item default set. Returns {} when nothing is requested.
|
|
5109
|
+
*/
|
|
5110
|
+
/**
|
|
5111
|
+
* The inline bag for ONE run of an explicit run pattern, relative to whatever
|
|
5112
|
+
* the item already carries.
|
|
5113
|
+
*
|
|
5114
|
+
* An inserted item inherits its neighbour's look, and the whole-item layer may
|
|
5115
|
+
* add more, so a run that asks for nothing is not automatically plain: without
|
|
5116
|
+
* explicit `false` values a pattern like [plain, bold] renders entirely bold
|
|
5117
|
+
* when the item inherited bold. Boolean marks active in the baseline are
|
|
5118
|
+
* therefore cleared on any run that does not ask for them itself.
|
|
5119
|
+
*/
|
|
5120
|
+
function runInlineAgainstBaseline(marks, baseline) {
|
|
5121
|
+
const inline = marksToInline(marks);
|
|
5122
|
+
for (const key of RUN_BOOL_MARKS) {
|
|
5123
|
+
if (baseline[key] === true && inline[key] === undefined)
|
|
5124
|
+
inline[key] = false;
|
|
5125
|
+
}
|
|
5126
|
+
return inline;
|
|
5127
|
+
}
|
|
5128
|
+
function marksToInline(marks) {
|
|
5129
|
+
const inline = {};
|
|
5130
|
+
if (!marks)
|
|
5131
|
+
return inline;
|
|
5132
|
+
if (typeof marks.bold === 'boolean')
|
|
5133
|
+
inline.bold = marks.bold;
|
|
5134
|
+
if (typeof marks.italic === 'boolean')
|
|
5135
|
+
inline.italic = marks.italic;
|
|
5136
|
+
if (typeof marks.underline === 'boolean')
|
|
5137
|
+
inline.underline = marks.underline;
|
|
5138
|
+
if (typeof marks.strikethrough === 'boolean')
|
|
5139
|
+
inline.strike = marks.strikethrough;
|
|
5140
|
+
if (typeof marks.fontFamily === 'string' && marks.fontFamily)
|
|
5141
|
+
inline.fontFamily = marks.fontFamily;
|
|
5142
|
+
if (typeof marks.fontSize === 'number' && Number.isFinite(marks.fontSize))
|
|
5143
|
+
inline.fontSize = marks.fontSize;
|
|
5144
|
+
if (typeof marks.color === 'string' && marks.color)
|
|
5145
|
+
inline.color = marks.color;
|
|
5146
|
+
if (typeof marks.highlight === 'string' && marks.highlight)
|
|
5147
|
+
inline.highlight = marks.highlight;
|
|
5148
|
+
return inline;
|
|
5149
|
+
}
|
|
5150
|
+
/** Parse a raw agent mark bag, keeping only recognized keys with valid types. */
|
|
5151
|
+
function normalizeMarks(raw) {
|
|
5152
|
+
if (!isRecord(raw))
|
|
5153
|
+
return undefined;
|
|
5154
|
+
const m = {};
|
|
5155
|
+
if (typeof raw.bold === 'boolean')
|
|
5156
|
+
m.bold = raw.bold;
|
|
5157
|
+
if (typeof raw.italic === 'boolean')
|
|
5158
|
+
m.italic = raw.italic;
|
|
5159
|
+
if (typeof raw.underline === 'boolean')
|
|
5160
|
+
m.underline = raw.underline;
|
|
5161
|
+
if (typeof raw.strikethrough === 'boolean')
|
|
5162
|
+
m.strikethrough = raw.strikethrough;
|
|
5163
|
+
if (typeof raw.fontFamily === 'string')
|
|
5164
|
+
m.fontFamily = raw.fontFamily;
|
|
5165
|
+
const sz = asNumber(raw.fontSize);
|
|
5166
|
+
if (sz != null && Number.isFinite(sz))
|
|
5167
|
+
m.fontSize = sz;
|
|
5168
|
+
if (typeof raw.color === 'string')
|
|
5169
|
+
m.color = raw.color;
|
|
5170
|
+
if (typeof raw.highlight === 'string')
|
|
5171
|
+
m.highlight = raw.highlight;
|
|
5172
|
+
return Object.keys(m).length > 0 ? m : undefined;
|
|
5173
|
+
}
|
|
5174
|
+
/**
|
|
5175
|
+
* Normalize one agent-supplied list item (string or object) into an
|
|
5176
|
+
* AgentListItemSpec. Accepts `{ text }`, `{ runs:[{text,marks}] }`, an optional
|
|
5177
|
+
* whole-item `marks`, and a relative `level`. Returns null for junk.
|
|
5178
|
+
*/
|
|
5179
|
+
function normalizeItemSpec(raw) {
|
|
5180
|
+
if (typeof raw === 'string')
|
|
5181
|
+
return { text: raw, level: 0 };
|
|
5182
|
+
if (!isRecord(raw))
|
|
5183
|
+
return null;
|
|
5184
|
+
const spec = {};
|
|
5185
|
+
const lvl = asNumber(raw.level);
|
|
5186
|
+
spec.level = lvl != null ? Math.floor(lvl) : 0;
|
|
5187
|
+
const itemMarks = normalizeMarks(raw.marks);
|
|
5188
|
+
if (itemMarks)
|
|
5189
|
+
spec.marks = itemMarks;
|
|
5190
|
+
if (Array.isArray(raw.runs)) {
|
|
5191
|
+
const runs = [];
|
|
5192
|
+
for (const r of raw.runs) {
|
|
5193
|
+
if (typeof r === 'string') {
|
|
5194
|
+
runs.push({ text: r });
|
|
5195
|
+
}
|
|
5196
|
+
else if (isRecord(r) && typeof r.text === 'string') {
|
|
5197
|
+
const rm = normalizeMarks(r.marks);
|
|
5198
|
+
runs.push(rm ? { text: r.text, marks: rm } : { text: r.text });
|
|
5199
|
+
}
|
|
5200
|
+
}
|
|
5201
|
+
if (runs.length > 0) {
|
|
5202
|
+
spec.runs = runs;
|
|
5203
|
+
return spec;
|
|
5204
|
+
}
|
|
5205
|
+
}
|
|
5206
|
+
if (typeof raw.text === 'string') {
|
|
5207
|
+
spec.text = raw.text;
|
|
5208
|
+
return spec;
|
|
5209
|
+
}
|
|
5210
|
+
return null;
|
|
5211
|
+
}
|
|
5212
|
+
/** The plain text an item renders as (concatenated runs, or its `text`). */
|
|
5213
|
+
function itemSpecText(spec) {
|
|
5214
|
+
if (spec.runs && spec.runs.length > 0)
|
|
5215
|
+
return spec.runs.map((r) => r.text).join('');
|
|
5216
|
+
return spec.text ?? '';
|
|
5217
|
+
}
|
|
5218
|
+
/**
|
|
5219
|
+
* Resolve a 1-based `listOrdinal` (as reported by inspect) to the nodeId of that
|
|
5220
|
+
* list's last item, so a listOrdinal-addressed add can anchor by nodeId and go
|
|
5221
|
+
* through runAddListItems (which applies numbering + per-run formatting) instead
|
|
5222
|
+
* of the text-only workflow path. Best-effort: undefined if it can't resolve.
|
|
5223
|
+
*/
|
|
5224
|
+
async function resolveListAnchorNodeId(doc, listOrdinal) {
|
|
5225
|
+
try {
|
|
5226
|
+
const snap = await docSnapshot.buildDocumentSnapshot(doc, { includeDomains: ['lists'] });
|
|
5227
|
+
const lists = Array.isArray(snap?.lists) ? snap.lists : [];
|
|
5228
|
+
const items = lists[listOrdinal - 1]?.items;
|
|
5229
|
+
if (!Array.isArray(items))
|
|
5230
|
+
return undefined;
|
|
5231
|
+
for (let i = items.length - 1; i >= 0; i -= 1) {
|
|
5232
|
+
const id = items[i]?.nodeId;
|
|
5233
|
+
if (typeof id === 'string' && id)
|
|
5234
|
+
return id;
|
|
5235
|
+
}
|
|
5236
|
+
}
|
|
5237
|
+
catch {
|
|
5238
|
+
// best-effort — fall back to the ghost/workflow path
|
|
5239
|
+
}
|
|
5240
|
+
return undefined;
|
|
5241
|
+
}
|
|
5242
|
+
/**
|
|
5243
|
+
* Apply whole-item + per-run marks to already-created list items, aligned by
|
|
5244
|
+
* index with `specs`. Layered per item (later overrides earlier on overlapping
|
|
5245
|
+
* keys): optional `baseInline` over the whole item, then the item's `marks`,
|
|
5246
|
+
* then each run's `marks` over its char range. This runs as a post-insert pass
|
|
5247
|
+
* (via doc.format.apply on the created nodeId), so it works no matter which
|
|
5248
|
+
* insert path created the item — including the text-only list workflow, which
|
|
5249
|
+
* is the only insert path some runtimes expose. Best-effort: a format failure
|
|
5250
|
+
* never invalidates an otherwise-landed insert.
|
|
5251
|
+
*/
|
|
5252
|
+
async function applyItemStyling(doc, nodeIds, specs, changeMode, baseInline) {
|
|
5253
|
+
const formatFn = maybeMethod(doc, ['format', 'apply']);
|
|
5254
|
+
if (!formatFn)
|
|
5255
|
+
return { skipped: 'format.apply unavailable' };
|
|
5256
|
+
let layers = 0;
|
|
5257
|
+
let redundantLayers = 0;
|
|
5258
|
+
const applyRange = async (blockId, start, end, inline) => {
|
|
5259
|
+
if (Object.keys(inline).length === 0 || end <= start)
|
|
5260
|
+
return;
|
|
5261
|
+
const params = { blockId, start, end, inline };
|
|
5262
|
+
if (changeMode === 'tracked')
|
|
5263
|
+
params.changeMode = changeMode;
|
|
5264
|
+
try {
|
|
5265
|
+
await formatFn(params);
|
|
5266
|
+
layers += 1;
|
|
5267
|
+
}
|
|
5268
|
+
catch (err) {
|
|
5269
|
+
// Inserted content often ALREADY carries part of the requested look
|
|
5270
|
+
// (it inherits from its neighbour), and format.apply answers a redundant
|
|
5271
|
+
// range with NO_OP. That means "nothing left to do here", never "stop
|
|
5272
|
+
// styling": isolating it per range keeps later ranges — and later items —
|
|
5273
|
+
// from silently losing their marks. Genuine failures still propagate.
|
|
5274
|
+
if (!isNoOp(err))
|
|
5275
|
+
throw err;
|
|
5276
|
+
redundantLayers += 1;
|
|
5277
|
+
}
|
|
5278
|
+
};
|
|
5279
|
+
try {
|
|
5280
|
+
const seenNodeIds = new Set();
|
|
5281
|
+
for (let i = 0; i < nodeIds.length; i += 1) {
|
|
5282
|
+
const nodeId = nodeIds[i];
|
|
5283
|
+
const spec = specs[i];
|
|
5284
|
+
if (!nodeId || !spec)
|
|
5285
|
+
continue;
|
|
5286
|
+
// A single target should be styled once — merged table cells can resolve
|
|
5287
|
+
// several requested coordinates to the same paragraph nodeId, and styling
|
|
5288
|
+
// it twice would overlap/overwrite.
|
|
5289
|
+
if (seenNodeIds.has(nodeId))
|
|
5290
|
+
continue;
|
|
5291
|
+
seenNodeIds.add(nodeId);
|
|
5292
|
+
const text = itemSpecText(spec);
|
|
5293
|
+
// Run offsets are block-relative to a SINGLE paragraph. A value spanning
|
|
5294
|
+
// multiple paragraphs (newlines) can't be mapped to one nodeId without the
|
|
5295
|
+
// ranges overflowing the block, so skip styling it (text is still applied).
|
|
5296
|
+
if (text.includes('\n'))
|
|
5297
|
+
continue;
|
|
5298
|
+
const runs = spec.runs && spec.runs.length > 0 ? spec.runs : [{ text: spec.text ?? '' }];
|
|
5299
|
+
const len = Math.max(text.length, 1);
|
|
5300
|
+
if (baseInline)
|
|
5301
|
+
;
|
|
5302
|
+
await applyRange(nodeId, 0, len, marksToInline(spec.marks));
|
|
5303
|
+
// Same baseline reasoning as the list path: an unmarked run must clear
|
|
5304
|
+
// boolean marks the base/whole-item layers put underneath it.
|
|
5305
|
+
const runBaseline = { ...baseInline, ...marksToInline(spec.marks) };
|
|
5306
|
+
const explicitRunPattern = (spec.runs?.length ?? 0) > 1 || (spec.runs ?? []).some((r) => r.marks != null);
|
|
5307
|
+
let offset = 0;
|
|
5308
|
+
for (const run of runs) {
|
|
5309
|
+
const runLen = run.text.length;
|
|
5310
|
+
const runInline = explicitRunPattern
|
|
5311
|
+
? runInlineAgainstBaseline(run.marks, runBaseline)
|
|
5312
|
+
: marksToInline(run.marks);
|
|
5313
|
+
if (Object.keys(runInline).length > 0)
|
|
5314
|
+
await applyRange(nodeId, offset, offset + runLen, runInline);
|
|
5315
|
+
offset += runLen;
|
|
5316
|
+
}
|
|
5317
|
+
}
|
|
5318
|
+
if (layers > 0)
|
|
5319
|
+
return { layers, ...(redundantLayers > 0 ? { alreadyMatched: redundantLayers } : {}) };
|
|
5320
|
+
return redundantLayers > 0 ? { layers: 0, alreadyMatched: redundantLayers } : { skipped: 'no marks requested' };
|
|
5321
|
+
}
|
|
5322
|
+
catch (err) {
|
|
5323
|
+
return { skipped: reasonOf(err) };
|
|
5324
|
+
}
|
|
5325
|
+
}
|
|
3841
5326
|
async function listBlockRows(doc) {
|
|
3842
5327
|
const fn = maybeMethod(doc, ['blocks', 'list']);
|
|
3843
5328
|
if (!fn)
|
|
@@ -3849,6 +5334,11 @@ function blockNumbering(row) {
|
|
|
3849
5334
|
const numbering = row?.numbering;
|
|
3850
5335
|
return numbering && (numbering.marker || numbering.path) ? numbering : null;
|
|
3851
5336
|
}
|
|
5337
|
+
/** A list row's outline level, or null when the row carries no numbering. */
|
|
5338
|
+
function levelOfRow(row) {
|
|
5339
|
+
const path = blockNumbering(row)?.path;
|
|
5340
|
+
return Array.isArray(path) ? Math.max(0, path.length - 1) : null;
|
|
5341
|
+
}
|
|
3852
5342
|
/** One real list across a contiguous paragraph range (lists.create fromParagraphs). */
|
|
3853
5343
|
async function createListFromParagraphRange(createFn, kind, fromNodeId, toNodeId, changeMode) {
|
|
3854
5344
|
// Dual dialect — see executeCreateParagraph: input key for the CLI
|
|
@@ -4140,7 +5630,7 @@ async function runConvertList(doc, args) {
|
|
|
4140
5630
|
// level does not exist in the abstract definition"). lists.create
|
|
4141
5631
|
// converts the paragraph range into a fresh real list instead — the
|
|
4142
5632
|
// correct result in every one of these cases.
|
|
4143
|
-
const message =
|
|
5633
|
+
const message = reasonOf(error);
|
|
4144
5634
|
const recoverable = /numbering metadata|abstract definition|level does not exist/i.test(message);
|
|
4145
5635
|
if (!recoverable || !createFn) {
|
|
4146
5636
|
throw error;
|
|
@@ -4156,7 +5646,7 @@ async function runConvertList(doc, args) {
|
|
|
4156
5646
|
// "Ghost" list items: listItem nodes with no numbering metadata.
|
|
4157
5647
|
// setType/applyPreset/create all refuse them — detach to plain
|
|
4158
5648
|
// paragraphs first (nodeIds are stable), then create the real list.
|
|
4159
|
-
const second =
|
|
5649
|
+
const second = reasonOf(secondError);
|
|
4160
5650
|
const detachFn = maybeMethod(doc, ['lists', 'detach']);
|
|
4161
5651
|
if (!/already list items/i.test(second) || !detachFn) {
|
|
4162
5652
|
throw secondError;
|
|
@@ -4408,7 +5898,7 @@ function indentsEqual(a, b) {
|
|
|
4408
5898
|
const keys = ['left', 'right', 'firstLine', 'hanging'];
|
|
4409
5899
|
return keys.every((k) => (a?.[k] ?? 0) === (b?.[k] ?? 0));
|
|
4410
5900
|
}
|
|
4411
|
-
async function matchOneBlock(doc, postBlocks, preIds, created, createdIndex, anchorRow, allowNumberingAttach, isTitle = false) {
|
|
5901
|
+
async function matchOneBlock(doc, postBlocks, preIds, created, createdIndex, anchorRow, allowNumberingAttach, isTitle = false, allowLeadPattern = true, createdText = '') {
|
|
4412
5902
|
try {
|
|
4413
5903
|
// Reference: placement anchor first, then nearest same-type sibling
|
|
4414
5904
|
// (previous preferred), skipping empties and other fresh blocks.
|
|
@@ -4453,7 +5943,7 @@ async function matchOneBlock(doc, postBlocks, preIds, created, createdIndex, anc
|
|
|
4453
5943
|
}
|
|
4454
5944
|
catch (err) {
|
|
4455
5945
|
// style/format matching continues, but the receipt must say so
|
|
4456
|
-
applied.numberingSkipped =
|
|
5946
|
+
applied.numberingSkipped = reasonOf(err);
|
|
4457
5947
|
}
|
|
4458
5948
|
}
|
|
4459
5949
|
}
|
|
@@ -4509,14 +5999,176 @@ async function matchOneBlock(doc, postBlocks, preIds, created, createdIndex, anc
|
|
|
4509
5999
|
applied.indent = refIndent;
|
|
4510
6000
|
}
|
|
4511
6001
|
}
|
|
6002
|
+
// Lead-marker pattern: manually lettered/numbered sections style the
|
|
6003
|
+
// marker run differently from the body ("(m) " bold, rest plain) — a
|
|
6004
|
+
// per-run pattern blocks.list rows cannot express. When the reference
|
|
6005
|
+
// block opens with such a styled marker and the created text opens with a
|
|
6006
|
+
// similar marker token, reproduce the reference's marker/body split on the
|
|
6007
|
+
// created block. Skipped when the caller supplied explicit runs/marks.
|
|
6008
|
+
if (allowLeadPattern) {
|
|
6009
|
+
const lead = await matchLeadMarkerPattern(doc, reference, created, createdText);
|
|
6010
|
+
if (lead)
|
|
6011
|
+
applied.leadMarker = lead;
|
|
6012
|
+
// matchLeadMarkerPattern only knows a shared literal lead ("WHEREAS") and
|
|
6013
|
+
// numbered tokens ("1.", "(m)"), and it needs the reference split across
|
|
6014
|
+
// two runs. Sample the reference's RUNS for everything else: a quoted
|
|
6015
|
+
// defined term (`"Deliverables" means …`), a "1.7. Records. " clause whose
|
|
6016
|
+
// marker and label share a run, and — the case nothing covered — a
|
|
6017
|
+
// UNIFORM reference, where the whole look transfers (a bold signature-block
|
|
6018
|
+
// name beside which the new one landed plain).
|
|
6019
|
+
else {
|
|
6020
|
+
const runPattern = await matchBlockRunPattern(doc, reference, created, createdText);
|
|
6021
|
+
// Assigning `undefined` would still create the key, and an `applied` with
|
|
6022
|
+
// keys reads as "this block was matched" — hiding a block that got nothing.
|
|
6023
|
+
if (runPattern)
|
|
6024
|
+
applied.runPattern = runPattern;
|
|
6025
|
+
}
|
|
6026
|
+
}
|
|
4512
6027
|
if (Object.keys(applied).length > 0) {
|
|
4513
6028
|
return { matchedSibling: reference.nodeId, applied };
|
|
4514
6029
|
}
|
|
4515
6030
|
return null;
|
|
4516
6031
|
}
|
|
4517
6032
|
catch (err) {
|
|
4518
|
-
return { matchedSibling: null, skipped:
|
|
6033
|
+
return { matchedSibling: null, skipped: reasonOf(err) };
|
|
6034
|
+
}
|
|
6035
|
+
}
|
|
6036
|
+
/** A lead-in marker token like "(m)", "1.", "a)", "(iv)" at the start of clause text. */
|
|
6037
|
+
const LEAD_MARKER_PATTERN = /^\(?(?:[a-zA-Z]{1,3}|\d{1,3})[).:\]]/;
|
|
6038
|
+
/**
|
|
6039
|
+
* Reproduce a reference block's lead/body run split on a freshly-created
|
|
6040
|
+
* block. Fires only when the reference genuinely has the pattern — its first
|
|
6041
|
+
* run is a SHORT lead-in styled differently from the body — and the created
|
|
6042
|
+
* text opens with a matching lead of its own. Two lead shapes are recognized:
|
|
6043
|
+
* - literal lead words shared by siblings ("WHEREAS", "NOW, THEREFORE"): the
|
|
6044
|
+
* created text starts with the reference's exact lead text;
|
|
6045
|
+
* - marker tokens ("(m)", "1.", "a)"): the token text differs per clause, so
|
|
6046
|
+
* the created lead is its OWN token matched by shape.
|
|
6047
|
+
* Applies the reference lead's marks to the created lead span and the
|
|
6048
|
+
* reference body's marks to the rest — with explicit false for boolean marks
|
|
6049
|
+
* only one side has, so formatting inherited from a mis-styled anchor is
|
|
6050
|
+
* corrected, not compounded.
|
|
6051
|
+
*/
|
|
6052
|
+
async function matchLeadMarkerPattern(doc, reference, created, createdText) {
|
|
6053
|
+
const createdPreview = created.textPreview ?? '';
|
|
6054
|
+
if (!reference.nodeId || !created.nodeId || !createdPreview)
|
|
6055
|
+
return null;
|
|
6056
|
+
const queryFn = maybeMethod(doc, ['query', 'match']);
|
|
6057
|
+
const formatFn = maybeMethod(doc, ['format', 'apply']);
|
|
6058
|
+
if (!queryFn || !formatFn)
|
|
6059
|
+
return null;
|
|
6060
|
+
// A short unique-enough prefix of the reference is all we need: the runs
|
|
6061
|
+
// returned tile the MATCHED range, covering the lead and the body start.
|
|
6062
|
+
// blocks.list truncates textPreview with a trailing ellipsis — strip it, the
|
|
6063
|
+
// remaining prefix IS document text so query.match can find it.
|
|
6064
|
+
const refProbe = (reference.textPreview ?? '')
|
|
6065
|
+
.replace(/…\s*$/, '')
|
|
6066
|
+
.slice(0, 60)
|
|
6067
|
+
.trim();
|
|
6068
|
+
if (!refProbe)
|
|
6069
|
+
return null;
|
|
6070
|
+
const refRuns = await docSnapshot.matchRunsForBlock(queryFn, refProbe, reference.nodeId);
|
|
6071
|
+
if (refRuns.length < 2)
|
|
6072
|
+
return null; // uniform reference — no split to copy
|
|
6073
|
+
const leadRun = refRuns[0];
|
|
6074
|
+
const bodyRun = refRuns[1];
|
|
6075
|
+
// The pattern requires a real FORMATTING transition between lead and body
|
|
6076
|
+
// (bold/italic/underline/strike). Runs split only by font/color/hyperlink
|
|
6077
|
+
// boundaries are ordinary prose — restyling from those would misfire.
|
|
6078
|
+
if (RUN_BOOL_MARKS.every((key) => Boolean(leadRun[key]) === Boolean(bodyRun[key])))
|
|
6079
|
+
return null;
|
|
6080
|
+
const refLead = leadRun.text.trimEnd();
|
|
6081
|
+
// The first run must be a short lead-in (a token or a word or two), not
|
|
6082
|
+
// half the sentence.
|
|
6083
|
+
if (refLead.length === 0 || refLead.length > 40 || leadRun.start !== 0)
|
|
6084
|
+
return null;
|
|
6085
|
+
// The caller knows the text it inserted; the blocks.list preview is truncated.
|
|
6086
|
+
const fullText = createdText || createdPreview.replace(/…\s*$/, '');
|
|
6087
|
+
// The created block's own lead span (ASCII markers only by design).
|
|
6088
|
+
let createdLeadLen = 0;
|
|
6089
|
+
const boundaryAfter = (len) => len >= fullText.length || !/[A-Za-z0-9]/.test(fullText[len]);
|
|
6090
|
+
if (fullText.startsWith(refLead) && boundaryAfter(refLead.length)) {
|
|
6091
|
+
// Literal shared lead word(s) — "WHEREAS, …" after a "WHEREAS, …" sibling.
|
|
6092
|
+
// Exact-case with a token boundary so "Note" never claims "Notebook".
|
|
6093
|
+
createdLeadLen = refLead.length;
|
|
6094
|
+
}
|
|
6095
|
+
else {
|
|
6096
|
+
const refMarker = LEAD_MARKER_PATTERN.exec(refLead);
|
|
6097
|
+
const createdMarker = LEAD_MARKER_PATTERN.exec(fullText);
|
|
6098
|
+
// Marker tokens: the reference's first run must BE the token, and the
|
|
6099
|
+
// created text must open with a token of the same category (letters
|
|
6100
|
+
// with letters, digits with digits — "(m)" doesn't restyle "1.").
|
|
6101
|
+
if (refMarker &&
|
|
6102
|
+
createdMarker &&
|
|
6103
|
+
refMarker[0] === refLead &&
|
|
6104
|
+
/\d/.test(refMarker[0]) === /\d/.test(createdMarker[0])) {
|
|
6105
|
+
createdLeadLen = createdMarker[0].length;
|
|
6106
|
+
}
|
|
6107
|
+
}
|
|
6108
|
+
if (createdLeadLen === 0)
|
|
6109
|
+
return null;
|
|
6110
|
+
const normalize = new Set(RUN_BOOL_MARKS.filter((key) => leadRun[key] !== bodyRun[key]));
|
|
6111
|
+
const markerInline = inlineFromSnapshotRun(leadRun, normalize);
|
|
6112
|
+
const bodyInline = inlineFromSnapshotRun(bodyRun, normalize);
|
|
6113
|
+
if (Object.keys(markerInline).length === 0 && Object.keys(bodyInline).length === 0)
|
|
6114
|
+
return null;
|
|
6115
|
+
let changed = false;
|
|
6116
|
+
if (Object.keys(markerInline).length > 0) {
|
|
6117
|
+
changed =
|
|
6118
|
+
(await applyInlineQuiet(formatFn, {
|
|
6119
|
+
blockId: created.nodeId,
|
|
6120
|
+
start: 0,
|
|
6121
|
+
end: createdLeadLen,
|
|
6122
|
+
inline: markerInline,
|
|
6123
|
+
})) || changed;
|
|
6124
|
+
}
|
|
6125
|
+
if (Object.keys(bodyInline).length > 0 && fullText.length > createdLeadLen) {
|
|
6126
|
+
changed =
|
|
6127
|
+
(await applyInlineQuiet(formatFn, {
|
|
6128
|
+
blockId: created.nodeId,
|
|
6129
|
+
start: createdLeadLen,
|
|
6130
|
+
end: fullText.length,
|
|
6131
|
+
inline: bodyInline,
|
|
6132
|
+
})) || changed;
|
|
6133
|
+
}
|
|
6134
|
+
return { marker: fullText.slice(0, createdLeadLen), markerInline, bodyInline, changed };
|
|
6135
|
+
}
|
|
6136
|
+
/**
|
|
6137
|
+
* Copy a reference block's RUN pattern onto a created block: the body look over
|
|
6138
|
+
* the whole block, then the label look over the new block's own label span.
|
|
6139
|
+
*
|
|
6140
|
+
* Complements matchLeadMarkerPattern, which requires either a shared literal
|
|
6141
|
+
* lead word or a marker token AND a two-run reference. This one reads the
|
|
6142
|
+
* reference's runs (`query.match`, the only read that reports run styling) and
|
|
6143
|
+
* handles the shapes that leaves out — including a reference with a single
|
|
6144
|
+
* uniform run, whose look simply transfers.
|
|
6145
|
+
*/
|
|
6146
|
+
async function matchBlockRunPattern(doc, reference, created, createdText) {
|
|
6147
|
+
const formatFn = maybeMethod(doc, ['format', 'apply']);
|
|
6148
|
+
const referenceText = (reference.textPreview ?? '').replace(/…\s*$/, '').trim();
|
|
6149
|
+
const fullText = createdText || (created.textPreview ?? '').replace(/…\s*$/, '');
|
|
6150
|
+
if (!formatFn || !reference.nodeId || !created.nodeId || !referenceText || !fullText)
|
|
6151
|
+
return null;
|
|
6152
|
+
const pattern = await listItemRunPattern(doc, referenceText, reference.nodeId);
|
|
6153
|
+
if (!pattern)
|
|
6154
|
+
return null;
|
|
6155
|
+
const end = Math.max(fullText.length, 1);
|
|
6156
|
+
const applied = {};
|
|
6157
|
+
if (Object.keys(pattern.body).length > 0) {
|
|
6158
|
+
if (await applyInlineQuiet(formatFn, { blockId: created.nodeId, start: 0, end, inline: pattern.body })) {
|
|
6159
|
+
applied.body = pattern.body;
|
|
6160
|
+
}
|
|
6161
|
+
}
|
|
6162
|
+
if (pattern.lead) {
|
|
6163
|
+
const span = leadInSpanOf(fullText, pattern.lead.shape);
|
|
6164
|
+
// A uniform reference has no body look and no delimiter of its own: its look
|
|
6165
|
+
// belongs to the whole created block, not to a label span inside it.
|
|
6166
|
+
const range = span ?? (Object.keys(pattern.body).length === 0 ? { start: 0, end } : null);
|
|
6167
|
+
if (range && (await applyInlineQuiet(formatFn, { blockId: created.nodeId, ...range, inline: pattern.lead.look }))) {
|
|
6168
|
+
applied.lead = { ...pattern.lead.look, range: [range.start, range.end] };
|
|
6169
|
+
}
|
|
4519
6170
|
}
|
|
6171
|
+
return Object.keys(applied).length > 0 ? { matchedSibling: reference.nodeId, ...applied } : null;
|
|
4520
6172
|
}
|
|
4521
6173
|
async function matchInsertedBlockFormatting(doc, preBlocks, receipt, args) {
|
|
4522
6174
|
if (receipt.status !== 'ok')
|
|
@@ -4535,6 +6187,17 @@ async function matchInsertedBlockFormatting(doc, preBlocks, receipt, args) {
|
|
|
4535
6187
|
const anchorSelector = placement && isRecord(placement.selector) ? placement.selector : null;
|
|
4536
6188
|
const anchorId = anchorSelector && typeof anchorSelector.nodeId === 'string' ? anchorSelector.nodeId : null;
|
|
4537
6189
|
const anchorRow = anchorId ? (postBlocks.find((b) => b.nodeId === anchorId) ?? null) : null;
|
|
6190
|
+
// When the caller styled any entry explicitly (runs/marks), it took
|
|
6191
|
+
// ownership of intra-paragraph formatting — the automatic lead-marker
|
|
6192
|
+
// pattern pass must not fight it.
|
|
6193
|
+
const rawTexts = Array.isArray(args?.texts) ? args.texts : [];
|
|
6194
|
+
const hasExplicitStyling = rawTexts.some((entry) => isRecord(entry) && (Array.isArray(entry.runs) || entry.marks != null));
|
|
6195
|
+
const insertedTexts = rawTexts.map((entry) => {
|
|
6196
|
+
if (typeof entry === 'string')
|
|
6197
|
+
return entry;
|
|
6198
|
+
const spec = normalizeItemSpec(entry);
|
|
6199
|
+
return spec ? itemSpecText(spec) : '';
|
|
6200
|
+
});
|
|
4538
6201
|
const allApplied = [];
|
|
4539
6202
|
for (let ci = 0; ci < createdRows.length; ci += 1) {
|
|
4540
6203
|
const { row: created, index: createdIndex } = createdRows[ci];
|
|
@@ -4542,7 +6205,24 @@ async function matchInsertedBlockFormatting(doc, preBlocks, receipt, args) {
|
|
|
4542
6205
|
// read like a heading (flush-left), so it must NOT inherit the body
|
|
4543
6206
|
// sibling's left indent even though it matches the same anchor.
|
|
4544
6207
|
const isTitle = createdRows.length > 1 && ci === 0;
|
|
4545
|
-
const
|
|
6208
|
+
const createdText = insertedTexts[ci] ?? '';
|
|
6209
|
+
const match = (anchor) => matchOneBlock(doc, postBlocks, preIds, created, createdIndex, anchor, createdRows.length === 1, isTitle, !hasExplicitStyling,
|
|
6210
|
+
// The texts the caller inserted, in order — the truncated preview
|
|
6211
|
+
// cannot measure a label span or the end of the block.
|
|
6212
|
+
createdText);
|
|
6213
|
+
let result = await match(anchorRow);
|
|
6214
|
+
// A GROUP of paragraphs usually repeats the group before it: a signature
|
|
6215
|
+
// block is name / "By:" / "Name:", and the new name line should look like
|
|
6216
|
+
// the previous NAME line, not like the "Name:" line directly above it.
|
|
6217
|
+
// Only tried when matching against the neighbour found nothing, so this
|
|
6218
|
+
// cannot change a case that already had an answer.
|
|
6219
|
+
const applied = isRecord(result?.applied) ? result.applied : null;
|
|
6220
|
+
if (createdRows.length > 1 && (!applied || Object.keys(applied).length === 0)) {
|
|
6221
|
+
const parallel = postBlocks[createdIndex - createdRows.length];
|
|
6222
|
+
if (parallel && preIds.has(parallel.nodeId) && !parallel.isEmpty) {
|
|
6223
|
+
result = (await match(parallel)) ?? result;
|
|
6224
|
+
}
|
|
6225
|
+
}
|
|
4546
6226
|
if (result)
|
|
4547
6227
|
allApplied.push(result);
|
|
4548
6228
|
}
|
|
@@ -4555,7 +6235,7 @@ async function matchInsertedBlockFormatting(doc, preBlocks, receipt, args) {
|
|
|
4555
6235
|
// insert because of it — but the receipt says it was skipped.
|
|
4556
6236
|
return {
|
|
4557
6237
|
...receipt,
|
|
4558
|
-
contextualFormatting: { skipped:
|
|
6238
|
+
contextualFormatting: { skipped: reasonOf(err) },
|
|
4559
6239
|
};
|
|
4560
6240
|
}
|
|
4561
6241
|
}
|
|
@@ -4766,10 +6446,20 @@ async function superdocPerformAction(doc, args) {
|
|
|
4766
6446
|
}
|
|
4767
6447
|
switch (action) {
|
|
4768
6448
|
case 'insert_paragraphs': {
|
|
4769
|
-
// Accept
|
|
4770
|
-
|
|
4771
|
-
|
|
4772
|
-
|
|
6449
|
+
// Accept `texts` (in order) or a single `text`. Each entry may be a
|
|
6450
|
+
// plain string or a {text, runs, marks} spec — styled entries get a
|
|
6451
|
+
// post-insert format.apply pass (mirrors add_list_items / cellTexts).
|
|
6452
|
+
const rawEntries = Array.isArray(args.texts) ? args.texts : args.text != null ? [args.text] : [];
|
|
6453
|
+
const texts = [];
|
|
6454
|
+
const textSpecs = [];
|
|
6455
|
+
for (const raw of rawEntries) {
|
|
6456
|
+
const spec = normalizeItemSpec(raw);
|
|
6457
|
+
if (!spec)
|
|
6458
|
+
continue;
|
|
6459
|
+
texts.push(itemSpecText(spec));
|
|
6460
|
+
textSpecs.push((spec.runs && spec.runs.length > 0) || spec.marks != null ? spec : null);
|
|
6461
|
+
}
|
|
6462
|
+
if (texts.length === 0) {
|
|
4773
6463
|
throw new errors.SuperDocCliError('insert_paragraphs requires a non-empty "texts" array (or a "text" string for a single paragraph)', {
|
|
4774
6464
|
code: 'INVALID_ARGUMENT',
|
|
4775
6465
|
});
|
|
@@ -4779,6 +6469,7 @@ async function superdocPerformAction(doc, args) {
|
|
|
4779
6469
|
const receipt = await runInsertParagraphs(doc, {
|
|
4780
6470
|
action,
|
|
4781
6471
|
texts,
|
|
6472
|
+
textSpecs,
|
|
4782
6473
|
placement: parsePlacement(args.placement),
|
|
4783
6474
|
changeMode: parseChangeMode(args.changeMode),
|
|
4784
6475
|
headingLevel: headingLevel != null && Number.isInteger(headingLevel) && headingLevel >= 1 && headingLevel <= 6
|
|
@@ -5105,7 +6796,7 @@ async function superdocPerformAction(doc, args) {
|
|
|
5105
6796
|
args.position === 'below'
|
|
5106
6797
|
? args.position
|
|
5107
6798
|
: undefined;
|
|
5108
|
-
const cellTexts =
|
|
6799
|
+
const cellTexts = parseCellArray(args.cellTexts);
|
|
5109
6800
|
return runInsertTableRow(doc, {
|
|
5110
6801
|
action,
|
|
5111
6802
|
tableOrdinal: tableOrdinal != null && Number.isInteger(tableOrdinal) && tableOrdinal >= 1 ? tableOrdinal : undefined,
|
|
@@ -5126,6 +6817,7 @@ async function superdocPerformAction(doc, args) {
|
|
|
5126
6817
|
columnIndex: columnIndex != null && Number.isInteger(columnIndex) && columnIndex >= 0 ? columnIndex : undefined,
|
|
5127
6818
|
position,
|
|
5128
6819
|
headerText: asString(args.headerText),
|
|
6820
|
+
cellTexts: parseCellArray(args.cellTexts) ?? undefined,
|
|
5129
6821
|
changeMode: parseChangeMode(args.changeMode),
|
|
5130
6822
|
});
|
|
5131
6823
|
}
|
|
@@ -5291,25 +6983,21 @@ async function superdocPerformAction(doc, args) {
|
|
|
5291
6983
|
const anchorText = asString(args.anchorText) ?? '';
|
|
5292
6984
|
const listOrdinalRaw = asNumber(args.listOrdinal);
|
|
5293
6985
|
const listOrdinal = listOrdinalRaw != null && Number.isInteger(listOrdinalRaw) && listOrdinalRaw >= 1 ? listOrdinalRaw : undefined;
|
|
6986
|
+
// Parse entries + items — either may carry per-run styling (runs/marks).
|
|
6987
|
+
// `items` is an alias for `entries` at the list's base level.
|
|
6988
|
+
const entries = [];
|
|
5294
6989
|
const rawEntries = Array.isArray(args.entries) ? args.entries : [];
|
|
5295
|
-
const
|
|
5296
|
-
|
|
5297
|
-
if (
|
|
5298
|
-
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
|
|
5302
|
-
const
|
|
5303
|
-
|
|
6990
|
+
for (const e of rawEntries) {
|
|
6991
|
+
const spec = normalizeItemSpec(e);
|
|
6992
|
+
if (spec)
|
|
6993
|
+
entries.push(spec);
|
|
6994
|
+
}
|
|
6995
|
+
if (entries.length === 0 && Array.isArray(args.items)) {
|
|
6996
|
+
for (const it of args.items) {
|
|
6997
|
+
const spec = normalizeItemSpec(it);
|
|
6998
|
+
if (spec)
|
|
6999
|
+
entries.push(spec);
|
|
5304
7000
|
}
|
|
5305
|
-
return null;
|
|
5306
|
-
})
|
|
5307
|
-
.filter((e) => e != null);
|
|
5308
|
-
// `items` is a plain-string alias for `entries` at the list's base level.
|
|
5309
|
-
const plainItems = parseStringArray(args.items);
|
|
5310
|
-
if (entries.length === 0 && plainItems) {
|
|
5311
|
-
for (const t of plainItems)
|
|
5312
|
-
entries.push({ text: t, level: 0 });
|
|
5313
7001
|
}
|
|
5314
7002
|
if (entries.length === 0) {
|
|
5315
7003
|
throw new errors.SuperDocCliError('add_list_items requires non-empty "entries" or "items"', {
|
|
@@ -5322,17 +7010,27 @@ async function superdocPerformAction(doc, args) {
|
|
|
5322
7010
|
});
|
|
5323
7011
|
}
|
|
5324
7012
|
const changeMode = parseChangeMode(args.changeMode);
|
|
5325
|
-
// Preferred path: a real NUMBERED list
|
|
5326
|
-
//
|
|
7013
|
+
// Preferred path: attach each entry to a real NUMBERED list (reuses the
|
|
7014
|
+
// list's markers + numbering, and applies anchor + whole-item + per-run
|
|
7015
|
+
// formatting). Locate the list by anchorText, else by resolving
|
|
7016
|
+
// listOrdinal to its last item's nodeId — the latter lets styled inserts
|
|
7017
|
+
// reach this path even when the model addressed the list by ordinal.
|
|
7018
|
+
let anchored = null;
|
|
5327
7019
|
if (anchorText) {
|
|
5328
|
-
|
|
5329
|
-
|
|
5330
|
-
|
|
7020
|
+
anchored = await runAddListItems(doc, { action, anchorText, entries, changeMode }, { fallbackOnMissing: true });
|
|
7021
|
+
}
|
|
7022
|
+
if (!anchored && listOrdinal != null) {
|
|
7023
|
+
const anchorNodeId = await resolveListAnchorNodeId(doc, listOrdinal);
|
|
7024
|
+
if (anchorNodeId) {
|
|
7025
|
+
anchored = await runAddListItems(doc, { action, anchorNodeId, entries, changeMode }, { fallbackOnMissing: true });
|
|
7026
|
+
}
|
|
5331
7027
|
}
|
|
7028
|
+
if (anchored)
|
|
7029
|
+
return anchored;
|
|
5332
7030
|
// Fallback: ghost lists (imported list-looking paragraphs with no
|
|
5333
|
-
// numbering) and listOrdinal addressing.
|
|
5334
|
-
//
|
|
5335
|
-
const flatItems = entries.map(
|
|
7031
|
+
// numbering) and listOrdinal addressing. This path is text-only, so any
|
|
7032
|
+
// requested per-run styling is dropped here.
|
|
7033
|
+
const flatItems = entries.map(itemSpecText);
|
|
5336
7034
|
const ghost = await insertListItemsIntoGhostList(doc, {
|
|
5337
7035
|
items: flatItems,
|
|
5338
7036
|
anchorText: anchorText || undefined,
|
|
@@ -5341,7 +7039,7 @@ async function superdocPerformAction(doc, args) {
|
|
|
5341
7039
|
});
|
|
5342
7040
|
if (ghost)
|
|
5343
7041
|
return ghost;
|
|
5344
|
-
return runInsertListItems(doc, { items: flatItems, listOrdinal, changeMode });
|
|
7042
|
+
return runInsertListItems(doc, { items: flatItems, listOrdinal, styled: entries, changeMode });
|
|
5345
7043
|
}
|
|
5346
7044
|
}
|
|
5347
7045
|
}
|
|
@@ -5392,6 +7090,23 @@ function parseEdits(value) {
|
|
|
5392
7090
|
}
|
|
5393
7091
|
return result;
|
|
5394
7092
|
}
|
|
7093
|
+
/** Parse one cell input: a plain string, or a styled {text, runs, marks} object.
|
|
7094
|
+
* Unparseable input coerces to an empty cell so a grid isn't voided by one bad cell. */
|
|
7095
|
+
function parseCellCandidate(cell) {
|
|
7096
|
+
if (typeof cell === 'string')
|
|
7097
|
+
return cell;
|
|
7098
|
+
if (isRecord(cell)) {
|
|
7099
|
+
const spec = normalizeItemSpec(cell);
|
|
7100
|
+
if (spec)
|
|
7101
|
+
return { text: spec.text, runs: spec.runs, marks: spec.marks };
|
|
7102
|
+
}
|
|
7103
|
+
return '';
|
|
7104
|
+
}
|
|
7105
|
+
function parseCellArray(value) {
|
|
7106
|
+
if (!Array.isArray(value))
|
|
7107
|
+
return undefined;
|
|
7108
|
+
return value.map(parseCellCandidate);
|
|
7109
|
+
}
|
|
5395
7110
|
function parseCellTexts(value) {
|
|
5396
7111
|
if (!Array.isArray(value))
|
|
5397
7112
|
return undefined;
|
|
@@ -5399,13 +7114,7 @@ function parseCellTexts(value) {
|
|
|
5399
7114
|
for (const row of value) {
|
|
5400
7115
|
if (!Array.isArray(row))
|
|
5401
7116
|
return undefined;
|
|
5402
|
-
|
|
5403
|
-
for (const cell of row) {
|
|
5404
|
-
if (typeof cell !== 'string')
|
|
5405
|
-
return undefined;
|
|
5406
|
-
cells.push(cell);
|
|
5407
|
-
}
|
|
5408
|
-
result.push(cells);
|
|
7117
|
+
result.push(row.map(parseCellCandidate));
|
|
5409
7118
|
}
|
|
5410
7119
|
return result;
|
|
5411
7120
|
}
|