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
86 changes: 86 additions & 0 deletions apps/cli/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,13 @@ describe('superdoc CLI', () => {
expect(result.stdout).not.toContain('<doc> Document path or stdin');
});

test('describe command doc.insert includes --block-id and --offset flags', async () => {
const result = await runCli(['describe', 'command', 'doc.insert', '--output', 'pretty']);
expect(result.code).toBe(0);
expect(result.stdout).toContain('--block-id');
expect(result.stdout).toContain('--offset');
});

test('call executes an operation from canonical input payload', async () => {
const result = await runCli([
'call',
Expand Down Expand Up @@ -949,6 +956,85 @@ describe('superdoc CLI', () => {
expect(verifyEnvelope.data.result.total).toBeGreaterThan(0);
});

test('insert with --block-id and --offset 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);

// Get a real blockId from the document
const target = await firstTextRange(['find', insertSource, '--type', 'text', '--pattern', 'Wilde']);

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

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

const verifyResult = await runCli([
'find',
insertOut,
'--type',
'text',
'--pattern',
'CLI_BLOCKID_OFFSET_INSERT_1597',
]);
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 alone defaults offset 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);

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

const insertResult = await runCli([
'insert',
insertSource,
'--block-id',
target.blockId,
'--text',
'CLI_BLOCKID_ONLY_INSERT_1597',
'--out',
insertOut,
]);

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

const insertEnvelope = parseJsonOutput<
SuccessEnvelope<{
target: TextRange;
}>
>(insertResult);
// blockId alone → offset defaults to 0 → collapsed range at start
expect(insertEnvelope.data.target.range.start).toBe(0);
expect(insertEnvelope.data.target.range.end).toBe(0);
});

test('insert with --offset but no --block-id returns INVALID_ARGUMENT', 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);

const result = await runCli(['insert', insertSource, '--offset', '5', '--text', 'should-fail', '--out', insertOut]);

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

test('create paragraph writes output and adds a new paragraph with seed text', async () => {
const createSource = join(TEST_DIR, 'create-paragraph-source.docx');
const createOut = join(TEST_DIR, 'create-paragraph-out.docx');
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/document-api/reference/_generated-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,5 +124,5 @@
}
],
"marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}",
"sourceHash": "3e7edc551feb80b42bfa59f928a0ce1f34ecb7cf71952d5a489107089105d543"
"sourceHash": "d53e75c131bc35996800d18ff3b67961de5d6d031ba20f4a6a03106e1117acaa"
}
40 changes: 40 additions & 0 deletions apps/docs/document-api/reference/insert.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ description: Generated reference for insert
- `TARGET_NOT_FOUND`
- `TRACK_CHANGE_COMMAND_UNAVAILABLE`
- `CAPABILITY_UNAVAILABLE`
- `INVALID_TARGET`

## Non-applied failure codes

Expand All @@ -34,7 +35,46 @@ description: Generated reference for insert
```json
{
"additionalProperties": false,
"allOf": [
{
"not": {
"required": [
"target",
"blockId"
]
}
},
{
"not": {
"required": [
"target",
"offset"
]
}
},
{
"if": {
"required": [
"offset"
]
},
"then": {
"required": [
"blockId"
]
}
}
],
"properties": {
"blockId": {
"description": "Block ID for block-relative targeting.",
"type": "string"
},
"offset": {
"description": "Character offset within the block identified by blockId.",
"minimum": 0,
"type": "integer"
},
"target": {
"additionalProperties": false,
"properties": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -362,5 +362,5 @@
"supportsTrackedMode": false
}
},
"sourceHash": "3e7edc551feb80b42bfa59f928a0ce1f34ecb7cf71952d5a489107089105d543"
"sourceHash": "d53e75c131bc35996800d18ff3b67961de5d6d031ba20f4a6a03106e1117acaa"
}
4 changes: 2 additions & 2 deletions packages/document-api/generated/agent/remediation-map.json
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@
"lists.setType",
"replace"
],
"preApplyOperations": []
"preApplyOperations": ["insert"]
},
{
"code": "NO_OP",
Expand Down Expand Up @@ -328,5 +328,5 @@
]
}
],
"sourceHash": "3e7edc551feb80b42bfa59f928a0ce1f34ecb7cf71952d5a489107089105d543"
"sourceHash": "d53e75c131bc35996800d18ff3b67961de5d6d031ba20f4a6a03106e1117acaa"
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"contractVersion": "0.1.0",
"sourceHash": "3e7edc551feb80b42bfa59f928a0ce1f34ecb7cf71952d5a489107089105d543",
"sourceHash": "d53e75c131bc35996800d18ff3b67961de5d6d031ba20f4a6a03106e1117acaa",
"workflows": [
{
"id": "find-mutate",
Expand Down
38 changes: 36 additions & 2 deletions packages/document-api/generated/manifests/document-api-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"contractVersion": "0.1.0",
"generatedAt": null,
"sourceCommit": null,
"sourceHash": "3e7edc551feb80b42bfa59f928a0ce1f34ecb7cf71952d5a489107089105d543",
"sourceHash": "d53e75c131bc35996800d18ff3b67961de5d6d031ba20f4a6a03106e1117acaa",
"tools": [
{
"description": "Read Document API data via `find`.",
Expand Down Expand Up @@ -1013,7 +1013,36 @@
"idempotency": "non-idempotent",
"inputSchema": {
"additionalProperties": false,
"allOf": [
{
"not": {
"required": ["target", "blockId"]
}
},
{
"not": {
"required": ["target", "offset"]
}
},
{
"if": {
"required": ["offset"]
},
"then": {
"required": ["blockId"]
}
}
],
"properties": {
"blockId": {
"description": "Block ID for block-relative targeting.",
"type": "string"
},
"offset": {
"description": "Character offset within the block identified by blockId.",
"minimum": 0,
"type": "integer"
},
"target": {
"additionalProperties": false,
"properties": {
Expand Down Expand Up @@ -1356,7 +1385,12 @@
]
},
"possibleFailureCodes": ["INVALID_TARGET", "NO_OP"],
"preApplyThrows": ["TARGET_NOT_FOUND", "TRACK_CHANGE_COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"],
"preApplyThrows": [
"TARGET_NOT_FOUND",
"TRACK_CHANGE_COMMAND_UNAVAILABLE",
"CAPABILITY_UNAVAILABLE",
"INVALID_TARGET"
],
"remediationHints": [],
"successSchema": {
"additionalProperties": false,
Expand Down
38 changes: 36 additions & 2 deletions packages/document-api/generated/schemas/document-api-contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -9278,7 +9278,36 @@
},
"inputSchema": {
"additionalProperties": false,
"allOf": [
{
"not": {
"required": ["target", "blockId"]
}
},
{
"not": {
"required": ["target", "offset"]
}
},
{
"if": {
"required": ["offset"]
},
"then": {
"required": ["blockId"]
}
}
],
"properties": {
"blockId": {
"description": "Block ID for block-relative targeting.",
"type": "string"
},
"offset": {
"description": "Character offset within the block identified by blockId.",
"minimum": 0,
"type": "integer"
},
"target": {
"additionalProperties": false,
"properties": {
Expand Down Expand Up @@ -9322,7 +9351,12 @@
"supportsTrackedMode": true,
"throws": {
"postApplyForbidden": true,
"preApply": ["TARGET_NOT_FOUND", "TRACK_CHANGE_COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"]
"preApply": [
"TARGET_NOT_FOUND",
"TRACK_CHANGE_COMMAND_UNAVAILABLE",
"CAPABILITY_UNAVAILABLE",
"INVALID_TARGET"
]
}
},
"outputSchema": {
Expand Down Expand Up @@ -13172,5 +13206,5 @@
}
},
"sourceCommit": null,
"sourceHash": "3e7edc551feb80b42bfa59f928a0ce1f34ecb7cf71952d5a489107089105d543"
"sourceHash": "d53e75c131bc35996800d18ff3b67961de5d6d031ba20f4a6a03106e1117acaa"
}
26 changes: 23 additions & 3 deletions packages/document-api/src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ lives in adapter layers that map engine behavior into `QueryResult` and other AP
- For text selectors (`{ type: 'text', ... }`), `matches` are containing block addresses.
- Exact matched spans are returned in `context[*].textRanges` as `TextAddress`.
- Mutating operations should target `TextAddress` values from `context[*].textRanges`.
- `insert` also supports omitting `target`; adapters resolve a deterministic default insertion point (first paragraph start when available).
- `insert` supports three targeting modes: canonical `TextAddress`, block-relative (`blockId` + optional `offset`), or default insertion point when all target fields are omitted.
- Structural creation is exposed under `create.*` (for example `create.paragraph`), separate from text mutations.

## Adapter Error Convention
Expand Down Expand Up @@ -94,6 +94,18 @@ if (target) {
}
```

### Workflow: Block-Relative Insert

Insert text at a specific position within a known block, without constructing a full `TextAddress`:

```ts
// Insert at the start of a block
editor.doc.insert({ blockId: 'paragraph-1', text: 'Hello ' });

// Insert at a specific character offset within a block
editor.doc.insert({ blockId: 'paragraph-1', offset: 5, text: 'world' });
```

### Workflow: Tracked-Mode Insert

Insert text as a tracked change so reviewers can accept or reject it:
Expand Down Expand Up @@ -208,9 +220,17 @@ Return document summary metadata (block count, word count, character count).

### `insert`

Insert text at an optional `TextAddress` target. When `target` is omitted, the adapter resolves a deterministic default insertion point. Supports dry-run and tracked mode.
Insert text at a target location. Supports three targeting modes:

1. **Canonical target**: `{ target: TextAddress, text }` — full address with block ID and range.
2. **Block-relative**: `{ blockId, offset?, text }` — friendly shorthand. `offset` defaults to 0 when omitted.
3. **Default insertion point**: `{ text }` — no target; adapter resolves to first paragraph start.

Exactly one targeting mode is allowed per call. Mixing `target` with `blockId`/`offset` throws `INVALID_TARGET`. `offset` without `blockId` throws `INVALID_TARGET`. `offset` must be a non-negative integer.

Supports dry-run and tracked mode.

- **Input**: `InsertInput` (`{ target?, text }`)
- **Input**: `InsertInput` (`{ target?, blockId?, offset?, text }`)
- **Options**: `MutationOptions` (`{ changeMode?, dryRun? }`)
- **Output**: `TextMutationReceipt`
- **Mutates**: Yes
Expand Down
13 changes: 13 additions & 0 deletions packages/document-api/src/contract/contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,19 @@ describe('document-api contract catalog', () => {
}
});

it('encodes insert locator pairing and exclusivity constraints in the input schema', () => {
const schemas = buildInternalContractSchemas();
const insertInputSchema = schemas.operations.insert.input as {
allOf?: Array<Record<string, unknown>>;
};

expect(insertInputSchema.allOf).toEqual([
{ not: { required: ['target', 'blockId'] } },
{ not: { required: ['target', 'offset'] } },
{ if: { required: ['offset'] }, then: { required: ['blockId'] } },
]);
});

it('derives OPERATION_IDS from OPERATION_DEFINITIONS keys', () => {
const definitionKeys = Object.keys(OPERATION_DEFINITIONS).sort();
const operationIds = [...OPERATION_IDS].sort();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export const OPERATION_DEFINITIONS = {
supportsDryRun: true,
supportsTrackedMode: true,
possibleFailureCodes: ['INVALID_TARGET', 'NO_OP'],
throws: T_NOT_FOUND_TRACKED,
throws: [...T_NOT_FOUND_TRACKED, 'INVALID_TARGET'],
}),
referenceDocPath: 'insert.mdx',
referenceGroup: 'core',
Expand Down
Loading
Loading