Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 46 additions & 8 deletions apps/cli/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1028,7 +1028,42 @@ describe('superdoc CLI', () => {
expect(verifyEnvelope.data.result.total).toBeGreaterThan(0);
});

test('insert with --block-id and --offset targets a specific block position', async () => {
test('insert with --block-id and --offset targets a specific block position (legacy compat)', async () => {
const insertSource = join(TEST_DIR, 'insert-blockid-legacy-offset-source.docx');
const insertOut = join(TEST_DIR, 'insert-blockid-legacy-offset-out.docx');
await copyFile(SAMPLE_DOC, insertSource);

const target = await firstTextRange(['find', insertSource, '--type', 'text', '--pattern', 'Wilde']);

const insertResult = await runCli([
'insert',
insertSource,
'--block-id',
target.blockId,
'--offset',
'0',
'--value',
'CLI_BLOCKID_LEGACY_OFFSET_INSERT',
'--out',
insertOut,
]);

expect(insertResult.code).toBe(0);

const verifyResult = await runCli([
'find',
insertOut,
'--type',
'text',
'--pattern',
'CLI_BLOCKID_LEGACY_OFFSET_INSERT',
]);
expect(verifyResult.code).toBe(0);
const verifyEnvelope = parseJsonOutput<SuccessEnvelope<{ result: { total: number } }>>(verifyResult);
expect(verifyEnvelope.data.result.total).toBeGreaterThan(0);
});

test('insert with --block-id and --start/--end targets a specific block position', async () => {
const insertSource = join(TEST_DIR, 'insert-blockid-offset-source.docx');
const insertOut = join(TEST_DIR, 'insert-blockid-offset-out.docx');
await copyFile(SAMPLE_DOC, insertSource);
Expand All @@ -1041,7 +1076,9 @@ describe('superdoc CLI', () => {
insertSource,
'--block-id',
target.blockId,
'--offset',
'--start',
'0',
'--end',
'0',
'--value',
'CLI_BLOCKID_OFFSET_INSERT_1597',
Expand All @@ -1064,7 +1101,7 @@ describe('superdoc CLI', () => {
expect(verifyEnvelope.data.result.total).toBeGreaterThan(0);
});

test('insert with --block-id alone defaults offset to 0', async () => {
test('insert with --block-id alone defaults start/end to 0', async () => {
const insertSource = join(TEST_DIR, 'insert-blockid-only-source.docx');
const insertOut = join(TEST_DIR, 'insert-blockid-only-out.docx');
await copyFile(SAMPLE_DOC, insertSource);
Expand Down Expand Up @@ -1093,15 +1130,19 @@ describe('superdoc CLI', () => {
expect(resolvedTarget?.range.end).toBe(0);
});

test('insert with --offset but no --block-id returns INVALID_ARGUMENT', async () => {
test('insert with --start but no --block-id returns validation error', async () => {
const insertSource = join(TEST_DIR, 'insert-offset-no-blockid-source.docx');
const insertOut = join(TEST_DIR, 'insert-offset-no-blockid-out.docx');
await copyFile(SAMPLE_DOC, insertSource);

// --start/--end without --block-id are not normalized into a target.
// They pass through as unknown fields and are rejected by validation.
const result = await runCli([
'insert',
insertSource,
'--offset',
'--start',
'5',
'--end',
'5',
'--value',
'should-fail',
Expand All @@ -1110,9 +1151,6 @@ describe('superdoc CLI', () => {
]);

expect(result.code).toBe(1);
const envelope = parseJsonOutput<ErrorEnvelope>(result);
expect(envelope.error.code).toBe('INVALID_ARGUMENT');
expect(envelope.error.message).toContain('Unknown field');
});

test('insert with --type html inserts HTML content into the document', async () => {
Expand Down
11 changes: 6 additions & 5 deletions apps/cli/src/__tests__/conformance/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,9 +375,10 @@ export class ConformanceHarness {
): Promise<{ docPath: string; changeId: string; target: TextRangeAddress }> {
const sourceDoc = await this.copyFixtureDoc(`${label}-source`);
const target = await this.firstTextRange(sourceDoc, stateDir);
const collapsedTarget: TextRangeAddress = {
...target,
range: { start: target.range.start, end: target.range.start },
const selectionTarget = {
kind: 'selection',
start: { kind: 'text', blockId: target.blockId, offset: target.range.start },
end: { kind: 'text', blockId: target.blockId, offset: target.range.start },
};
const outDoc = this.createOutputPath(`${label}-with-tracked-change`);

Expand All @@ -386,7 +387,7 @@ export class ConformanceHarness {
'insert',
sourceDoc,
'--target-json',
JSON.stringify(collapsedTarget),
JSON.stringify(selectionTarget),
'--value',
'TRACKED_CONFORMANCE_TOKEN',
'--change-mode',
Expand All @@ -412,7 +413,7 @@ export class ConformanceHarness {
throw new Error(`Tracked-change fixture did not produce a tracked change id for ${label}`);
}

return { docPath: outDoc, changeId, target: collapsedTarget };
return { docPath: outDoc, changeId, target };
}

async firstTwoBlockAddresses(
Expand Down
10 changes: 7 additions & 3 deletions apps/cli/src/__tests__/conformance/scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2084,15 +2084,19 @@ export const SUCCESS_SCENARIOS = {
'doc.insert': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
const stateDir = await harness.createStateDir('doc-insert-success');
const docPath = await harness.copyFixtureDoc('doc-insert');
const target = await harness.firstTextRange(docPath, stateDir);
const collapsed = { ...target, range: { start: target.range.start, end: target.range.start } };
const textRange = await harness.firstTextRange(docPath, stateDir);
const selectionTarget = {
kind: 'selection',
start: { kind: 'text', blockId: textRange.blockId, offset: textRange.range.start },
end: { kind: 'text', blockId: textRange.blockId, offset: textRange.range.start },
};
return {
stateDir,
args: [
'insert',
docPath,
'--target-json',
JSON.stringify(collapsed),
JSON.stringify(selectionTarget),
'--value',
'CONFORMANCE_INSERT',
'--out',
Expand Down
15 changes: 9 additions & 6 deletions apps/cli/src/cli/operation-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,11 +478,6 @@ const TEXT_TARGET_FLAT_PARAMS: CliOperationParamSpec[] = [
{ name: 'end', kind: 'flag', type: 'number', description: 'End offset within the block (character index).' },
];

const INSERT_FLAT_PARAMS: CliOperationParamSpec[] = [
{ name: 'blockId', kind: 'flag', flag: 'block-id', type: 'string', description: 'Block ID of the target paragraph.' },
{ name: 'offset', kind: 'flag', type: 'number', description: 'Character offset within the block for insertion.' },
];

const LIST_TARGET_FLAT_PARAMS: CliOperationParamSpec[] = [
{ name: 'nodeId', kind: 'flag', flag: 'node-id', type: 'string', description: 'Node ID of the target list item.' },
];
Expand Down Expand Up @@ -536,7 +531,15 @@ const EXTRA_CLI_PARAMS: Partial<Record<string, CliOperationParamSpec[]>> = {
},
],
// Text-range operations: flat flags (--block-id, --start, --end) as shortcuts for --target-json
'doc.insert': [...INSERT_FLAT_PARAMS],
'doc.insert': [
...TEXT_TARGET_FLAT_PARAMS,
{
name: 'offset',
kind: 'flag',
type: 'number',
description: 'Character offset for insertion (alias for --start/--end with same value).',
},
],
'doc.replace': [...TEXT_TARGET_FLAT_PARAMS],
'doc.delete': [...TEXT_TARGET_FLAT_PARAMS],
'doc.styles.apply': [
Expand Down
37 changes: 13 additions & 24 deletions apps/cli/src/lib/invoke-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,12 @@ const FORMAT_TARGET_OPERATIONS = CLI_DOC_OPERATIONS.filter((operationId): operat
* The CLI still supports legacy single-block text range flags/JSON inputs and
* upgrades them to the equivalent SelectionTarget before dispatch.
*/
const SELECTION_TARGET_OPERATIONS = new Set<CliExposedOperationId>(['replace', 'delete', ...FORMAT_TARGET_OPERATIONS]);
const SELECTION_TARGET_OPERATIONS = new Set<CliExposedOperationId>([
'insert',
'replace',
'delete',
...FORMAT_TARGET_OPERATIONS,
]);

/**
* Operations that still accept a text-range target (textAddressSchema):
Expand All @@ -104,11 +109,7 @@ const SELECTION_TARGET_OPERATIONS = new Set<CliExposedOperationId>(['replace', '
*/
const TEXT_ADDRESS_TARGET_OPERATIONS = new Set<CliExposedOperationId>(['comments.create', 'comments.patch']);

/**
* Insert is a text-range operation but uses `offset` instead of `start`/`end`
* to specify a zero-width insertion point.
*/
const INSERT_OPERATION: CliExposedOperationId = 'insert';
// INSERT_OPERATION removed — insert now uses SelectionTarget via SELECTION_TARGET_OPERATIONS.

/**
* List operations that accept a list-item target (listItemAddressSchema):
Expand Down Expand Up @@ -196,14 +197,16 @@ function normalizeFlatTargetFlags(operationId: CliExposedOperationId, apiInput:
return apiInput;
}

// --- Selection-based text mutations (replace, delete, format.*) ---
// --- Selection-based text mutations (insert, replace, delete, format.*) ---
if (SELECTION_TARGET_OPERATIONS.has(operationId)) {
const blockId = apiInput.blockId;
if (typeof blockId === 'string') {
const start = typeof apiInput.start === 'number' ? apiInput.start : 0;
const end = typeof apiInput.end === 'number' ? apiInput.end : 0;
// Legacy --offset for insert: expand to collapsed start/end
const hasOffset = typeof apiInput.offset === 'number';
const start = typeof apiInput.start === 'number' ? apiInput.start : hasOffset ? (apiInput.offset as number) : 0;
const end = typeof apiInput.end === 'number' ? apiInput.end : hasOffset ? (apiInput.offset as number) : 0;
assertLegacySelectionTargetSupported(operationId, { range: { start, end } });
const { blockId: _, start: _s, end: _e, ...rest } = apiInput;
const { blockId: _, start: _s, end: _e, offset: _o, ...rest } = apiInput;
return {
...rest,
target: textAddressToSelectionTarget({ blockId, range: { start, end } }),
Expand All @@ -227,20 +230,6 @@ function normalizeFlatTargetFlags(operationId: CliExposedOperationId, apiInput:
return apiInput;
}

// --- Insert operation (uses offset for zero-width insertion point) ---
if (operationId === INSERT_OPERATION) {
const blockId = apiInput.blockId;
if (typeof blockId === 'string') {
const offset = typeof apiInput.offset === 'number' ? apiInput.offset : 0;
const { blockId: _, offset: _o, ...rest } = apiInput;
return {
...rest,
target: { kind: 'text', blockId, range: { start: offset, end: offset } },
};
}
return apiInput;
}

// --- Block delete (nodeType + nodeId → block target) ---
if (operationId === 'blocks.delete') {
const nodeType = apiInput.nodeType;
Expand Down
4 changes: 3 additions & 1 deletion apps/cli/src/lib/operation-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,9 @@ function acceptsLegacyTextAddressTarget(
): boolean {
if (param.name !== 'target' || !isTextAddressLike(value)) return false;
const docApiId = toDocApiId(operationId);
return docApiId === 'replace' || docApiId === 'delete' || docApiId?.startsWith('format.') === true;
return (
docApiId === 'insert' || docApiId === 'replace' || docApiId === 'delete' || docApiId?.startsWith('format.') === true
);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -986,5 +986,5 @@
}
],
"marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}",
"sourceHash": "4b45100573fb74d1eb2f006d5064fbcc2e5af3331272207dccbab5f0c97142ef"
"sourceHash": "52013243a974232110399da20b569ed98bfa7ab25ab0c09f314168b748ebc7a8"
}
2 changes: 1 addition & 1 deletion apps/docs/document-api/reference/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ The tables below are grouped by namespace.
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><a href="/document-api/reference/markdown-to-fragment"><code>markdownToFragment</code></a></span> | <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.markdownToFragment(...)</code></span> | Convert a Markdown string into an SDM/1 structural fragment. |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><a href="/document-api/reference/info"><code>info</code></a></span> | <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.info(...)</code></span> | Return document summary info including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, list, and page counts, plus outline and capabilities. |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><a href="/document-api/reference/clear-content"><code>clearContent</code></a></span> | <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.clearContent(...)</code></span> | Clear all document body content, leaving a single empty paragraph. |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><a href="/document-api/reference/insert"><code>insert</code></a></span> | <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.insert(...)</code></span> | Insert content into the document. Two input shapes: legacy string-based (value + type) inserts inline content at a text position within an existing block; structural SDFragment (content) inserts one or more blocks as siblings relative to a BlockNodeAddress target. When target is omitted, content appends at the end of the document. Legacy mode supports text (default), markdown, and html content types via the `type` field. Structural mode uses `placement` (before/after/insideStart/insideEnd) to position relative to the target block. |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><a href="/document-api/reference/insert"><code>insert</code></a></span> | <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.insert(...)</code></span> | Insert content into the document. Two input shapes: text-based (value + type) inserts inline content at a SelectionTarget or ref position within an existing block; structural SDFragment (content) inserts one or more blocks as siblings relative to a BlockNodeAddress target. When target/ref is omitted, content appends at the end of the document. Text mode supports text (default), markdown, and html content types via the `type` field. Structural mode uses `placement` (before/after/insideStart/insideEnd) to position relative to the target block. |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><a href="/document-api/reference/replace"><code>replace</code></a></span> | <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.replace(...)</code></span> | Replace content at a contiguous document selection. Text path accepts a SelectionTarget or ref plus replacement text. Structural path accepts a BlockNodeAddress (replaces whole block), SelectionTarget (expands to full covered block boundaries), or ref plus SDFragment content. |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><a href="/document-api/reference/delete"><code>delete</code></a></span> | <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.delete(...)</code></span> | Delete content at a contiguous document selection. Accepts a SelectionTarget or mutation-ready ref. Supports cross-block deletion and optional block-edge expansion via behavior mode. |

Expand Down
Loading
Loading