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