diff --git a/apps/docs/document-api/available-operations.mdx b/apps/docs/document-api/available-operations.mdx index 81355e5e89..bbc4a1dfd9 100644 --- a/apps/docs/document-api/available-operations.mdx +++ b/apps/docs/document-api/available-operations.mdx @@ -32,7 +32,7 @@ Use the tables below to see what operations are available and where each one is | Hyperlinks | 6 | 0 | 6 | [Reference](/document-api/reference/hyperlinks/index) | | Images | 27 | 0 | 27 | [Reference](/document-api/reference/images/index) | | Index | 11 | 0 | 11 | [Reference](/document-api/reference/index/index) | -| Lists | 36 | 0 | 36 | [Reference](/document-api/reference/lists/index) | +| Lists | 38 | 0 | 38 | [Reference](/document-api/reference/lists/index) | | Mutations | 2 | 0 | 2 | [Reference](/document-api/reference/mutations/index) | | Paragraph Formatting | 19 | 0 | 19 | [Reference](/document-api/reference/format/paragraph/index) | | Paragraph Styles | 2 | 0 | 2 | [Reference](/document-api/reference/styles/paragraph/index) | @@ -291,6 +291,8 @@ Use the tables below to see what operations are available and where each one is | editor.doc.lists.join(...) | [`lists.join`](/document-api/reference/lists/join) | | editor.doc.lists.canJoin(...) | [`lists.canJoin`](/document-api/reference/lists/can-join) | | editor.doc.lists.separate(...) | [`lists.separate`](/document-api/reference/lists/separate) | +| editor.doc.lists.merge(...) | [`lists.merge`](/document-api/reference/lists/merge) | +| editor.doc.lists.split(...) | [`lists.split`](/document-api/reference/lists/split) | | editor.doc.lists.setLevel(...) | [`lists.setLevel`](/document-api/reference/lists/set-level) | | editor.doc.lists.setValue(...) | [`lists.setValue`](/document-api/reference/lists/set-value) | | editor.doc.lists.continuePrevious(...) | [`lists.continuePrevious`](/document-api/reference/lists/continue-previous) | diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 8eb643e96b..253f804db0 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -299,6 +299,7 @@ "apps/docs/document-api/reference/lists/insert.mdx", "apps/docs/document-api/reference/lists/join.mdx", "apps/docs/document-api/reference/lists/list.mdx", + "apps/docs/document-api/reference/lists/merge.mdx", "apps/docs/document-api/reference/lists/outdent.mdx", "apps/docs/document-api/reference/lists/restart-at.mdx", "apps/docs/document-api/reference/lists/separate.mdx", @@ -317,6 +318,7 @@ "apps/docs/document-api/reference/lists/set-level.mdx", "apps/docs/document-api/reference/lists/set-type.mdx", "apps/docs/document-api/reference/lists/set-value.mdx", + "apps/docs/document-api/reference/lists/split.mdx", "apps/docs/document-api/reference/markdown-to-fragment.mdx", "apps/docs/document-api/reference/mutations/apply.mdx", "apps/docs/document-api/reference/mutations/index.mdx", @@ -576,6 +578,8 @@ "lists.join", "lists.canJoin", "lists.separate", + "lists.merge", + "lists.split", "lists.setLevel", "lists.setValue", "lists.continuePrevious", @@ -1027,5 +1031,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "f5c0786256e77432e9b9a58bc2009e39e2e832f6b0b2b625ee6dbeb3a762bdd6" + "sourceHash": "0bb50c2977e652d32a4c3dd591c774e7d164c013b53f2c951de8573911ecdef6" } diff --git a/apps/docs/document-api/reference/capabilities/get.mdx b/apps/docs/document-api/reference/capabilities/get.mdx index 52226257c2..c7e2a42e15 100644 --- a/apps/docs/document-api/reference/capabilities/get.mdx +++ b/apps/docs/document-api/reference/capabilities/get.mdx @@ -1645,6 +1645,11 @@ _No fields._ | `operations.lists.list.dryRun` | boolean | yes | | | `operations.lists.list.reasons` | enum[] | no | | | `operations.lists.list.tracked` | boolean | yes | | +| `operations.lists.merge` | object | yes | | +| `operations.lists.merge.available` | boolean | yes | | +| `operations.lists.merge.dryRun` | boolean | yes | | +| `operations.lists.merge.reasons` | enum[] | no | | +| `operations.lists.merge.tracked` | boolean | yes | | | `operations.lists.outdent` | object | yes | | | `operations.lists.outdent.available` | boolean | yes | | | `operations.lists.outdent.dryRun` | boolean | yes | | @@ -1735,6 +1740,11 @@ _No fields._ | `operations.lists.setValue.dryRun` | boolean | yes | | | `operations.lists.setValue.reasons` | enum[] | no | | | `operations.lists.setValue.tracked` | boolean | yes | | +| `operations.lists.split` | object | yes | | +| `operations.lists.split.available` | boolean | yes | | +| `operations.lists.split.dryRun` | boolean | yes | | +| `operations.lists.split.reasons` | enum[] | no | | +| `operations.lists.split.tracked` | boolean | yes | | | `operations.markdownToFragment` | object | yes | | | `operations.markdownToFragment.available` | boolean | yes | | | `operations.markdownToFragment.dryRun` | boolean | yes | | @@ -3871,6 +3881,11 @@ _No fields._ "dryRun": false, "tracked": false }, + "lists.merge": { + "available": true, + "dryRun": true, + "tracked": false + }, "lists.outdent": { "available": true, "dryRun": true, @@ -3961,6 +3976,11 @@ _No fields._ "dryRun": true, "tracked": false }, + "lists.split": { + "available": true, + "dryRun": true, + "tracked": false + }, "markdownToFragment": { "available": true, "dryRun": false, @@ -15729,6 +15749,41 @@ _No fields._ ], "type": "object" }, + "lists.merge": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, "lists.outdent": { "additionalProperties": false, "properties": { @@ -16359,6 +16414,41 @@ _No fields._ ], "type": "object" }, + "lists.split": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, "markdownToFragment": { "additionalProperties": false, "properties": { @@ -19766,6 +19856,8 @@ _No fields._ "lists.join", "lists.canJoin", "lists.separate", + "lists.merge", + "lists.split", "lists.setLevel", "lists.setValue", "lists.continuePrevious", diff --git a/apps/docs/document-api/reference/history/get.mdx b/apps/docs/document-api/reference/history/get.mdx index 38eeb39ffa..b2193cf963 100644 --- a/apps/docs/document-api/reference/history/get.mdx +++ b/apps/docs/document-api/reference/history/get.mdx @@ -1,14 +1,14 @@ --- title: history.get sidebarTitle: history.get -description: Query the current undo/redo history state of the active editor. +description: Query the current undo/redo history state of the document. --- {/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} ## Summary -Query the current undo/redo history state of the active editor. +Query the current undo/redo history state of the document. - Operation ID: `history.get` - API member path: `editor.doc.history.get(...)` diff --git a/apps/docs/document-api/reference/history/redo.mdx b/apps/docs/document-api/reference/history/redo.mdx index 00c361d109..5427a1cc13 100644 --- a/apps/docs/document-api/reference/history/redo.mdx +++ b/apps/docs/document-api/reference/history/redo.mdx @@ -1,14 +1,14 @@ --- title: history.redo sidebarTitle: history.redo -description: Redo the most recently undone action in the active editor. +description: Redo the most recently undone action in the document. --- {/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} ## Summary -Redo the most recently undone action in the active editor. +Redo the most recently undone action in the document. - Operation ID: `history.redo` - API member path: `editor.doc.history.redo(...)` diff --git a/apps/docs/document-api/reference/history/undo.mdx b/apps/docs/document-api/reference/history/undo.mdx index 596d151d89..31a560e167 100644 --- a/apps/docs/document-api/reference/history/undo.mdx +++ b/apps/docs/document-api/reference/history/undo.mdx @@ -1,14 +1,14 @@ --- title: history.undo sidebarTitle: history.undo -description: Undo the most recent history-safe mutation in the active editor. +description: Undo the most recent history-safe mutation in the document. --- {/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} ## Summary -Undo the most recent history-safe mutation in the active editor. +Undo the most recent history-safe mutation in the document. - Operation ID: `history.undo` - API member path: `editor.doc.history.undo(...)` diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index c343099b5a..edf97f08d0 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -26,7 +26,7 @@ This reference is sourced from `packages/document-api/src/contract/*`. | Sections | 18 | 0 | 18 | [Open](/document-api/reference/sections/index) | | Format | 44 | 1 | 45 | [Open](/document-api/reference/format/index) | | Styles | 1 | 0 | 1 | [Open](/document-api/reference/styles/index) | -| Lists | 36 | 0 | 36 | [Open](/document-api/reference/lists/index) | +| Lists | 38 | 0 | 38 | [Open](/document-api/reference/lists/index) | | Comments | 5 | 0 | 5 | [Open](/document-api/reference/comments/index) | | Track Changes | 3 | 0 | 3 | [Open](/document-api/reference/track-changes/index) | | Query | 1 | 0 | 1 | [Open](/document-api/reference/query/index) | @@ -196,6 +196,8 @@ The tables below are grouped by namespace. | lists.join | editor.doc.lists.join(...) | Merge two adjacent list sequences into one. | | lists.canJoin | editor.doc.lists.canJoin(...) | Check whether two adjacent list sequences can be joined. | | lists.separate | editor.doc.lists.separate(...) | Split a list sequence at the target item, creating a new sequence from that point forward. | +| lists.merge | editor.doc.lists.merge(...) | Compound: merge two adjacent list sequences into one. Reassigns numId on the absorbed sequence (no strict abstractNumId check — absorbed items adopt the absorbing definition) and deletes empty paragraphs between the two sequences. Use this instead of lists.join for the user-facing "merge these lists" intent. | +| lists.split | editor.doc.lists.split(...) | Compound: split a list sequence at the target item into two independent sequences. Runs lists.separate then (by default) lists.setValue(1) so the new half starts numbering fresh at 1. Pass restartNumbering:false for raw separate semantics (new half continues the previous count). | | lists.setLevel | editor.doc.lists.setLevel(...) | Set the absolute nesting level (0..8) of a list item. | | lists.setValue | editor.doc.lists.setValue(...) | Set an explicit numbering value at the target item. Mid-sequence targets are atomically separated first. | | lists.continuePrevious | editor.doc.lists.continuePrevious(...) | Continue numbering from the nearest compatible previous list sequence. | @@ -338,9 +340,9 @@ The tables below are grouped by namespace. | Operation | API member path | Description | | --- | --- | --- | -| history.get | editor.doc.history.get(...) | Query the current undo/redo history state of the active editor. | -| history.undo | editor.doc.history.undo(...) | Undo the most recent history-safe mutation in the active editor. | -| history.redo | editor.doc.history.redo(...) | Redo the most recently undone action in the active editor. | +| history.get | editor.doc.history.get(...) | Query the current undo/redo history state of the document. | +| history.undo | editor.doc.history.undo(...) | Undo the most recent history-safe mutation in the document. | +| history.redo | editor.doc.history.redo(...) | Redo the most recently undone action in the document. | #### Table of Contents diff --git a/apps/docs/document-api/reference/lists/index.mdx b/apps/docs/document-api/reference/lists/index.mdx index 8bb2e687d1..b5c2b65561 100644 --- a/apps/docs/document-api/reference/lists/index.mdx +++ b/apps/docs/document-api/reference/lists/index.mdx @@ -23,6 +23,8 @@ List inspection and list mutations. | lists.join | `lists.join` | Yes | `conditional` | No | Yes | | lists.canJoin | `lists.canJoin` | No | `idempotent` | No | No | | lists.separate | `lists.separate` | Yes | `conditional` | No | Yes | +| lists.merge | `lists.merge` | Yes | `conditional` | No | Yes | +| lists.split | `lists.split` | Yes | `conditional` | No | Yes | | lists.setLevel | `lists.setLevel` | Yes | `conditional` | No | Yes | | lists.setValue | `lists.setValue` | Yes | `conditional` | No | Yes | | lists.continuePrevious | `lists.continuePrevious` | Yes | `conditional` | No | Yes | diff --git a/apps/docs/document-api/reference/lists/merge.mdx b/apps/docs/document-api/reference/lists/merge.mdx new file mode 100644 index 0000000000..99d778684a --- /dev/null +++ b/apps/docs/document-api/reference/lists/merge.mdx @@ -0,0 +1,251 @@ +--- +title: lists.merge +sidebarTitle: lists.merge +description: "Compound: merge two adjacent list sequences into one. Reassigns numId on the absorbed sequence (no strict abstractNumId check — absorbed items adopt the absorbing definition) and deletes empty paragraphs between the two sequences. Use this instead of lists.join for the user-facing \"merge these lists\" intent." +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +## Summary + +Compound: merge two adjacent list sequences into one. Reassigns numId on the absorbed sequence (no strict abstractNumId check — absorbed items adopt the absorbing definition) and deletes empty paragraphs between the two sequences. Use this instead of lists.join for the user-facing "merge these lists" intent. + +- Operation ID: `lists.merge` +- API member path: `editor.doc.lists.merge(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a ListsMergeResult with the merged listId, absorbedCount, and removedEmptyBlocks count. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `direction` | enum | yes | `"withPrevious"`, `"withNext"` | +| `target` | ListItemAddress | yes | ListItemAddress | +| `target.kind` | `"block"` | yes | Constant: `"block"` | +| `target.nodeId` | string | yes | | +| `target.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | + +### Example request + +```json +{ + "direction": "withPrevious", + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + } +} +``` + +## Output fields + +### Variant 1 (success=true) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `absorbedCount` | integer | yes | | +| `listId` | string | yes | | +| `removedEmptyBlocks` | integer | yes | | +| `success` | `true` | yes | Constant: `true` | + +### Variant 2 (success=false) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | yes | | +| `failure.code` | enum | yes | `"INVALID_TARGET"`, `"NO_ADJACENT_SEQUENCE"`, `"NO_OP"` | +| `failure.details` | any | no | | +| `failure.message` | string | yes | | +| `success` | `false` | yes | Constant: `false` | + +### Example response + +```json +{ + "absorbedCount": 1, + "listId": "example", + "removedEmptyBlocks": 1, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` + +## Non-applied failure codes + +- `INVALID_TARGET` +- `NO_ADJACENT_SEQUENCE` +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "direction": { + "enum": [ + "withPrevious", + "withNext" + ] + }, + "target": { + "$ref": "#/$defs/ListItemAddress" + } + }, + "required": [ + "target", + "direction" + ], + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "absorbedCount": { + "type": "integer" + }, + "listId": { + "type": "string" + }, + "removedEmptyBlocks": { + "type": "integer" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "listId", + "absorbedCount", + "removedEmptyBlocks" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "NO_ADJACENT_SEQUENCE", + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "absorbedCount": { + "type": "integer" + }, + "listId": { + "type": "string" + }, + "removedEmptyBlocks": { + "type": "integer" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "listId", + "absorbedCount", + "removedEmptyBlocks" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "NO_ADJACENT_SEQUENCE", + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/lists/split.mdx b/apps/docs/document-api/reference/lists/split.mdx new file mode 100644 index 0000000000..9b3b534228 --- /dev/null +++ b/apps/docs/document-api/reference/lists/split.mdx @@ -0,0 +1,250 @@ +--- +title: lists.split +sidebarTitle: lists.split +description: "Compound: split a list sequence at the target item into two independent sequences. Runs lists.separate then (by default) lists.setValue(1) so the new half starts numbering fresh at 1. Pass restartNumbering:false for raw separate semantics (new half continues the previous count)." +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +## Summary + +Compound: split a list sequence at the target item into two independent sequences. Runs lists.separate then (by default) lists.setValue(1) so the new half starts numbering fresh at 1. Pass restartNumbering:false for raw separate semantics (new half continues the previous count). + +- Operation ID: `lists.split` +- API member path: `editor.doc.lists.split(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a ListsSplitResult with the new listId, numId, and the restart value applied (or null). + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `restartNumbering` | boolean | no | | +| `target` | ListItemAddress | yes | ListItemAddress | +| `target.kind` | `"block"` | yes | Constant: `"block"` | +| `target.nodeId` | string | yes | | +| `target.nodeType` | `"listItem"` | yes | Constant: `"listItem"` | + +### Example request + +```json +{ + "restartNumbering": true, + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "listItem" + } +} +``` + +## Output fields + +### Variant 1 (success=true) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `listId` | string | yes | | +| `numId` | integer | yes | | +| `restartedAt` | any | yes | | +| `success` | `true` | yes | Constant: `true` | + +### Variant 2 (success=false) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | yes | | +| `failure.code` | enum | yes | `"INVALID_TARGET"`, `"NO_OP"` | +| `failure.details` | any | no | | +| `failure.message` | string | yes | | +| `success` | `false` | yes | Constant: `false` | + +### Example response + +```json +{ + "listId": "example", + "numId": 1, + "restartedAt": {}, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` + +## Non-applied failure codes + +- `INVALID_TARGET` +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "restartNumbering": { + "type": "boolean" + }, + "target": { + "$ref": "#/$defs/ListItemAddress" + } + }, + "required": [ + "target" + ], + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "listId": { + "type": "string" + }, + "numId": { + "type": "integer" + }, + "restartedAt": { + "type": [ + "integer", + "null" + ] + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "listId", + "numId", + "restartedAt" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "listId": { + "type": "string" + }, + "numId": { + "type": "integer" + }, + "restartedAt": { + "type": [ + "integer", + "null" + ] + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "listId", + "numId", + "restartedAt" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "INVALID_TARGET", + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index 722c94517f..37eddaa6fd 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -853,6 +853,8 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.lists.join` | `lists join` | Merge two adjacent list sequences into one. | | `doc.lists.canJoin` | `lists can-join` | Check whether two adjacent list sequences can be joined. | | `doc.lists.separate` | `lists separate` | Split a list sequence at the target item, creating a new sequence from that point forward. | +| `doc.lists.merge` | `lists merge` | Compound: merge two adjacent list sequences into one. Reassigns numId on the absorbed sequence (no strict abstractNumId check — absorbed items adopt the absorbing definition) and deletes empty paragraphs between the two sequences. Use this instead of lists.join for the user-facing "merge these lists" intent. | +| `doc.lists.split` | `lists split` | Compound: split a list sequence at the target item into two independent sequences. Runs lists.separate then (by default) lists.setValue(1) so the new half starts numbering fresh at 1. Pass restartNumbering:false for raw separate semantics (new half continues the previous count). | | `doc.lists.setLevel` | `lists set-level` | Set the absolute nesting level (0..8) of a list item. | | `doc.lists.setValue` | `lists set-value` | Set an explicit numbering value at the target item. Mid-sequence targets are atomically separated first. | | `doc.lists.continuePrevious` | `lists continue-previous` | Continue numbering from the nearest compatible previous list sequence. | @@ -998,9 +1000,9 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | Operation | CLI command | Description | | --- | --- | --- | -| `doc.history.get` | `history get` | Query the current undo/redo history state of the active editor. | -| `doc.history.undo` | `history undo` | Undo the most recent history-safe mutation in the active editor. | -| `doc.history.redo` | `history redo` | Redo the most recently undone action in the active editor. | +| `doc.history.get` | `history get` | Query the current undo/redo history state of the document. | +| `doc.history.undo` | `history undo` | Undo the most recent history-safe mutation in the document. | +| `doc.history.redo` | `history redo` | Redo the most recently undone action in the document. | #### Lifecycle @@ -1315,6 +1317,8 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.lists.join` | `lists join` | Merge two adjacent list sequences into one. | | `doc.lists.can_join` | `lists can-join` | Check whether two adjacent list sequences can be joined. | | `doc.lists.separate` | `lists separate` | Split a list sequence at the target item, creating a new sequence from that point forward. | +| `doc.lists.merge` | `lists merge` | Compound: merge two adjacent list sequences into one. Reassigns numId on the absorbed sequence (no strict abstractNumId check — absorbed items adopt the absorbing definition) and deletes empty paragraphs between the two sequences. Use this instead of lists.join for the user-facing "merge these lists" intent. | +| `doc.lists.split` | `lists split` | Compound: split a list sequence at the target item into two independent sequences. Runs lists.separate then (by default) lists.setValue(1) so the new half starts numbering fresh at 1. Pass restartNumbering:false for raw separate semantics (new half continues the previous count). | | `doc.lists.set_level` | `lists set-level` | Set the absolute nesting level (0..8) of a list item. | | `doc.lists.set_value` | `lists set-value` | Set an explicit numbering value at the target item. Mid-sequence targets are atomically separated first. | | `doc.lists.continue_previous` | `lists continue-previous` | Continue numbering from the nearest compatible previous list sequence. | @@ -1460,9 +1464,9 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | Operation | CLI command | Description | | --- | --- | --- | -| `doc.history.get` | `history get` | Query the current undo/redo history state of the active editor. | -| `doc.history.undo` | `history undo` | Undo the most recent history-safe mutation in the active editor. | -| `doc.history.redo` | `history redo` | Redo the most recently undone action in the active editor. | +| `doc.history.get` | `history get` | Query the current undo/redo history state of the document. | +| `doc.history.undo` | `history undo` | Undo the most recent history-safe mutation in the document. | +| `doc.history.redo` | `history redo` | Redo the most recently undone action in the document. | #### Lifecycle diff --git a/evals/fixtures/docs/basic-list.docx b/evals/fixtures/docs/basic-list.docx new file mode 100644 index 0000000000..eef23f96a3 Binary files /dev/null and b/evals/fixtures/docs/basic-list.docx differ diff --git a/evals/package.json b/evals/package.json index 682c38e8a5..a30e261a6a 100644 --- a/evals/package.json +++ b/evals/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "test": "node --test shared/*.test.mjs", - "clean": "rm -rf fixtures/docs/.state* fixtures/docs/tmp-* artifacts/ .promptfoo", + "clean": "rm -rf fixtures/docs/.state* fixtures/docs/tmp-* artifacts/ results/output results/.cache", "view": "npx promptfoo view", "preeval": "node scripts/prepare-local-sdk.mjs --light", "preeval:openai": "node scripts/prepare-local-sdk.mjs --light", @@ -18,10 +18,13 @@ "eval:analyze": "npx promptfoo eval --env-file .env -c config/tool-quality.promptfoo.yaml -o artifacts/latest/tool-quality.json && node shared/analyze-results.mjs", "baseline:save": "node shared/save-baseline.mjs", "baseline:compare": "node shared/compare-baselines.mjs", - "preeval:benchmark": "cd ../apps/cli && pnpm run build && cd ../apps/mcp && pnpm run build", + "prebuild:benchmark-deps": "pnpm --filter @superdoc-dev/cli run build && pnpm --filter @superdoc-dev/mcp run build", + "preeval:benchmark": "pnpm run prebuild:benchmark-deps", "eval:benchmark": "npx promptfoo eval --env-file .env -c config/benchmark.promptfoo.yaml -o artifacts/benchmark-runs/latest.json", "eval:benchmark:report": "node suites/benchmark/reports/benchmark-report.mjs", + "preeval:benchmark:claude": "pnpm run prebuild:benchmark-deps", "eval:benchmark:claude": "npx promptfoo eval --env-file .env -c config/benchmark.promptfoo.yaml --filter-providers 'CC-*' -o artifacts/benchmark-runs/latest-claude.json", + "preeval:benchmark:codex": "pnpm run prebuild:benchmark-deps", "eval:benchmark:codex": "npx promptfoo eval --env-file .env -c config/benchmark.promptfoo.yaml --filter-providers 'Codex-*' -o artifacts/benchmark-runs/latest-codex.json" }, "devDependencies": { diff --git a/evals/shared/checks.cjs b/evals/shared/checks.cjs index 1753baf7ea..e94b4637a7 100644 --- a/evals/shared/checks.cjs +++ b/evals/shared/checks.cjs @@ -274,6 +274,31 @@ module.exports.usesCreateAction = (output, context) => { return { pass: true, score: 1, reason: `superdoc_create with action "${expectedAction}"` }; }; +module.exports.usesListAction = (output, context) => { + const expectedAction = context?.vars?.expectedListAction; + // Fail loudly on malformed input — Promptfoo's matrix-expansion of array vars + // can turn `[a, b]` into a string, which would silently bypass this check. + if (expectedAction === undefined || expectedAction === null) return true; + if (typeof expectedAction !== 'string' || expectedAction.length === 0) { + return { + pass: false, + score: 0, + reason: `expectedListAction var must be a non-empty string; got ${typeof expectedAction} (${JSON.stringify(expectedAction)})`, + }; + } + const calls = findTools(output, LIST); + if (calls.length === 0) return { pass: false, score: 0, reason: 'superdoc_list not called' }; + const actions = calls.map((c) => getArgs(c).action).filter(Boolean); + if (!actions.includes(expectedAction)) { + return { + pass: false, + score: 0, + reason: `superdoc_list called with actions [${actions.join(', ')}], expected "${expectedAction}"`, + }; + } + return { pass: true, score: 1, reason: `superdoc_list with action "${expectedAction}"` }; +}; + module.exports.usesCommentCreate = (output) => { const call = findTool(output, COMMENT); if (!call) return { pass: false, score: 0, reason: 'superdoc_comment not called' }; @@ -748,3 +773,290 @@ console.log(JSON.stringify(diff)); return { pass: true, score: diff.ratio, reason: diff.reason }; }; + +// --------------------------------------------------------------------------- +// OOXML numbering-consistency check (symbol-font-on-ordered-level regression guard) +// +// After a list mutation that converts bullet → ordered (e.g. lists.set_type), +// Word-level fidelity requires that no level ends up with an ordered numFmt +// paired with a symbol font (Wingdings, Symbol, Webdings, Zapf Dingbats). +// Symbol fonts have no numeric/alphabetic glyphs at ASCII codepoints — Word +// then renders "1.", "2.", etc. through the symbol font and shows unrelated +// pictographic glyphs (envelopes, scissors, folders, etc.) instead of digits. +// SuperDoc's internal projection hides the bug because it normalizes markers +// to logical strings. Only visible when real OOXML is rendered. +// --------------------------------------------------------------------------- + +const ORDERED_NUM_FMTS = new Set([ + 'decimal', + 'decimalZero', + 'decimalEnclosedCircle', + 'decimalEnclosedFullstop', + 'decimalEnclosedParen', + 'lowerLetter', + 'upperLetter', + 'lowerRoman', + 'upperRoman', + 'ordinal', + 'ordinalText', + 'cardinalText', + 'chicago', +]); + +const SYMBOL_MARKER_FONTS = new Set([ + 'Wingdings', + 'Wingdings 2', + 'Wingdings 3', + 'Symbol', + 'Webdings', + 'ZapfDingbats', + 'Zapf Dingbats', +]); + +/** Read just `word/numbering.xml` out of a `.docx` via `unzip -p`. */ +function readNumberingXml(docxPath) { + try { + return execSync(`unzip -p ${JSON.stringify(docxPath)} word/numbering.xml`, { + encoding: 'utf8', + timeout: 10000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (_) { + return null; + } +} + +/** Regex-scan numbering.xml for nodes that pair an ordered numFmt with a symbol font. */ +function scanNumberingXmlForSymbolFontsOnOrderedLevels(xml) { + if (!xml) return []; + const violations = []; + const absRegex = /]*w:abstractNumId="(\d+)"[^>]*>([\s\S]*?)<\/w:abstractNum>/g; + let absMatch; + while ((absMatch = absRegex.exec(xml)) !== null) { + const abstractId = Number(absMatch[1]); + const body = absMatch[2]; + const lvlRegex = /]*w:ilvl="(\d+)"[^>]*>([\s\S]*?)<\/w:lvl>/g; + let lvlMatch; + while ((lvlMatch = lvlRegex.exec(body)) !== null) { + const ilvl = Number(lvlMatch[1]); + const lvlBody = lvlMatch[2]; + const numFmtMatch = lvlBody.match(/ { + let parsed; + try { + parsed = JSON.parse(output); + } catch { + return { pass: false, score: 0, reason: 'output is not JSON' }; + } + const outputFile = parsed?.outputFile; + if (!outputFile || typeof outputFile !== 'string') { + return true; // No keepFile → nothing to inspect; skip rather than fail. + } + const xml = readNumberingXml(outputFile); + if (!xml) { + return { + pass: false, + score: 0, + reason: `Could not read word/numbering.xml from ${outputFile} (unzip failed or file absent)`, + }; + } + const violations = scanNumberingXmlForSymbolFontsOnOrderedLevels(xml); + if (violations.length === 0) { + return { pass: true, score: 1, reason: 'No ordered-format levels with symbol-font rFonts' }; + } + return { + pass: false, + score: 0, + reason: + `Found ${violations.length} ordered-format level(s) with symbol-font rFonts. ` + + `Word will render these as pictograph glyphs instead of digits. ` + + `Violations: ${JSON.stringify(violations)}`, + }; +}; + +// --------------------------------------------------------------------------- +// List structural checks for merge / split / restart evals. +// +// The text-and-action-name asserts in execution.yaml prove the agent picked +// the right tool, but they do not prove the list itself changed. These read +// `word/document.xml` from the saved `.docx` and inspect each paragraph's +// `` / `` so a no-op or wrong-direction edit fails loudly. +// --------------------------------------------------------------------------- + +function readDocumentXml(docxPath) { + try { + return execSync(`unzip -p ${JSON.stringify(docxPath)} word/document.xml`, { + encoding: 'utf8', + timeout: 10000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (_) { + return null; + } +} + +function extractListItems(documentXml) { + if (!documentXml) return []; + const items = []; + const pRegex = /]*>([\s\S]*?)<\/w:p>/g; + let m; + while ((m = pRegex.exec(documentXml)) !== null) { + const body = m[1]; + const numIdMatch = body.match(/]*>([\s\S]*?)<\/w:t>/g)].map((x) => x[1]); + items.push({ + text: textParts.join(''), + numId: Number(numIdMatch[1]), + ilvl: ilvlMatch ? Number(ilvlMatch[1]) : 0, + }); + } + return items; +} + +function loadListItems(output) { + let parsed; + try { parsed = JSON.parse(output); } catch { return { skip: true, reason: 'output is not JSON' }; } + const outputFile = parsed?.outputFile; + if (!outputFile || typeof outputFile !== 'string') return { skip: true, reason: 'no outputFile (keepFile not set?)' }; + const xml = readDocumentXml(outputFile); + if (!xml) return { fail: true, reason: `Could not read word/document.xml from ${outputFile}` }; + return { items: extractListItems(xml), outputFile }; +} + +function findItem(items, snippet) { + return items.find((it) => it.text.includes(snippet)); +} + +function assertSingleNumIdAcross(output, itemTexts) { + const loaded = loadListItems(output); + if (loaded.skip) return true; + if (loaded.fail) return { pass: false, score: 0, reason: loaded.reason }; + const numIds = itemTexts.map((t) => { + const found = findItem(loaded.items, t); + return found ? found.numId : null; + }); + const missing = itemTexts.filter((_, i) => numIds[i] == null); + if (missing.length) return { pass: false, score: 0, reason: `List items not found: ${missing.join(', ')}` }; + const distinct = new Set(numIds); + if (distinct.size > 1) { + return { + pass: false, + score: 0, + reason: `Expected one numId across all items, got ${distinct.size}: ${[...distinct].join(', ')}`, + }; + } + return { pass: true, score: 1, reason: `All items share numId ${numIds[0]}` }; +} + +function assertDistinctNumIds(output, beforeText, afterText) { + const loaded = loadListItems(output); + if (loaded.skip) return true; + if (loaded.fail) return { pass: false, score: 0, reason: loaded.reason }; + const before = findItem(loaded.items, beforeText); + const after = findItem(loaded.items, afterText); + if (!before || !after) { + return { + pass: false, + score: 0, + reason: `Could not find both items as list items: before=${!!before}, after=${!!after}`, + }; + } + if (before.numId === after.numId) { + return { + pass: false, + score: 0, + reason: `Expected split: "${beforeText}" and "${afterText}" both still on numId ${before.numId}`, + }; + } + return { + pass: true, + score: 1, + reason: `Split: "${beforeText}" on ${before.numId}, "${afterText}" on ${after.numId}`, + }; +} + +function assertRestartedNumbering(output, priorText, targetText) { + const loaded = loadListItems(output); + if (loaded.skip) return true; + if (loaded.fail) return { pass: false, score: 0, reason: loaded.reason }; + const prior = findItem(loaded.items, priorText); + const target = findItem(loaded.items, targetText); + if (!prior || !target) { + return { + pass: false, + score: 0, + reason: `Could not find items: prior=${!!prior}, target=${!!target}`, + }; + } + // Restart can show up two ways: + // (a) target moved to a new numId (the new numId starts at 1) + // (b) target stays on the same numId but numbering.xml gains a startOverride + if (prior.numId !== target.numId) { + return { + pass: true, + score: 1, + reason: `Restart via new numId: prior=${prior.numId}, target=${target.numId}`, + }; + } + const numXml = readNumberingXml(loaded.outputFile); + if (numXml && / + assertSingleNumIdAcross(output, [ + 'All sorts of bullets.', + 'Nested lists', + 'Numbers', + 'Or letters', + 'All sorts of lists are supported', + ]); + +module.exports.checkBulletListSplitAtWith = (output) => + assertDistinctNumIds(output, 'All sorts of bullets.', 'With'); + +module.exports.checkRestartAtAllSorts = (output) => + assertRestartedNumbering(output, 'Numbers', 'All sorts of lists are supported'); diff --git a/evals/suites/benchmark/tests/agent-benchmark-v2.yaml b/evals/suites/benchmark/tests/agent-benchmark-v2.yaml index 5f31cf4482..d8eec7b85b 100644 --- a/evals/suites/benchmark/tests/agent-benchmark-v2.yaml +++ b/evals/suites/benchmark/tests/agent-benchmark-v2.yaml @@ -288,3 +288,261 @@ - type: javascript metric: path value: file://../shared/checks.cjs:benchmarkPath + +# ═══════════════════════════════════════════════════════════════ +# CATEGORY D: List workflows — compound ops (merge / split / restart / subpoint / nested) +# +# Fixtures: +# document.docx — two adjacent lists: bullet (4 items, nested) + ordered (3 items, lettered) +# Source of truth for merge / split / restart tasks. +# basic-list.docx — simple nested bullet list (4 items L0/L1) + a 4-item ordered list. +# Used for sub-point and nested-construction tasks. +# +# All tasks set keepFile: true so the saved .docx feeds the fidelity + numbering- +# consistency guards below. Assertions follow the Level 3 pattern (correctness + +# collateral + fidelity + observational-zero-weight metrics). +# ═══════════════════════════════════════════════════════════════ + +- description: 'List: merge two adjacent lists into one' + vars: + fixture: document.docx + keepFile: true + task: 'The document has two adjacent lists — a bullet list that starts with "All sorts of bullets." and a numbered list that starts with "Numbers". Merge the numbered list into the bullet list so they become one continuous list. All item texts must be preserved.' + assert: + - type: javascript + metric: correctness + value: | + const d = JSON.parse(output); + const t = d.documentText || ''; + const required = ['All sorts of bullets.', 'Nested lists', 'With', 'Lots of plenty of different icons', 'Numbers', 'Or letters', 'All sorts of lists are supported']; + const missing = required.filter((s) => !t.includes(s)); + if (missing.length) return { pass: false, score: 0, reason: `Missing after merge: ${missing.join(', ')}` }; + return { pass: true, score: 1, reason: 'All 7 item texts preserved' }; + - type: javascript + metric: collateral + value: | + const d = JSON.parse(output); + if (d.documentChanged !== true) return { pass: false, score: 0, reason: 'documentChanged should be true' }; + const t = d.documentText || ''; + if (!t.includes('A list of list features')) return { pass: false, score: 0, reason: 'Document preamble removed' }; + return { pass: true, score: 1, reason: 'Preamble intact' }; + - type: javascript + metric: numbering_font_consistency + value: file://../shared/checks.cjs:checkNoSymbolFontsOnOrderedLevels + - type: javascript + metric: fidelity + value: file://../shared/checks.cjs:benchmarkFidelity + - type: javascript + metric: diff + weight: 0 + value: file://../shared/checks.cjs:benchmarkDiff + - type: javascript + metric: steps + weight: 0 + value: file://../shared/checks.cjs:benchmarkSteps + - type: javascript + metric: latency + weight: 0 + value: file://../shared/checks.cjs:benchmarkLatency + - type: javascript + metric: tokens + weight: 0 + value: file://../shared/checks.cjs:benchmarkTokens + - type: javascript + metric: path + value: file://../shared/checks.cjs:benchmarkPath + +- description: 'List: split a list at a specific item' + vars: + fixture: document.docx + keepFile: true + task: 'In the bullet list, split the list at the item "With" so that "With" and the item after it become a new separate list. All original item texts must remain in the document.' + assert: + - type: javascript + metric: correctness + value: | + const d = JSON.parse(output); + const t = d.documentText || ''; + const required = ['All sorts of bullets.', 'Nested lists', 'With', 'Lots of plenty of different icons']; + const missing = required.filter((s) => !t.includes(s)); + if (missing.length) return { pass: false, score: 0, reason: `Content damaged: missing ${missing.join(', ')}` }; + return { pass: true, score: 1, reason: 'All items preserved' }; + - type: javascript + metric: collateral + value: | + const d = JSON.parse(output); + if (d.documentChanged !== true) return { pass: false, score: 0, reason: 'documentChanged should be true' }; + const t = d.documentText || ''; + if (!t.includes('Numbers') || !t.includes('Or letters')) return { pass: false, score: 0, reason: 'Second (numbered) list damaged' }; + return { pass: true, score: 1, reason: 'Numbered list untouched' }; + - type: javascript + metric: numbering_font_consistency + value: file://../shared/checks.cjs:checkNoSymbolFontsOnOrderedLevels + - type: javascript + metric: fidelity + value: file://../shared/checks.cjs:benchmarkFidelity + - type: javascript + metric: diff + weight: 0 + value: file://../shared/checks.cjs:benchmarkDiff + - type: javascript + metric: steps + weight: 0 + value: file://../shared/checks.cjs:benchmarkSteps + - type: javascript + metric: latency + weight: 0 + value: file://../shared/checks.cjs:benchmarkLatency + - type: javascript + metric: tokens + weight: 0 + value: file://../shared/checks.cjs:benchmarkTokens + - type: javascript + metric: path + value: file://../shared/checks.cjs:benchmarkPath + +- description: 'List: restart numbering at a specific item' + vars: + fixture: document.docx + keepFile: true + task: 'In the numbered list, restart the numbering at the item "All sorts of lists are supported" so it counts from 1 again from that point forward. Keep all item texts unchanged.' + assert: + - type: javascript + metric: correctness + value: | + const d = JSON.parse(output); + const t = d.documentText || ''; + const required = ['Numbers', 'Or letters', 'All sorts of lists are supported']; + const missing = required.filter((s) => !t.includes(s)); + if (missing.length) return { pass: false, score: 0, reason: `Content damaged: missing ${missing.join(', ')}` }; + return { pass: true, score: 1, reason: 'All items preserved' }; + - type: javascript + metric: collateral + value: | + const d = JSON.parse(output); + if (d.documentChanged !== true) return { pass: false, score: 0, reason: 'documentChanged should be true' }; + const t = d.documentText || ''; + if (!t.includes('All sorts of bullets.') || !t.includes('Nested lists')) { + return { pass: false, score: 0, reason: 'Bullet list damaged' }; + } + return { pass: true, score: 1, reason: 'Bullet list untouched' }; + - type: javascript + metric: numbering_font_consistency + value: file://../shared/checks.cjs:checkNoSymbolFontsOnOrderedLevels + - type: javascript + metric: fidelity + value: file://../shared/checks.cjs:benchmarkFidelity + - type: javascript + metric: diff + weight: 0 + value: file://../shared/checks.cjs:benchmarkDiff + - type: javascript + metric: steps + weight: 0 + value: file://../shared/checks.cjs:benchmarkSteps + - type: javascript + metric: latency + weight: 0 + value: file://../shared/checks.cjs:benchmarkLatency + - type: javascript + metric: tokens + weight: 0 + value: file://../shared/checks.cjs:benchmarkTokens + - type: javascript + metric: path + value: file://../shared/checks.cjs:benchmarkPath + +- description: 'List: add a sub-point under an existing item' + vars: + fixture: basic-list.docx + keepFile: true + task: 'Under the bullet list item "List item 1", add a new sub-point with the text "Added sub-point" that is nested one level deeper than "List item 1". Do not change any other content.' + assert: + - type: javascript + metric: correctness + value: | + const d = JSON.parse(output); + const t = d.documentText || ''; + if (!t.includes('Added sub-point')) return { pass: false, score: 0, reason: 'Sub-point text not in document' }; + if (!t.includes('List item 1')) return { pass: false, score: 0, reason: 'Parent item damaged' }; + return { pass: true, score: 1, reason: 'Sub-point added; parent intact' }; + - type: javascript + metric: collateral + value: | + const d = JSON.parse(output); + if (d.documentChanged !== true) return { pass: false, score: 0, reason: 'documentChanged should be true' }; + const t = d.documentText || ''; + const originals = ['List item 1', 'List item 2', 'Indentation 1', 'Back']; + const missing = originals.filter((s) => !t.includes(s)); + if (missing.length) return { pass: false, score: 0, reason: `Original items missing: ${missing.join(', ')}` }; + return { pass: true, score: 1, reason: 'All original items preserved' }; + - type: javascript + metric: fidelity + value: file://../shared/checks.cjs:benchmarkFidelity + - type: javascript + metric: diff + weight: 0 + value: file://../shared/checks.cjs:benchmarkDiff + - type: javascript + metric: steps + weight: 0 + value: file://../shared/checks.cjs:benchmarkSteps + - type: javascript + metric: latency + weight: 0 + value: file://../shared/checks.cjs:benchmarkLatency + - type: javascript + metric: tokens + weight: 0 + value: file://../shared/checks.cjs:benchmarkTokens + - type: javascript + metric: path + value: file://../shared/checks.cjs:benchmarkPath + +- description: 'List: build nested bullets at multiple levels' + vars: + fixture: basic-list.docx + keepFile: true + task: 'After the last bullet item in the existing bullet list, add three new bullet items in sequence: "Top new" at the top level (level 0), then "Child of top new" nested one level deeper (level 1), then "Grandchild" nested two levels deeper (level 2). Do not touch any existing content.' + assert: + - type: javascript + metric: correctness + value: | + const d = JSON.parse(output); + const t = d.documentText || ''; + const required = ['Top new', 'Child of top new', 'Grandchild']; + const missing = required.filter((s) => !t.includes(s)); + if (missing.length) return { pass: false, score: 0, reason: `Missing new items: ${missing.join(', ')}` }; + return { pass: true, score: 1, reason: 'All three new items present' }; + - type: javascript + metric: collateral + value: | + const d = JSON.parse(output); + if (d.documentChanged !== true) return { pass: false, score: 0, reason: 'documentChanged should be true' }; + const t = d.documentText || ''; + const originals = ['List item 1', 'List item 2', 'Indentation 1', 'Back']; + const missing = originals.filter((s) => !t.includes(s)); + if (missing.length) return { pass: false, score: 0, reason: `Original items lost: ${missing.join(', ')}` }; + return { pass: true, score: 1, reason: 'All original items preserved' }; + - type: javascript + metric: fidelity + value: file://../shared/checks.cjs:benchmarkFidelity + - type: javascript + metric: diff + weight: 0 + value: file://../shared/checks.cjs:benchmarkDiff + - type: javascript + metric: steps + weight: 0 + value: file://../shared/checks.cjs:benchmarkSteps + - type: javascript + metric: latency + weight: 0 + value: file://../shared/checks.cjs:benchmarkLatency + - type: javascript + metric: tokens + weight: 0 + value: file://../shared/checks.cjs:benchmarkTokens + - type: javascript + metric: path + value: file://../shared/checks.cjs:benchmarkPath diff --git a/evals/suites/execution/tests/execution.yaml b/evals/suites/execution/tests/execution.yaml index 929f76914c..70a4140793 100644 --- a/evals/suites/execution/tests/execution.yaml +++ b/evals/suites/execution/tests/execution.yaml @@ -397,6 +397,147 @@ if (!hasListCall) return { pass: false, score: 0, reason: `No superdoc_list call. Tools: ${tools.join(' → ')}` }; return { pass: true, score: 1, reason: `Used list tool` }; metric: tool_selection + # Regression guard: after bullet→ordered conversion, no level's rFonts + # should still point at a symbol font (Wingdings / Symbol / Webdings) — + # those produce pictographic glyphs instead of digits in Word. + # Reads word/numbering.xml from the saved .docx and flags violations. + - type: javascript + value: file://../shared/checks.cjs:checkNoSymbolFontsOnOrderedLevels + metric: numbering_font_consistency + +# --- Compound-op execution tests (merge / split / restart / subpoint) --- +# +# document.docx contains two separate lists: +# - Bullet list (listId 5:...): "All sorts of bullets." → "Nested lists" → "With" → "Lots of plenty of different icons" +# - Numbered list (listId 6:...): "Numbers" → "Or letters" → "All sorts of lists are supported" +# These tests exercise the compound list ops (merge / split / set_value / insert+indent) +# and the paraId roundtrip — insert receipt nodeId must survive OOXML export/import. + +- description: 'List: merge two adjacent lists into one' + vars: + fixture: document.docx + keepFile: true + task: 'The document has two adjacent lists. Merge the second list (starting with "Numbers") into the first list (starting with "All sorts of bullets.") so they become one continuous list.' + assert: + - type: javascript + value: | + const d = JSON.parse(output); + const t = d.documentText || ''; + // All original list-item texts must still be present + const items = ['All sorts of bullets.', 'Nested lists', 'Numbers', 'Or letters', 'All sorts of lists are supported']; + const missing = items.filter(s => !t.includes(s)); + if (missing.length) return { pass: false, score: 0, reason: `Missing items after merge: ${missing.join(', ')}` }; + return { pass: true, score: 1, reason: 'All original items preserved' }; + - type: javascript + value: | + const d = JSON.parse(output); + const listCalls = (d.toolCalls || []).filter(tc => tc.tool === 'superdoc_list'); + const actions = listCalls.map(tc => tc.args?.action).filter(Boolean); + if (!actions.includes('merge')) return { pass: false, score: 0, reason: `Expected action "merge", got [${actions.join(', ')}]` }; + return { pass: true, score: 1, reason: 'Used superdoc_list({action:"merge"})' }; + metric: action_selection + - type: javascript + value: file://../shared/checks.cjs:checkBulletsAndNumbersMerged + metric: structural_change + - type: javascript + value: file://../shared/checks.cjs:traceAllOk + - type: javascript + value: file://../shared/checks.cjs:traceLog + +- description: 'List: split a list at a specific item' + vars: + fixture: document.docx + keepFile: true + task: 'In the bullet list, split at the item "With" so that "With" and everything after it become a new separate list. Use the list-split operation, not a workaround.' + assert: + - type: javascript + value: | + const d = JSON.parse(output); + const t = d.documentText || ''; + // Target text must remain in the document after split — split only reassigns numId, doesn't delete content + const items = ['All sorts of bullets.', 'Nested lists', 'With', 'Lots of plenty of different icons']; + const missing = items.filter(s => !t.includes(s)); + if (missing.length) return { pass: false, score: 0, reason: `Content damaged: missing ${missing.join(', ')}` }; + return { pass: true, score: 1, reason: 'All items preserved' }; + - type: javascript + value: | + const d = JSON.parse(output); + const listCalls = (d.toolCalls || []).filter(tc => tc.tool === 'superdoc_list'); + const actions = listCalls.map(tc => tc.args?.action).filter(Boolean); + if (!actions.includes('split')) return { pass: false, score: 0, reason: `Expected action "split", got [${actions.join(', ')}]` }; + return { pass: true, score: 1, reason: 'Used superdoc_list({action:"split"})' }; + metric: action_selection + - type: javascript + value: file://../shared/checks.cjs:checkBulletListSplitAtWith + metric: structural_change + - type: javascript + value: file://../shared/checks.cjs:traceAllOk + - type: javascript + value: file://../shared/checks.cjs:traceLog + +- description: 'List: restart numbering at a specific item' + vars: + fixture: document.docx + keepFile: true + task: 'In the numbered list, restart the numbering at the item "All sorts of lists are supported" so it counts from 1 again from that point forward. Use the set-value operation, not split or continue-previous.' + assert: + - type: javascript + value: | + const d = JSON.parse(output); + const t = d.documentText || ''; + const items = ['Numbers', 'Or letters', 'All sorts of lists are supported']; + const missing = items.filter(s => !t.includes(s)); + if (missing.length) return { pass: false, score: 0, reason: `Content damaged: missing ${missing.join(', ')}` }; + return { pass: true, score: 1, reason: 'All items preserved' }; + - type: javascript + value: | + const d = JSON.parse(output); + const listCalls = (d.toolCalls || []).filter(tc => tc.tool === 'superdoc_list'); + const actions = listCalls.map(tc => tc.args?.action).filter(Boolean); + if (!actions.includes('set_value')) return { pass: false, score: 0, reason: `Expected action "set_value", got [${actions.join(', ')}]` }; + return { pass: true, score: 1, reason: 'Used superdoc_list({action:"set_value"})' }; + metric: action_selection + - type: javascript + value: file://../shared/checks.cjs:checkRestartAtAllSorts + metric: structural_change + - type: javascript + value: file://../shared/checks.cjs:traceAllOk + - type: javascript + value: file://../shared/checks.cjs:traceLog + +- description: 'List: add a sub-point under an existing item (exercises paraId fix)' + vars: + fixture: document.docx + keepFile: true + task: 'Under the list item "All sorts of bullets.", add a new sub-point with text "Freshly nested" that is nested one level deeper than it. Use lists.insert for the new item, then indent it.' + assert: + - type: javascript + value: | + const d = JSON.parse(output); + const t = d.documentText || ''; + if (!t.includes('Freshly nested')) return { pass: false, score: 0, reason: 'New sub-point text missing' }; + if (!t.includes('All sorts of bullets.')) return { pass: false, score: 0, reason: 'Parent item damaged' }; + return { pass: true, score: 1, reason: 'Sub-point text present with parent intact' }; + - type: javascript + value: | + // Key assertion: both "insert" and a nesting call (indent OR set_level) + // must have succeeded against the SAME new list item. If the insert + // receipt returned an unresolvable UUID (pre-paraId fix), the indent + // step would have failed with TARGET_NOT_FOUND and shown up in trace + // errors or caused the agent to retry. + const d = JSON.parse(output); + const listCalls = (d.toolCalls || []).filter(tc => tc.tool === 'superdoc_list'); + const actions = listCalls.map(tc => tc.args?.action).filter(Boolean); + const hasInsert = actions.includes('insert'); + const hasNest = actions.includes('indent') || actions.includes('set_level'); + if (!hasInsert) return { pass: false, score: 0, reason: `Expected action "insert", got [${actions.join(', ')}]` }; + if (!hasNest) return { pass: false, score: 0, reason: `Expected follow-up "indent" or "set_level" after insert, got [${actions.join(', ')}]` }; + return { pass: true, score: 1, reason: `Sequence: ${actions.join(' → ')}` }; + metric: action_sequence + - type: javascript + value: file://../shared/checks.cjs:traceAllOk + - type: javascript + value: file://../shared/checks.cjs:traceLog # ============================================================================= # ASPIRATIONAL diff --git a/evals/suites/tool-quality/tests/tool-quality.yaml b/evals/suites/tool-quality/tests/tool-quality.yaml index 5aa7acf954..9f4a6be500 100644 --- a/evals/suites/tool-quality/tests/tool-quality.yaml +++ b/evals/suites/tool-quality/tests/tool-quality.yaml @@ -233,6 +233,54 @@ value: file://../shared/checks.cjs:instinctList metric: tool_instinct +# --- List tasks: compound ops (merge / split / restart) --- +# +# These tests probe the model's ACTION-selection within superdoc_list in a +# single turn. Task prompts include a concrete listItem nodeId so the model +# can skip the read and go straight to the mutation — otherwise in single-turn +# with tool_choice: required, the model correctly reads first and never emits +# the mutation. Multi-step workflows (e.g. sub-point = insert then indent) are +# deferred to Level 2 execution evals. + +- description: 'Merge two adjacent lists (instinct)' + metadata: { category: instinct, group: 2 } + vars: + task: 'The document has two adjacent numbered lists. Given that the first item of the second list has nodeId "A1B2C3D4", call the tool that merges its sequence into the previous list so the two become one continuous numbered list.' + expectedListAction: merge + assert: + - type: javascript + value: file://../shared/checks.cjs:instinctList + metric: tool_instinct + - type: javascript + value: file://../shared/checks.cjs:usesListAction + metric: argument_accuracy + +- description: 'Split a list at a specific item (instinct)' + metadata: { category: instinct, group: 2 } + vars: + task: 'Given a numbered list, split it at the item with nodeId "E5F6G7H8" so that item and everything after it become a new separate list whose numbering restarts at 1.' + expectedListAction: split + assert: + - type: javascript + value: file://../shared/checks.cjs:instinctList + metric: tool_instinct + - type: javascript + value: file://../shared/checks.cjs:usesListAction + metric: argument_accuracy + +- description: 'Restart list numbering at a specific item (instinct)' + metadata: { category: instinct, group: 2 } + vars: + task: 'The ordered list currently runs 1, 2, 3, 4, 5 continuously. Make item with nodeId "I9J0K1L2" restart at 1 — set its explicit numbering value to 1 so subsequent items become 2, 3, etc.' + expectedListAction: set_value + assert: + - type: javascript + value: file://../shared/checks.cjs:instinctList + metric: tool_instinct + - type: javascript + value: file://../shared/checks.cjs:usesListAction + metric: argument_accuracy + # --- Tracked changes tasks --- - description: 'Tracked change edit (instinct)' diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index d3631203c2..18697e1d98 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -251,14 +251,32 @@ export const INTENT_GROUP_META: Record = { toolName: 'superdoc_list', description: 'Create and manipulate bullet and numbered lists. ' + - 'To create a list: first create all paragraphs at the SAME location using superdoc_create (chain each using the previous nodeId as the "at" target). ' + - 'Then call action "create" with mode:"fromParagraphs", a preset ("disc" for bullet, "decimal" for numbered), and a range target: {from:{kind:"block", nodeType:"paragraph", nodeId:""}, to:{kind:"block", nodeType:"paragraph", nodeId:""}}. ' + - 'The range converts ALL paragraphs between from and to into list items. Make sure no other content exists between them. ' + - 'Action "set_type" converts between bullet and ordered (target any item in the list, kind:"ordered" or "bullet"). ' + - 'Action "insert" adds a new item before/after a target list item. ' + - 'Actions "indent" and "outdent" change nesting level; "set_level" jumps to a specific level (0-8). ' + - 'Action "detach" converts a list item back to a plain paragraph. ' + - 'Do NOT target paragraphs with indent/outdent/set_type; these actions require a listItem target.', + 'Most actions require a list-item target: {kind:"block", nodeType:"listItem", nodeId:""}. ' + + 'Exceptions: "create" and "attach" operate on paragraph targets (they turn paragraphs into list items). ' + + 'Find nodeIds via superdoc_get_content({action:"blocks"}) — pick listItem blocks for most actions, paragraph blocks for create/attach.\n' + + '\n' + + 'CREATE & CONVERT:\n' + + '• "create" — make a NEW list from paragraphs. Two modes: ' + + 'mode:"empty" with at:{kind:"block", nodeType:"paragraph", nodeId} converts a single paragraph; ' + + 'mode:"fromParagraphs" with target:{from:{...paragraph block address}, to:{...paragraph block address}} converts a range — ALL paragraphs between from and to become items, so make sure no other content sits between them. ' + + 'Pass a preset ("disc"|"circle"|"square"|"dash" for bullets; "decimal"|"decimalParenthesis"|"lowerLetter"|"upperLetter"|"lowerRoman"|"upperRoman" for ordered) or a custom style. ' + + 'Use "create" to start a fresh list — NOT to extend an existing one (use "attach" for that).\n' + + '• "attach" — add paragraphs to an EXISTING list, inheriting its numbering definition. Pass target:{paragraph block address} (or {from, to} range of paragraphs) + attachTo:{kind:"block", nodeType:"listItem", nodeId:""} + optional level:0..8. Use this to extend a list or as the second half of a merge workflow (see "join" below).\n' + + '• "set_type" — convert an existing list between ordered and bullet. Pass target:{listItem} + kind:"ordered" or "bullet". Adjacent compatible sequences are merged automatically to preserve continuous numbering.\n' + + '• "detach" — convert a list item back to a plain paragraph. Pass target:{listItem}.\n' + + '\n' + + 'ITEMS & NESTING:\n' + + '• "insert" — add a new list item adjacent to an existing item in the same list. Pass target:{listItem} + position:"before"|"after" + optional text. Use this (NOT superdoc_create) to add items to an existing list.\n' + + '• "indent" / "outdent" — bump the target item\'s nesting level by one (0-8 range). Pass target:{listItem}.\n' + + '• "set_level" — jump the target item to an explicit level. Pass target:{listItem} + level:0..8.\n' + + '\n' + + 'NUMBERING (ordered lists):\n' + + '• "set_value" — restart numbering at the target. Pass target:{listItem} + value: (e.g. value:1 to start over) or value:null to clear a previous override. Mid-sequence targets are atomically split off into their own sequence.\n' + + '• "continue_previous" — make the target\'s sequence continue numbering from the nearest compatible previous sequence (same abstract definition). Pass target:{listItem of the sequence you want to renumber}. Fails with NO_COMPATIBLE_PREVIOUS or INCOMPATIBLE_DEFINITIONS if no matching prior sequence exists.\n' + + '\n' + + 'SEQUENCE SHAPE (merge / split):\n' + + '• "merge" — merge the target\'s sequence with an adjacent one into one continuous list. Pass target:{listItem} + direction:"withPrevious" or "withNext". Absorbed items adopt the absorbing sequence\'s numbering definition, and empty paragraphs between the two sequences are removed so numbering flows continuously.\n' + + '• "split" — split the target\'s sequence at the target item into two independent lists. The target and everything after become a new sequence that restarts numbering at 1. Pass target:{listItem}; add restartNumbering:false to keep the count continuing instead of restarting.', inputExamples: [ { action: 'create', @@ -277,6 +295,14 @@ export const INTENT_GROUP_META: Record = { text: 'New list item', }, { action: 'indent', target: { kind: 'block', nodeType: 'listItem', nodeId: '' } }, + { + action: 'merge', + target: { kind: 'block', nodeType: 'listItem', nodeId: '' }, + direction: 'withPrevious', + }, + { action: 'split', target: { kind: 'block', nodeType: 'listItem', nodeId: '' } }, + { action: 'set_value', target: { kind: 'block', nodeType: 'listItem', nodeId: '' }, value: 1 }, + { action: 'continue_previous', target: { kind: 'block', nodeType: 'listItem', nodeId: '' } }, ], }, comment: { @@ -1695,6 +1721,8 @@ export const OPERATION_DEFINITIONS = { }), referenceDocPath: 'lists/attach.mdx', referenceGroup: 'lists', + intentGroup: 'list', + intentAction: 'attach', }, 'lists.detach': { memberPath: 'lists.detach', @@ -1795,6 +1823,42 @@ export const OPERATION_DEFINITIONS = { referenceDocPath: 'lists/separate.mdx', referenceGroup: 'lists', }, + 'lists.merge': { + memberPath: 'lists.merge', + description: + 'Compound: merge two adjacent list sequences into one. Reassigns numId on the absorbed sequence (no strict abstractNumId check — absorbed items adopt the absorbing definition) and deletes empty paragraphs between the two sequences. Use this instead of lists.join for the user-facing "merge these lists" intent.', + expectedResult: 'Returns a ListsMergeResult with the merged listId, absorbedCount, and removedEmptyBlocks count.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['INVALID_TARGET', 'NO_ADJACENT_SEQUENCE', 'NO_OP'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET'], + }), + referenceDocPath: 'lists/merge.mdx', + referenceGroup: 'lists', + intentGroup: 'list', + intentAction: 'merge', + }, + 'lists.split': { + memberPath: 'lists.split', + description: + 'Compound: split a list sequence at the target item into two independent sequences. Runs lists.separate then (by default) lists.setValue(1) so the new half starts numbering fresh at 1. Pass restartNumbering:false for raw separate semantics (new half continues the previous count).', + expectedResult: 'Returns a ListsSplitResult with the new listId, numId, and the restart value applied (or null).', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['INVALID_TARGET', 'NO_OP'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET'], + }), + referenceDocPath: 'lists/split.mdx', + referenceGroup: 'lists', + intentGroup: 'list', + intentAction: 'split', + }, 'lists.setLevel': { memberPath: 'lists.setLevel', description: 'Set the absolute nesting level (0..8) of a list item.', @@ -1827,6 +1891,8 @@ export const OPERATION_DEFINITIONS = { }), referenceDocPath: 'lists/set-value.mdx', referenceGroup: 'lists', + intentGroup: 'list', + intentAction: 'set_value', }, 'lists.continuePrevious': { memberPath: 'lists.continuePrevious', @@ -1842,6 +1908,8 @@ export const OPERATION_DEFINITIONS = { }), referenceDocPath: 'lists/continue-previous.mdx', referenceGroup: 'lists', + intentGroup: 'list', + intentAction: 'continue_previous', }, 'lists.canContinuePrevious': { memberPath: 'lists.canContinuePrevious', diff --git a/packages/document-api/src/contract/operation-registry.ts b/packages/document-api/src/contract/operation-registry.ts index 4eef085c5d..610b1695ae 100644 --- a/packages/document-api/src/contract/operation-registry.ts +++ b/packages/document-api/src/contract/operation-registry.ts @@ -102,6 +102,10 @@ import type { ListsCanJoinResult, ListsSeparateInput, ListsSeparateResult, + ListsMergeInput, + ListsMergeResult, + ListsSplitInput, + ListsSplitResult, ListsSetLevelInput, ListsSetValueInput, ListsContinuePreviousInput, @@ -674,6 +678,8 @@ export interface OperationRegistry extends FormatInlineAliasOperationRegistry { 'lists.join': { input: ListsJoinInput; options: MutationOptions; output: ListsJoinResult }; 'lists.canJoin': { input: ListsCanJoinInput; options: never; output: ListsCanJoinResult }; 'lists.separate': { input: ListsSeparateInput; options: MutationOptions; output: ListsSeparateResult }; + 'lists.merge': { input: ListsMergeInput; options: MutationOptions; output: ListsMergeResult }; + 'lists.split': { input: ListsSplitInput; options: MutationOptions; output: ListsSplitResult }; 'lists.setLevel': { input: ListsSetLevelInput; options: MutationOptions; output: ListsMutateItemResult }; 'lists.setValue': { input: ListsSetValueInput; options: MutationOptions; output: ListsMutateItemResult }; 'lists.continuePrevious': { diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index 80f8dd8f63..ab9d89aed6 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -4193,6 +4193,72 @@ const operationSchemas: Record = { ]), failure: listsFailureSchemaFor('lists.separate'), }, + 'lists.merge': { + input: objectSchema( + { + target: listItemAddressSchema, + direction: { enum: ['withPrevious', 'withNext'] }, + }, + ['target', 'direction'], + ), + output: { + oneOf: [ + objectSchema( + { + success: { const: true }, + listId: { type: 'string' }, + absorbedCount: { type: 'integer' }, + removedEmptyBlocks: { type: 'integer' }, + }, + ['success', 'listId', 'absorbedCount', 'removedEmptyBlocks'], + ), + listsFailureSchemaFor('lists.merge'), + ], + }, + success: objectSchema( + { + success: { const: true }, + listId: { type: 'string' }, + absorbedCount: { type: 'integer' }, + removedEmptyBlocks: { type: 'integer' }, + }, + ['success', 'listId', 'absorbedCount', 'removedEmptyBlocks'], + ), + failure: listsFailureSchemaFor('lists.merge'), + }, + 'lists.split': { + input: objectSchema( + { + target: listItemAddressSchema, + restartNumbering: { type: 'boolean' }, + }, + ['target'], + ), + output: { + oneOf: [ + objectSchema( + { + success: { const: true }, + listId: { type: 'string' }, + numId: { type: 'integer' }, + restartedAt: { type: ['integer', 'null'] }, + }, + ['success', 'listId', 'numId', 'restartedAt'], + ), + listsFailureSchemaFor('lists.split'), + ], + }, + success: objectSchema( + { + success: { const: true }, + listId: { type: 'string' }, + numId: { type: 'integer' }, + restartedAt: { type: ['integer', 'null'] }, + }, + ['success', 'listId', 'numId', 'restartedAt'], + ), + failure: listsFailureSchemaFor('lists.split'), + }, 'lists.setLevel': { input: objectSchema( { diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index be03635aaf..a30988b4a3 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -174,6 +174,10 @@ import type { ListsCanJoinResult, ListsSeparateInput, ListsSeparateResult, + ListsMergeInput, + ListsMergeResult, + ListsSplitInput, + ListsSplitResult, ListsSetLevelInput, ListsSetValueInput, ListsContinuePreviousInput, @@ -216,6 +220,8 @@ import { executeListsJoin, executeListsCanJoin, executeListsSeparate, + executeListsMerge, + executeListsSplit, executeListsSetLevel, executeListsSetValue, executeListsContinuePrevious, @@ -1300,6 +1306,10 @@ export type { ListsMutateItemResult, ListsSeparateInput, ListsSeparateResult, + ListsMergeInput, + ListsMergeResult, + ListsSplitInput, + ListsSplitResult, ListsSetLevelInput, ListsSetLevelRestartInput, ListsSetValueInput, @@ -2233,6 +2243,12 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { separate(input: ListsSeparateInput, options?: MutationOptions): ListsSeparateResult { return executeListsSeparate(adapters.lists, input, options); }, + merge(input: ListsMergeInput, options?: MutationOptions): ListsMergeResult { + return executeListsMerge(adapters.lists, input, options); + }, + split(input: ListsSplitInput, options?: MutationOptions): ListsSplitResult { + return executeListsSplit(adapters.lists, input, options); + }, setLevel(input: ListsSetLevelInput, options?: MutationOptions): ListsMutateItemResult { return executeListsSetLevel(adapters.lists, input, options); }, diff --git a/packages/document-api/src/invoke/invoke.ts b/packages/document-api/src/invoke/invoke.ts index 224b066528..2df6e5524c 100644 --- a/packages/document-api/src/invoke/invoke.ts +++ b/packages/document-api/src/invoke/invoke.ts @@ -129,6 +129,8 @@ export function buildDispatchTable(api: DocumentApi): TypedDispatchTable { 'lists.join': (input, options) => api.lists.join(input, options), 'lists.canJoin': (input) => api.lists.canJoin(input), 'lists.separate': (input, options) => api.lists.separate(input, options), + 'lists.merge': (input, options) => api.lists.merge(input, options), + 'lists.split': (input, options) => api.lists.split(input, options), 'lists.setLevel': (input, options) => api.lists.setLevel(input, options), 'lists.setValue': (input, options) => api.lists.setValue(input, options), 'lists.continuePrevious': (input, options) => api.lists.continuePrevious(input, options), diff --git a/packages/document-api/src/lists/lists.test.ts b/packages/document-api/src/lists/lists.test.ts index 7a921e9bd7..44becbf927 100644 --- a/packages/document-api/src/lists/lists.test.ts +++ b/packages/document-api/src/lists/lists.test.ts @@ -8,6 +8,8 @@ import { executeListsAttach, executeListsSeparate, executeListsJoin, + executeListsMerge, + executeListsSplit, executeListsSetLevel, executeListsSetValue, executeListsConvertToText, @@ -40,6 +42,8 @@ const stubAdapter = () => join: mock(() => ({ success: true })), canJoin: mock(() => ({ canJoin: true })), separate: mock(() => ({ success: true })), + merge: mock(() => ({ success: true, listId: 'l1', absorbedCount: 1, removedEmptyBlocks: 0 })), + split: mock(() => ({ success: true, listId: 'l2', numId: 2, restartedAt: 1 })), setLevel: mock(() => ({ success: true })), setValue: mock(() => ({ success: true })), continuePrevious: mock(() => ({ success: true })), @@ -237,6 +241,64 @@ describe('executeListsJoin validates direction', () => { }); }); +describe('executeListsMerge validates direction', () => { + it('rejects missing target.kind', () => { + expect(() => + executeListsMerge(stubAdapter(), { + target: { nodeType: 'listItem', nodeId: 'x' }, + direction: 'withPrevious', + } as any), + ).toThrow(/target\.kind/); + }); + + it('rejects invalid direction', () => { + expect(() => executeListsMerge(stubAdapter(), { target: validTarget, direction: 'sideways' } as any)).toThrow( + /direction must be one of/, + ); + }); + + it('accepts valid withPrevious / withNext', () => { + const adapter = stubAdapter(); + executeListsMerge(adapter, { target: validTarget, direction: 'withPrevious' }); + executeListsMerge(adapter, { target: validTarget, direction: 'withNext' }); + expect(adapter.merge).toHaveBeenCalledTimes(2); + }); + + it('forwards mutation options to the adapter', () => { + const adapter = stubAdapter(); + executeListsMerge(adapter, { target: validTarget, direction: 'withPrevious' }, { dryRun: true }); + const [, options] = adapter.merge.mock.calls[0]; + expect(options).toMatchObject({ dryRun: true }); + }); +}); + +describe('executeListsSplit validates restartNumbering', () => { + it('rejects missing target.kind', () => { + expect(() => executeListsSplit(stubAdapter(), { target: { nodeType: 'listItem', nodeId: 'x' } } as any)).toThrow( + /target\.kind/, + ); + }); + + it('rejects non-boolean restartNumbering', () => { + expect(() => executeListsSplit(stubAdapter(), { target: validTarget, restartNumbering: 'yes' } as any)).toThrow( + /restartNumbering must be a boolean/, + ); + }); + + it('accepts omitted restartNumbering (defaults to restart-on at the wrapper layer)', () => { + const adapter = stubAdapter(); + executeListsSplit(adapter, { target: validTarget }); + expect(adapter.split).toHaveBeenCalled(); + }); + + it('accepts explicit restartNumbering:true and restartNumbering:false', () => { + const adapter = stubAdapter(); + executeListsSplit(adapter, { target: validTarget, restartNumbering: true }); + executeListsSplit(adapter, { target: validTarget, restartNumbering: false }); + expect(adapter.split).toHaveBeenCalledTimes(2); + }); +}); + describe('executeListsSetLevel validates level', () => { it('rejects string level', () => { expect(() => executeListsSetLevel(stubAdapter(), { target: validTarget, level: '2' } as any)).toThrow( diff --git a/packages/document-api/src/lists/lists.ts b/packages/document-api/src/lists/lists.ts index 3b7de1f304..5b7e11259d 100644 --- a/packages/document-api/src/lists/lists.ts +++ b/packages/document-api/src/lists/lists.ts @@ -32,6 +32,10 @@ import type { ListsCanJoinResult, ListsSeparateInput, ListsSeparateResult, + ListsMergeInput, + ListsMergeResult, + ListsSplitInput, + ListsSplitResult, ListsSetLevelInput, ListsSetValueInput, ListsContinuePreviousInput, @@ -83,6 +87,10 @@ export type { ListsCanJoinResult, ListsSeparateInput, ListsSeparateResult, + ListsMergeInput, + ListsMergeResult, + ListsSplitInput, + ListsSplitResult, ListsSetLevelInput, ListsSetValueInput, ListsContinuePreviousInput, @@ -480,6 +488,8 @@ export interface ListsAdapter { join(input: ListsJoinInput, options?: MutationOptions): ListsJoinResult; canJoin(input: ListsCanJoinInput): ListsCanJoinResult; separate(input: ListsSeparateInput, options?: MutationOptions): ListsSeparateResult; + merge(input: ListsMergeInput, options?: MutationOptions): ListsMergeResult; + split(input: ListsSplitInput, options?: MutationOptions): ListsSplitResult; setLevel(input: ListsSetLevelInput, options?: MutationOptions): ListsMutateItemResult; setValue(input: ListsSetValueInput, options?: MutationOptions): ListsMutateItemResult; continuePrevious(input: ListsContinuePreviousInput, options?: MutationOptions): ListsMutateItemResult; @@ -700,6 +710,26 @@ export function executeListsSeparate( return adapter.separate(input, normalizeMutationOptions(options)); } +export function executeListsMerge( + adapter: ListsAdapter, + input: ListsMergeInput, + options?: MutationOptions, +): ListsMergeResult { + validateListItemTarget(input, 'lists.merge'); + requireEnum(input.direction, 'direction', VALID_JOIN_DIRECTIONS, 'lists.merge'); + return adapter.merge(input, normalizeMutationOptions(options)); +} + +export function executeListsSplit( + adapter: ListsAdapter, + input: ListsSplitInput, + options?: MutationOptions, +): ListsSplitResult { + validateListItemTarget(input, 'lists.split'); + optionalBoolean(input.restartNumbering, 'restartNumbering', 'lists.split'); + return adapter.split(input, normalizeMutationOptions(options)); +} + export function executeListsSetLevel( adapter: ListsAdapter, input: ListsSetLevelInput, diff --git a/packages/document-api/src/lists/lists.types.ts b/packages/document-api/src/lists/lists.types.ts index cf9acaf0ed..77895ba3b4 100644 --- a/packages/document-api/src/lists/lists.types.ts +++ b/packages/document-api/src/lists/lists.types.ts @@ -223,6 +223,16 @@ export interface ListsSeparateInput { copyOverrides?: boolean; } +export interface ListsMergeInput { + target: ListItemAddress; + direction: JoinDirection; +} + +export interface ListsSplitInput { + target: ListItemAddress; + restartNumbering?: boolean; +} + export interface ListsSetLevelInput { target: ListItemAddress; level: number; @@ -488,6 +498,20 @@ export interface ListsSeparateSuccessResult { numId: number; } +export interface ListsMergeSuccessResult { + success: true; + listId: string; + absorbedCount: number; + removedEmptyBlocks: number; +} + +export interface ListsSplitSuccessResult { + success: true; + listId: string; + numId: number; + restartedAt: number | null; +} + export interface ListsDetachSuccessResult { success: true; paragraph: { @@ -528,5 +552,7 @@ export type ListsMutateItemResult = ListsMutateItemSuccessResult | ListsFailureR export type ListsCreateResult = ListsCreateSuccessResult | ListsFailureResult; export type ListsJoinResult = ListsJoinSuccessResult | ListsFailureResult; export type ListsSeparateResult = ListsSeparateSuccessResult | ListsFailureResult; +export type ListsMergeResult = ListsMergeSuccessResult | ListsFailureResult; +export type ListsSplitResult = ListsSplitSuccessResult | ListsFailureResult; export type ListsDetachResult = ListsDetachSuccessResult | ListsFailureResult; export type ListsConvertToTextResult = ListsConvertToTextSuccessResult | ListsFailureResult; diff --git a/packages/sdk/langs/browser/src/intent-dispatch.ts b/packages/sdk/langs/browser/src/intent-dispatch.ts index 9f1225b1a8..c4feedc7de 100644 --- a/packages/sdk/langs/browser/src/intent-dispatch.ts +++ b/packages/sdk/langs/browser/src/intent-dispatch.ts @@ -84,14 +84,24 @@ export function dispatchIntentTool( return execute('doc.lists.insert', rest); case 'create': return execute('doc.lists.create', rest); + case 'attach': + return execute('doc.lists.attach', rest); case 'detach': return execute('doc.lists.detach', rest); case 'indent': return execute('doc.lists.indent', rest); case 'outdent': return execute('doc.lists.outdent', rest); + case 'merge': + return execute('doc.lists.merge', rest); + case 'split': + return execute('doc.lists.split', rest); case 'set_level': return execute('doc.lists.setLevel', rest); + case 'set_value': + return execute('doc.lists.setValue', rest); + case 'continue_previous': + return execute('doc.lists.continuePrevious', rest); case 'set_type': return execute('doc.lists.setType', rest); default: diff --git a/packages/sdk/langs/browser/src/system-prompt.ts b/packages/sdk/langs/browser/src/system-prompt.ts index 5cd5ae49b5..a327acc7d4 100644 --- a/packages/sdk/langs/browser/src/system-prompt.ts +++ b/packages/sdk/langs/browser/src/system-prompt.ts @@ -166,6 +166,94 @@ Use preset "disc" for bullets, "decimal" for numbered. WARNING: the range conver 3. To change a bullet list to numbered: \`superdoc_list({action: "set_type", target: {kind: "block", nodeType: "listItem", nodeId: ""}, kind: "ordered"})\` +### Add items to an existing list + +To add a new item adjacent to an existing list item, use \`superdoc_list({action: "insert"})\`, NOT \`superdoc_create({action: "paragraph"})\` — the latter creates a standalone paragraph that is not part of the list: + +\`\`\` +superdoc_get_content({action: "blocks"}) // find the listItem nodeId you want to insert next to +superdoc_list({action: "insert", target: {kind: "block", nodeType: "listItem", nodeId: ""}, position: "after", text: "New item text"}) +\`\`\` + +**Level inheritance.** The new item inherits the target's nesting level. Insert after a level-0 item → new item is level 0. Insert after a level-2 item → new item is level 2. To change the level, chain \`indent\` / \`outdent\` / \`set_level\` on the nodeId returned in the insert response. + +**Use the nodeId from the response directly.** \`superdoc_list({action: "insert"})\` returns \`{item: {nodeId: ""}}\` — that id is ready for subsequent \`indent\`, \`outdent\`, \`set_level\`, or text edits. You do NOT need to re-fetch blocks between the insert and the follow-up operation. + +### Add a sub-point under an existing item + +Insert a peer, then indent it one level: + +\`\`\` +// 1. Insert a peer item after the parent — new item is at the parent's level +const resp = superdoc_list({action: "insert", target: {kind: "block", nodeType: "listItem", nodeId: ""}, position: "after", text: "Sub-point"}) + +// 2. Indent using the nodeId from resp.item.nodeId +superdoc_list({action: "indent", target: {kind: "block", nodeType: "listItem", nodeId: ""}}) +\`\`\` + +### Build a nested list with mixed levels + +\`lists.create\` produces a flat list. Add nesting by chaining \`insert\` + \`indent\` / \`set_level\`, using the nodeId returned by each insert to target the next step: + +\`\`\` +// Starting point: a list item at level 0 ("Parent" with nodeId ) + +// Sibling at level 0 +const r1 = superdoc_list({action: "insert", target: {kind: "block", nodeType: "listItem", nodeId: ""}, position: "after", text: "Sibling"}) + +// Child at level 1 (insert after r1, then indent) +const r2 = superdoc_list({action: "insert", target: {kind: "block", nodeType: "listItem", nodeId: ""}, position: "after", text: "Child"}) +superdoc_list({action: "indent", target: {kind: "block", nodeType: "listItem", nodeId: ""}}) + +// Grandchild at level 3 (insert after r2, then jump to level 3 directly) +const r3 = superdoc_list({action: "insert", target: {kind: "block", nodeType: "listItem", nodeId: ""}, position: "after", text: "Deep"}) +superdoc_list({action: "set_level", target: {kind: "block", nodeType: "listItem", nodeId: ""}, level: 3}) +\`\`\` + +\`indent\` bumps the level by one (bounded 0–8). \`set_level\` jumps directly to any level 0–8. Markers update automatically based on the list's definition for each level (e.g. \`1.\` / \`a.\` / \`i.\` for an ordered list). + +### Merge two adjacent lists into one + +Use \`merge\` — it handles the common case where two ordered or bulleted lists sit next to each other and should become one continuous list. Absorbed items adopt the absorbing sequence's definition, and any empty paragraphs between the two lists are removed so numbering flows continuously. + +\`\`\` +superdoc_get_content({action: "blocks"}) // find a listItem in either sequence +// To merge with the previous sequence: +superdoc_list({action: "merge", target: {kind: "block", nodeType: "listItem", nodeId: ""}, direction: "withPrevious"}) +// Or with the next sequence: +superdoc_list({action: "merge", target: {kind: "block", nodeType: "listItem", nodeId: ""}, direction: "withNext"}) +\`\`\` + +### Split a list into two + +Use \`split\` to break one list into two independent lists at a specific item. The target and everything after become a new sequence that restarts numbering at 1: + +\`\`\` +superdoc_list({action: "split", target: {kind: "block", nodeType: "listItem", nodeId: ""}}) +\`\`\` + +Pass \`restartNumbering: false\` if you want the new half to keep counting from where the original left off. + +### Restart numbering at a specific item + +For ordered lists. To make item N restart from a chosen number (commonly 1): + +\`\`\` +superdoc_list({action: "set_value", target: {kind: "block", nodeType: "listItem", nodeId: ""}, value: 1}) +\`\`\` + +Pass \`value: null\` to clear a previously-set restart override and let the item resume natural numbering. + +### Continue numbering across a break + +For ordered lists. When two sibling sequences should be numbered as one (e.g. numbering jumps back to 1 and you want it to continue from where the previous list left off), target the FIRST item of the second sequence: + +\`\`\` +superdoc_list({action: "continue_previous", target: {kind: "block", nodeType: "listItem", nodeId: ""}}) +\`\`\` + +Fails with \`NO_COMPATIBLE_PREVIOUS\` or \`INCOMPATIBLE_DEFINITIONS\` if no prior sequence shares the same abstract definition. In that case, use \`merge\` instead — it handles mismatched definitions, removes empty gap paragraphs, and produces one continuous list. + ### Insert content into a document (new or existing) Markdown insert creates block structure but uses default formatting. You MUST follow up with formatting so inserted content looks like it belongs in the document. diff --git a/packages/sdk/tools/intent_dispatch_generated.py b/packages/sdk/tools/intent_dispatch_generated.py index 605fb135a7..84ff20d2b5 100644 --- a/packages/sdk/tools/intent_dispatch_generated.py +++ b/packages/sdk/tools/intent_dispatch_generated.py @@ -79,14 +79,24 @@ def dispatch_intent_tool( return execute('doc.lists.insert', rest) elif action == 'create': return execute('doc.lists.create', rest) + elif action == 'attach': + return execute('doc.lists.attach', rest) elif action == 'detach': return execute('doc.lists.detach', rest) elif action == 'indent': return execute('doc.lists.indent', rest) elif action == 'outdent': return execute('doc.lists.outdent', rest) + elif action == 'merge': + return execute('doc.lists.merge', rest) + elif action == 'split': + return execute('doc.lists.split', rest) elif action == 'set_level': return execute('doc.lists.setLevel', rest) + elif action == 'set_value': + return execute('doc.lists.setValue', rest) + elif action == 'continue_previous': + return execute('doc.lists.continuePrevious', rest) elif action == 'set_type': return execute('doc.lists.setType', rest) else: diff --git a/packages/sdk/tools/prompt-templates/system-prompt-core.md b/packages/sdk/tools/prompt-templates/system-prompt-core.md index 87c6a2d171..8bb7c8a763 100644 --- a/packages/sdk/tools/prompt-templates/system-prompt-core.md +++ b/packages/sdk/tools/prompt-templates/system-prompt-core.md @@ -160,6 +160,94 @@ Use preset "disc" for bullets, "decimal" for numbered. WARNING: the range conver 3. To change a bullet list to numbered: `superdoc_list({action: "set_type", target: {kind: "block", nodeType: "listItem", nodeId: ""}, kind: "ordered"})` +### Add items to an existing list + +To add a new item adjacent to an existing list item, use `superdoc_list({action: "insert"})`, NOT `superdoc_create({action: "paragraph"})` — the latter creates a standalone paragraph that is not part of the list: + +``` +superdoc_get_content({action: "blocks"}) // find the listItem nodeId you want to insert next to +superdoc_list({action: "insert", target: {kind: "block", nodeType: "listItem", nodeId: ""}, position: "after", text: "New item text"}) +``` + +**Level inheritance.** The new item inherits the target's nesting level. Insert after a level-0 item → new item is level 0. Insert after a level-2 item → new item is level 2. To change the level, chain `indent` / `outdent` / `set_level` on the nodeId returned in the insert response. + +**Use the nodeId from the response directly.** `superdoc_list({action: "insert"})` returns `{item: {nodeId: ""}}` — that id is ready for subsequent `indent`, `outdent`, `set_level`, or text edits. You do NOT need to re-fetch blocks between the insert and the follow-up operation. + +### Add a sub-point under an existing item + +Insert a peer, then indent it one level: + +``` +// 1. Insert a peer item after the parent — new item is at the parent's level +const resp = superdoc_list({action: "insert", target: {kind: "block", nodeType: "listItem", nodeId: ""}, position: "after", text: "Sub-point"}) + +// 2. Indent using the nodeId from resp.item.nodeId +superdoc_list({action: "indent", target: {kind: "block", nodeType: "listItem", nodeId: ""}}) +``` + +### Build a nested list with mixed levels + +`lists.create` produces a flat list. Add nesting by chaining `insert` + `indent` / `set_level`, using the nodeId returned by each insert to target the next step: + +``` +// Starting point: a list item at level 0 ("Parent" with nodeId ) + +// Sibling at level 0 +const r1 = superdoc_list({action: "insert", target: {kind: "block", nodeType: "listItem", nodeId: ""}, position: "after", text: "Sibling"}) + +// Child at level 1 (insert after r1, then indent) +const r2 = superdoc_list({action: "insert", target: {kind: "block", nodeType: "listItem", nodeId: ""}, position: "after", text: "Child"}) +superdoc_list({action: "indent", target: {kind: "block", nodeType: "listItem", nodeId: ""}}) + +// Grandchild at level 3 (insert after r2, then jump to level 3 directly) +const r3 = superdoc_list({action: "insert", target: {kind: "block", nodeType: "listItem", nodeId: ""}, position: "after", text: "Deep"}) +superdoc_list({action: "set_level", target: {kind: "block", nodeType: "listItem", nodeId: ""}, level: 3}) +``` + +`indent` bumps the level by one (bounded 0–8). `set_level` jumps directly to any level 0–8. Markers update automatically based on the list's definition for each level (e.g. `1.` / `a.` / `i.` for an ordered list). + +### Merge two adjacent lists into one + +Use `merge` — it handles the common case where two ordered or bulleted lists sit next to each other and should become one continuous list. Absorbed items adopt the absorbing sequence's definition, and any empty paragraphs between the two lists are removed so numbering flows continuously. + +``` +superdoc_get_content({action: "blocks"}) // find a listItem in either sequence +// To merge with the previous sequence: +superdoc_list({action: "merge", target: {kind: "block", nodeType: "listItem", nodeId: ""}, direction: "withPrevious"}) +// Or with the next sequence: +superdoc_list({action: "merge", target: {kind: "block", nodeType: "listItem", nodeId: ""}, direction: "withNext"}) +``` + +### Split a list into two + +Use `split` to break one list into two independent lists at a specific item. The target and everything after become a new sequence that restarts numbering at 1: + +``` +superdoc_list({action: "split", target: {kind: "block", nodeType: "listItem", nodeId: ""}}) +``` + +Pass `restartNumbering: false` if you want the new half to keep counting from where the original left off. + +### Restart numbering at a specific item + +For ordered lists. To make item N restart from a chosen number (commonly 1): + +``` +superdoc_list({action: "set_value", target: {kind: "block", nodeType: "listItem", nodeId: ""}, value: 1}) +``` + +Pass `value: null` to clear a previously-set restart override and let the item resume natural numbering. + +### Continue numbering across a break + +For ordered lists. When two sibling sequences should be numbered as one (e.g. numbering jumps back to 1 and you want it to continue from where the previous list left off), target the FIRST item of the second sequence: + +``` +superdoc_list({action: "continue_previous", target: {kind: "block", nodeType: "listItem", nodeId: ""}}) +``` + +Fails with `NO_COMPATIBLE_PREVIOUS` or `INCOMPATIBLE_DEFINITIONS` if no prior sequence shares the same abstract definition. In that case, use `merge` instead — it handles mismatched definitions, removes empty gap paragraphs, and produces one continuous list. + ### Insert content into a document (new or existing) Markdown insert creates block structure but uses default formatting. You MUST follow up with formatting so inserted content looks like it belongs in the document. diff --git a/packages/sdk/tools/system-prompt-mcp.md b/packages/sdk/tools/system-prompt-mcp.md index 8b16122809..d4c42b6cf1 100644 --- a/packages/sdk/tools/system-prompt-mcp.md +++ b/packages/sdk/tools/system-prompt-mcp.md @@ -209,6 +209,94 @@ Use preset "disc" for bullets, "decimal" for numbered. WARNING: the range conver 3. To change a bullet list to numbered: `superdoc_list({action: "set_type", target: {kind: "block", nodeType: "listItem", nodeId: ""}, kind: "ordered"})` +### Add items to an existing list + +To add a new item adjacent to an existing list item, use `superdoc_list({action: "insert"})`, NOT `superdoc_create({action: "paragraph"})` — the latter creates a standalone paragraph that is not part of the list: + +``` +superdoc_get_content({action: "blocks"}) // find the listItem nodeId you want to insert next to +superdoc_list({action: "insert", target: {kind: "block", nodeType: "listItem", nodeId: ""}, position: "after", text: "New item text"}) +``` + +**Level inheritance.** The new item inherits the target's nesting level. Insert after a level-0 item → new item is level 0. Insert after a level-2 item → new item is level 2. To change the level, chain `indent` / `outdent` / `set_level` on the nodeId returned in the insert response. + +**Use the nodeId from the response directly.** `superdoc_list({action: "insert"})` returns `{item: {nodeId: ""}}` — that id is ready for subsequent `indent`, `outdent`, `set_level`, or text edits. You do NOT need to re-fetch blocks between the insert and the follow-up operation. + +### Add a sub-point under an existing item + +Insert a peer, then indent it one level: + +``` +// 1. Insert a peer item after the parent — new item is at the parent's level +const resp = superdoc_list({action: "insert", target: {kind: "block", nodeType: "listItem", nodeId: ""}, position: "after", text: "Sub-point"}) + +// 2. Indent using the nodeId from resp.item.nodeId +superdoc_list({action: "indent", target: {kind: "block", nodeType: "listItem", nodeId: ""}}) +``` + +### Build a nested list with mixed levels + +`lists.create` produces a flat list. Add nesting by chaining `insert` + `indent` / `set_level`, using the nodeId returned by each insert to target the next step: + +``` +// Starting point: a list item at level 0 ("Parent" with nodeId ) + +// Sibling at level 0 +const r1 = superdoc_list({action: "insert", target: {kind: "block", nodeType: "listItem", nodeId: ""}, position: "after", text: "Sibling"}) + +// Child at level 1 (insert after r1, then indent) +const r2 = superdoc_list({action: "insert", target: {kind: "block", nodeType: "listItem", nodeId: ""}, position: "after", text: "Child"}) +superdoc_list({action: "indent", target: {kind: "block", nodeType: "listItem", nodeId: ""}}) + +// Grandchild at level 3 (insert after r2, then jump to level 3 directly) +const r3 = superdoc_list({action: "insert", target: {kind: "block", nodeType: "listItem", nodeId: ""}, position: "after", text: "Deep"}) +superdoc_list({action: "set_level", target: {kind: "block", nodeType: "listItem", nodeId: ""}, level: 3}) +``` + +`indent` bumps the level by one (bounded 0–8). `set_level` jumps directly to any level 0–8. Markers update automatically based on the list's definition for each level (e.g. `1.` / `a.` / `i.` for an ordered list). + +### Merge two adjacent lists into one + +Use `merge` — it handles the common case where two ordered or bulleted lists sit next to each other and should become one continuous list. Absorbed items adopt the absorbing sequence's definition, and any empty paragraphs between the two lists are removed so numbering flows continuously. + +``` +superdoc_get_content({action: "blocks"}) // find a listItem in either sequence +// To merge with the previous sequence: +superdoc_list({action: "merge", target: {kind: "block", nodeType: "listItem", nodeId: ""}, direction: "withPrevious"}) +// Or with the next sequence: +superdoc_list({action: "merge", target: {kind: "block", nodeType: "listItem", nodeId: ""}, direction: "withNext"}) +``` + +### Split a list into two + +Use `split` to break one list into two independent lists at a specific item. The target and everything after become a new sequence that restarts numbering at 1: + +``` +superdoc_list({action: "split", target: {kind: "block", nodeType: "listItem", nodeId: ""}}) +``` + +Pass `restartNumbering: false` if you want the new half to keep counting from where the original left off. + +### Restart numbering at a specific item + +For ordered lists. To make item N restart from a chosen number (commonly 1): + +``` +superdoc_list({action: "set_value", target: {kind: "block", nodeType: "listItem", nodeId: ""}, value: 1}) +``` + +Pass `value: null` to clear a previously-set restart override and let the item resume natural numbering. + +### Continue numbering across a break + +For ordered lists. When two sibling sequences should be numbered as one (e.g. numbering jumps back to 1 and you want it to continue from where the previous list left off), target the FIRST item of the second sequence: + +``` +superdoc_list({action: "continue_previous", target: {kind: "block", nodeType: "listItem", nodeId: ""}}) +``` + +Fails with `NO_COMPATIBLE_PREVIOUS` or `INCOMPATIBLE_DEFINITIONS` if no prior sequence shares the same abstract definition. In that case, use `merge` instead — it handles mismatched definitions, removes empty gap paragraphs, and produces one continuous list. + ### Insert content into a document (new or existing) Markdown insert creates block structure but uses default formatting. You MUST follow up with formatting so inserted content looks like it belongs in the document. diff --git a/packages/sdk/tools/system-prompt.md b/packages/sdk/tools/system-prompt.md index 20b27de54f..95634bd889 100644 --- a/packages/sdk/tools/system-prompt.md +++ b/packages/sdk/tools/system-prompt.md @@ -164,6 +164,94 @@ Use preset "disc" for bullets, "decimal" for numbered. WARNING: the range conver 3. To change a bullet list to numbered: `superdoc_list({action: "set_type", target: {kind: "block", nodeType: "listItem", nodeId: ""}, kind: "ordered"})` +### Add items to an existing list + +To add a new item adjacent to an existing list item, use `superdoc_list({action: "insert"})`, NOT `superdoc_create({action: "paragraph"})` — the latter creates a standalone paragraph that is not part of the list: + +``` +superdoc_get_content({action: "blocks"}) // find the listItem nodeId you want to insert next to +superdoc_list({action: "insert", target: {kind: "block", nodeType: "listItem", nodeId: ""}, position: "after", text: "New item text"}) +``` + +**Level inheritance.** The new item inherits the target's nesting level. Insert after a level-0 item → new item is level 0. Insert after a level-2 item → new item is level 2. To change the level, chain `indent` / `outdent` / `set_level` on the nodeId returned in the insert response. + +**Use the nodeId from the response directly.** `superdoc_list({action: "insert"})` returns `{item: {nodeId: ""}}` — that id is ready for subsequent `indent`, `outdent`, `set_level`, or text edits. You do NOT need to re-fetch blocks between the insert and the follow-up operation. + +### Add a sub-point under an existing item + +Insert a peer, then indent it one level: + +``` +// 1. Insert a peer item after the parent — new item is at the parent's level +const resp = superdoc_list({action: "insert", target: {kind: "block", nodeType: "listItem", nodeId: ""}, position: "after", text: "Sub-point"}) + +// 2. Indent using the nodeId from resp.item.nodeId +superdoc_list({action: "indent", target: {kind: "block", nodeType: "listItem", nodeId: ""}}) +``` + +### Build a nested list with mixed levels + +`lists.create` produces a flat list. Add nesting by chaining `insert` + `indent` / `set_level`, using the nodeId returned by each insert to target the next step: + +``` +// Starting point: a list item at level 0 ("Parent" with nodeId ) + +// Sibling at level 0 +const r1 = superdoc_list({action: "insert", target: {kind: "block", nodeType: "listItem", nodeId: ""}, position: "after", text: "Sibling"}) + +// Child at level 1 (insert after r1, then indent) +const r2 = superdoc_list({action: "insert", target: {kind: "block", nodeType: "listItem", nodeId: ""}, position: "after", text: "Child"}) +superdoc_list({action: "indent", target: {kind: "block", nodeType: "listItem", nodeId: ""}}) + +// Grandchild at level 3 (insert after r2, then jump to level 3 directly) +const r3 = superdoc_list({action: "insert", target: {kind: "block", nodeType: "listItem", nodeId: ""}, position: "after", text: "Deep"}) +superdoc_list({action: "set_level", target: {kind: "block", nodeType: "listItem", nodeId: ""}, level: 3}) +``` + +`indent` bumps the level by one (bounded 0–8). `set_level` jumps directly to any level 0–8. Markers update automatically based on the list's definition for each level (e.g. `1.` / `a.` / `i.` for an ordered list). + +### Merge two adjacent lists into one + +Use `merge` — it handles the common case where two ordered or bulleted lists sit next to each other and should become one continuous list. Absorbed items adopt the absorbing sequence's definition, and any empty paragraphs between the two lists are removed so numbering flows continuously. + +``` +superdoc_get_content({action: "blocks"}) // find a listItem in either sequence +// To merge with the previous sequence: +superdoc_list({action: "merge", target: {kind: "block", nodeType: "listItem", nodeId: ""}, direction: "withPrevious"}) +// Or with the next sequence: +superdoc_list({action: "merge", target: {kind: "block", nodeType: "listItem", nodeId: ""}, direction: "withNext"}) +``` + +### Split a list into two + +Use `split` to break one list into two independent lists at a specific item. The target and everything after become a new sequence that restarts numbering at 1: + +``` +superdoc_list({action: "split", target: {kind: "block", nodeType: "listItem", nodeId: ""}}) +``` + +Pass `restartNumbering: false` if you want the new half to keep counting from where the original left off. + +### Restart numbering at a specific item + +For ordered lists. To make item N restart from a chosen number (commonly 1): + +``` +superdoc_list({action: "set_value", target: {kind: "block", nodeType: "listItem", nodeId: ""}, value: 1}) +``` + +Pass `value: null` to clear a previously-set restart override and let the item resume natural numbering. + +### Continue numbering across a break + +For ordered lists. When two sibling sequences should be numbered as one (e.g. numbering jumps back to 1 and you want it to continue from where the previous list left off), target the FIRST item of the second sequence: + +``` +superdoc_list({action: "continue_previous", target: {kind: "block", nodeType: "listItem", nodeId: ""}}) +``` + +Fails with `NO_COMPATIBLE_PREVIOUS` or `INCOMPATIBLE_DEFINITIONS` if no prior sequence shares the same abstract definition. In that case, use `merge` instead — it handles mismatched definitions, removes empty gap paragraphs, and produces one continuous list. + ### Insert content into a document (new or existing) Markdown insert creates block structure but uses default formatting. You MUST follow up with formatting so inserted content looks like it belongs in the document. diff --git a/packages/super-editor/src/editors/v1/core/commands/insertListItemAt.js b/packages/super-editor/src/editors/v1/core/commands/insertListItemAt.js index f6065177b8..6bcad5612b 100644 --- a/packages/super-editor/src/editors/v1/core/commands/insertListItemAt.js +++ b/packages/super-editor/src/editors/v1/core/commands/insertListItemAt.js @@ -7,11 +7,11 @@ import { buildTextWithTabs } from '../../document-api-adapters/helpers/text-with * This command preserves numbering metadata (numId/ilvl) from the target item, * and always leaves marker rendering to the numbering plugin. * - * @param {{ pos: number; position: 'before' | 'after'; text?: string; sdBlockId?: string; tracked?: boolean }} options + * @param {{ pos: number; position: 'before' | 'after'; text?: string; sdBlockId?: string; paraId?: string; tracked?: boolean }} options * @returns {import('./types/index.js').Command} */ export const insertListItemAt = - ({ pos, position, text = '', sdBlockId, tracked }) => + ({ pos, position, text = '', sdBlockId, paraId, tracked }) => ({ state, dispatch }) => { if (!Number.isInteger(pos) || pos < 0 || pos > state.doc.content.size) return false; if (position !== 'before' && position !== 'after') return false; @@ -35,7 +35,7 @@ export const insertListItemAt = const attrs = { ...(targetNode.attrs ?? {}), sdBlockId: sdBlockId ?? null, - paraId: null, + paraId: paraId ?? null, textId: null, listRendering: null, paragraphProperties: newParagraphProperties, diff --git a/packages/super-editor/src/editors/v1/core/commands/insertListItemAt.test.js b/packages/super-editor/src/editors/v1/core/commands/insertListItemAt.test.js index d5823a7ec9..13a8efe7ee 100644 --- a/packages/super-editor/src/editors/v1/core/commands/insertListItemAt.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/insertListItemAt.test.js @@ -154,6 +154,27 @@ describe('insertListItemAt', () => { expect(callArgs?.[0]).toMatchObject({ sdBlockId: 'custom-id' }); }); + it('passes paraId into the created node attrs (survives OOXML roundtrip via w14:paraId)', () => { + const { state, dispatch, paragraphType } = createMockState(); + + insertListItemAt({ pos: 0, position: 'after', paraId: 'A1B2C3D4' })({ + state, + dispatch, + }); + + const callArgs = paragraphType.createAndFill.mock.calls[0]; + expect(callArgs?.[0]).toMatchObject({ paraId: 'A1B2C3D4' }); + }); + + it('sets paraId to null when not provided (preserves existing insert behavior)', () => { + const { state, dispatch, paragraphType } = createMockState(); + + insertListItemAt({ pos: 0, position: 'after' })({ state, dispatch }); + + const callArgs = paragraphType.createAndFill.mock.calls[0]; + expect(callArgs?.[0]?.paraId).toBeNull(); + }); + it('preserves numbering properties from the target node', () => { const { state, dispatch, paragraphType } = createMockState(); diff --git a/packages/super-editor/src/editors/v1/core/helpers/numbering-consistency.js b/packages/super-editor/src/editors/v1/core/helpers/numbering-consistency.js new file mode 100644 index 0000000000..ea220d959c --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/helpers/numbering-consistency.js @@ -0,0 +1,85 @@ +/** + * Programmatic sanity checks for numbering abstract definitions. + * + * These checks catch OOXML-level correctness issues that the SuperDoc internal + * projection layer normalizes away — in particular, cases where a list level's + * `numFmt` disagrees with its `rFonts` (e.g. `decimal` numbering paired with + * `Wingdings`, which causes Word to render digits as pictographic glyphs). + * + * Designed to be cheap and dependency-free so any unit or integration test can + * use it to gate post-mutation state. + */ + +/** + * Word-known `numFmt` values that render numeric or alphabetic markers. + * When any of these is set on a level whose `rFonts` points at a symbol font, + * the marker character (e.g. "1", "a") is drawn through the symbol font and + * comes out as a pictograph instead of a readable digit/letter. + */ +export const ORDERED_NUM_FMTS = new Set([ + 'decimal', + 'decimalZero', + 'decimalEnclosedCircle', + 'decimalEnclosedFullstop', + 'decimalEnclosedParen', + 'lowerLetter', + 'upperLetter', + 'lowerRoman', + 'upperRoman', + 'ordinal', + 'ordinalText', + 'cardinalText', + 'chicago', +]); + +/** + * Fonts with no standard numeric/alphabetic glyphs at ASCII codepoints. + * Legitimate choice for bullet markers; never correct for ordered markers. + */ +export const SYMBOL_MARKER_FONTS = new Set([ + 'Wingdings', + 'Wingdings 2', + 'Wingdings 3', + 'Symbol', + 'Webdings', + 'ZapfDingbats', + 'Zapf Dingbats', +]); + +/** + * Walk an OOXML abstractNum element tree and flag any `` whose + * `numFmt` is in the ordered family and whose `rFonts` points at a symbol + * font. Returns the list of violations; empty means the abstract is clean. + * + * @param {object} abstractNum OOXML element shape: { name, attributes, elements: [...] }. + * @returns {Array<{ ilvl: number, numFmt: string, font: string }>} + */ +export function findSymbolFontsOnOrderedLevels(abstractNum) { + if (!abstractNum || !Array.isArray(abstractNum.elements)) return []; + + const violations = []; + for (const level of abstractNum.elements) { + if (!level || level.name !== 'w:lvl') continue; + + const ilvl = Number.parseInt(level.attributes?.['w:ilvl'] ?? '-1', 10); + const children = Array.isArray(level.elements) ? level.elements : []; + + const numFmtEl = children.find((c) => c?.name === 'w:numFmt'); + const numFmt = numFmtEl?.attributes?.['w:val']; + if (!numFmt || !ORDERED_NUM_FMTS.has(numFmt)) continue; + + const rPr = children.find((c) => c?.name === 'w:rPr'); + const rFonts = Array.isArray(rPr?.elements) ? rPr.elements.find((c) => c?.name === 'w:rFonts') : undefined; + const font = + rFonts?.attributes?.['w:ascii'] || + rFonts?.attributes?.['w:hAnsi'] || + rFonts?.attributes?.['w:cs'] || + rFonts?.attributes?.['w:eastAsia']; + if (!font) continue; + + if (SYMBOL_MARKER_FONTS.has(font)) { + violations.push({ ilvl, numFmt, font }); + } + } + return violations; +} diff --git a/packages/super-editor/src/editors/v1/core/helpers/numbering-consistency.test.js b/packages/super-editor/src/editors/v1/core/helpers/numbering-consistency.test.js new file mode 100644 index 0000000000..64493a0943 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/helpers/numbering-consistency.test.js @@ -0,0 +1,135 @@ +import { describe, it, expect } from 'vitest'; +import { findSymbolFontsOnOrderedLevels } from './numbering-consistency.js'; + +/** + * Build a minimal OOXML-shaped `` element for tests. + * Each entry in `levels` may set `numFmt` and (optionally) a `font`. + */ +function makeAbstractNum(levels) { + return { + name: 'w:abstractNum', + attributes: { 'w:abstractNumId': '0' }, + elements: levels.map((lvl, i) => ({ + name: 'w:lvl', + attributes: { 'w:ilvl': String(i) }, + elements: [ + { name: 'w:numFmt', attributes: { 'w:val': lvl.numFmt } }, + ...(lvl.font + ? [ + { + name: 'w:rPr', + elements: [ + { + name: 'w:rFonts', + attributes: { 'w:ascii': lvl.font, 'w:hAnsi': lvl.font }, + }, + ], + }, + ] + : []), + ], + })), + }; +} + +describe('findSymbolFontsOnOrderedLevels', () => { + it('returns [] for undefined / null / malformed input', () => { + expect(findSymbolFontsOnOrderedLevels(undefined)).toEqual([]); + expect(findSymbolFontsOnOrderedLevels(null)).toEqual([]); + expect(findSymbolFontsOnOrderedLevels({})).toEqual([]); + expect(findSymbolFontsOnOrderedLevels({ elements: [] })).toEqual([]); + expect(findSymbolFontsOnOrderedLevels({ elements: 'not-an-array' })).toEqual([]); + }); + + it('does NOT flag bullet levels that use symbol fonts (the normal case)', () => { + // Bullet lists legitimately render glyphs through Wingdings / Symbol — + // that is the entire point. Flagging these would produce false positives. + const abstract = makeAbstractNum([ + { numFmt: 'bullet', font: 'Courier New' }, + { numFmt: 'bullet', font: 'Wingdings' }, + { numFmt: 'bullet', font: 'Symbol' }, + { numFmt: 'bullet', font: 'Webdings' }, + { numFmt: 'bullet', font: 'Zapf Dingbats' }, + ]); + expect(findSymbolFontsOnOrderedLevels(abstract)).toEqual([]); + }); + + it('does NOT flag ordered levels with safe fonts', () => { + const abstract = makeAbstractNum([ + { numFmt: 'decimal', font: 'Courier New' }, + { numFmt: 'lowerLetter', font: 'Arial' }, + { numFmt: 'lowerRoman', font: 'Times New Roman' }, + { numFmt: 'upperRoman', font: 'Calibri' }, + ]); + expect(findSymbolFontsOnOrderedLevels(abstract)).toEqual([]); + }); + + it('does NOT flag ordered levels that have no rFonts override (body font fallback)', () => { + const abstract = makeAbstractNum([{ numFmt: 'decimal' }, { numFmt: 'lowerLetter' }, { numFmt: 'lowerRoman' }]); + expect(findSymbolFontsOnOrderedLevels(abstract)).toEqual([]); + }); + + it('flags ordered numFmt paired with Wingdings / Symbol (the core bug signature)', () => { + const abstract = makeAbstractNum([ + { numFmt: 'decimal', font: 'Courier New' }, // L0 clean + { numFmt: 'decimal', font: 'Courier New' }, // L1 clean + { numFmt: 'decimal', font: 'Wingdings' }, // L2 violation + { numFmt: 'decimal', font: 'Symbol' }, // L3 violation + ]); + expect(findSymbolFontsOnOrderedLevels(abstract)).toEqual([ + { ilvl: 2, numFmt: 'decimal', font: 'Wingdings' }, + { ilvl: 3, numFmt: 'decimal', font: 'Symbol' }, + ]); + }); + + it('flags every ordered numFmt variant when paired with any symbol font', () => { + const abstract = makeAbstractNum([ + { numFmt: 'lowerLetter', font: 'Wingdings' }, + { numFmt: 'upperLetter', font: 'Wingdings 2' }, + { numFmt: 'lowerRoman', font: 'Webdings' }, + { numFmt: 'upperRoman', font: 'Zapf Dingbats' }, + { numFmt: 'decimalZero', font: 'ZapfDingbats' }, + ]); + const result = findSymbolFontsOnOrderedLevels(abstract); + expect(result).toHaveLength(5); + expect(result.map((v) => v.numFmt)).toEqual([ + 'lowerLetter', + 'upperLetter', + 'lowerRoman', + 'upperRoman', + 'decimalZero', + ]); + }); + + it('ignores unknown numFmt values', () => { + // `chicago` is in the set; arbitrary strings are not. + const abstract = makeAbstractNum([ + { numFmt: 'chicago', font: 'Wingdings' }, + { numFmt: 'some-unknown-format', font: 'Wingdings' }, + ]); + const result = findSymbolFontsOnOrderedLevels(abstract); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ ilvl: 0, numFmt: 'chicago' }); + }); + + it('falls back to hAnsi / cs / eastAsia when ascii is absent', () => { + const abstract = { + name: 'w:abstractNum', + attributes: { 'w:abstractNumId': '0' }, + elements: [ + { + name: 'w:lvl', + attributes: { 'w:ilvl': '0' }, + elements: [ + { name: 'w:numFmt', attributes: { 'w:val': 'decimal' } }, + { + name: 'w:rPr', + elements: [{ name: 'w:rFonts', attributes: { 'w:hAnsi': 'Wingdings' } }], + }, + ], + }, + ], + }; + expect(findSymbolFontsOnOrderedLevels(abstract)).toEqual([{ ilvl: 0, numFmt: 'decimal', font: 'Wingdings' }]); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts index d4b3c91f06..e7240e8cb4 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts @@ -152,6 +152,8 @@ import { listsDetachWrapper, listsJoinWrapper, listsSeparateWrapper, + listsMergeWrapper, + listsSplitWrapper, listsSetLevelWrapper, listsSetValueWrapper, listsContinuePreviousWrapper, @@ -4987,6 +4989,110 @@ const mutationVectors: Partial> = { return result; }, }, + 'lists.merge': { + throwCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsMergeWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, direction: 'withNext' }, + { changeMode: 'tracked' }, + ); + }, + failureCase: () => { + const adjacentSpy = vi.spyOn(listSequenceHelpers, 'findAdjacentSequence').mockReturnValue(null); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsMergeWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + direction: 'withNext', + }); + adjacentSpy.mockRestore(); + return result; + }, + applyCase: () => { + const adjacentSpy = vi.spyOn(listSequenceHelpers, 'findAdjacentSequence').mockReturnValue({ + numId: 2, + sequence: [ + { + address: { kind: 'block', nodeType: 'listItem', nodeId: 'li-2' }, + candidate: { + nodeId: 'li-2', + nodeType: 'listItem', + pos: 4, + end: 8, + node: { + attrs: { paragraphProperties: { numberingProperties: { numId: 2, ilvl: 0 } } }, + nodeSize: 4, + } as any, + }, + numId: 2, + level: 0, + } as any, + ], + }); + const sequenceSpy = vi.spyOn(listSequenceHelpers, 'getContiguousSequence').mockReturnValue([ + { + address: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + candidate: { + nodeId: 'li-1', + nodeType: 'listItem', + pos: 0, + end: 4, + node: { + attrs: { paragraphProperties: { numberingProperties: { numId: 1, ilvl: 0 } } }, + nodeSize: 4, + } as any, + }, + numId: 1, + level: 0, + } as any, + ]); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsMergeWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + direction: 'withNext', + }); + adjacentSpy.mockRestore(); + sequenceSpy.mockRestore(); + return result; + }, + }, + 'lists.split': { + throwCase: () => { + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + return listsSplitWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, + { changeMode: 'tracked' }, + ); + }, + failureCase: () => { + const firstInSeqSpy = vi.spyOn(listSequenceHelpers, 'isFirstInSequence').mockReturnValue(true); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsSplitWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + }); + firstInSeqSpy.mockRestore(); + return result; + }, + applyCase: () => { + const firstInSeqSpy = vi.spyOn(listSequenceHelpers, 'isFirstInSequence').mockReturnValue(false); + const abstractSpy = vi.spyOn(listSequenceHelpers, 'getAbstractNumId').mockReturnValue(1); + const seqSpy = vi.spyOn(listSequenceHelpers, 'getSequenceFromTarget').mockReturnValue([]); + const createNumSpy = vi + .spyOn(ListHelpers, 'createNumDefinition') + .mockReturnValue({ numId: 99, numDef: {} } as any); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsSplitWrapper(editor, { + target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + restartNumbering: false, // skip the second mutation in the conformance harness + }); + firstInSeqSpy.mockRestore(); + abstractSpy.mockRestore(); + seqSpy.mockRestore(); + createNumSpy.mockRestore(); + return result; + }, + }, 'lists.setLevel': { throwCase: () => { const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); @@ -8965,6 +9071,66 @@ const dryRunVectors: Partial unknown>> = { seqSpy.mockRestore(); return result; }, + 'lists.merge': () => { + const adjacentSpy = vi.spyOn(listSequenceHelpers, 'findAdjacentSequence').mockReturnValue({ + numId: 2, + sequence: [ + { + address: { kind: 'block', nodeType: 'listItem', nodeId: 'li-2' }, + candidate: { + nodeId: 'li-2', + nodeType: 'listItem', + pos: 4, + end: 8, + node: { + attrs: { paragraphProperties: { numberingProperties: { numId: 2, ilvl: 0 } } }, + nodeSize: 4, + } as any, + }, + numId: 2, + level: 0, + } as any, + ], + }); + const sequenceSpy = vi.spyOn(listSequenceHelpers, 'getContiguousSequence').mockReturnValue([ + { + address: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, + candidate: { + nodeId: 'li-1', + nodeType: 'listItem', + pos: 0, + end: 4, + node: { attrs: { paragraphProperties: { numberingProperties: { numId: 1, ilvl: 0 } } }, nodeSize: 4 } as any, + }, + numId: 1, + level: 0, + } as any, + ]); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsMergeWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, direction: 'withNext' }, + { changeMode: 'direct', dryRun: true }, + ); + adjacentSpy.mockRestore(); + sequenceSpy.mockRestore(); + return result; + }, + 'lists.split': () => { + const firstInSeqSpy = vi.spyOn(listSequenceHelpers, 'isFirstInSequence').mockReturnValue(false); + const abstractSpy = vi.spyOn(listSequenceHelpers, 'getAbstractNumId').mockReturnValue(1); + const seqSpy = vi.spyOn(listSequenceHelpers, 'getSequenceFromTarget').mockReturnValue([]); + const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); + const result = listsSplitWrapper( + editor, + { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, + { changeMode: 'direct', dryRun: true }, + ); + firstInSeqSpy.mockRestore(); + abstractSpy.mockRestore(); + seqSpy.mockRestore(); + return result; + }, 'lists.setLevel': () => { const hasDefinitionSpy = vi.spyOn(ListHelpers, 'hasListDefinition').mockReturnValue(true); const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/assemble-adapters.ts b/packages/super-editor/src/editors/v1/document-api-adapters/assemble-adapters.ts index 1143316d91..c08bf28159 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/assemble-adapters.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/assemble-adapters.ts @@ -64,6 +64,8 @@ import { listsJoinWrapper, listsCanJoinWrapper, listsSeparateWrapper, + listsMergeWrapper, + listsSplitWrapper, listsSetLevelWrapper, listsSetValueWrapper, listsContinuePreviousWrapper, @@ -464,6 +466,8 @@ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters join: (input, options) => listsJoinWrapper(editor, input, options), canJoin: (input) => listsCanJoinWrapper(editor, input), separate: (input, options) => listsSeparateWrapper(editor, input, options), + merge: (input, options) => listsMergeWrapper(editor, input, options), + split: (input, options) => listsSplitWrapper(editor, input, options), setLevel: (input, options) => listsSetLevelWrapper(editor, input, options), setValue: (input, options) => listsSetValueWrapper(editor, input, options), continuePrevious: (input, options) => listsContinuePreviousWrapper(editor, input, options), diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/lists-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/lists-wrappers.test.ts index a2c51f978b..cb3ac974a5 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/lists-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/lists-wrappers.test.ts @@ -108,8 +108,13 @@ import { listsConvertToTextWrapper, listsIndentWrapper, listsOutdentWrapper, + listsInsertWrapper, + listsMergeWrapper, + listsSplitWrapper, } from './lists-wrappers.js'; +import { getBlockIndex } from '../helpers/index-cache.js'; + import { listListItems, resolveListItem } from '../helpers/list-item-resolver.js'; import { resolveBlock, @@ -364,6 +369,63 @@ describe('lists-wrappers', () => { }); }); + // ========================================================================= + // listsInsertWrapper + // ========================================================================= + + describe('listsInsertWrapper', () => { + it('passes both sdBlockId and paraId to insertListItemAt (paraId survives OOXML roundtrip)', () => { + const target = makeProjection({ numId: 1, level: 0 }); + vi.mocked(resolveListItem).mockReturnValueOnce(target); + vi.mocked(getBlockIndex).mockReturnValueOnce({ candidates: [], byId: new Map(), ambiguous: new Set() } as any); + + const insertCmd = editor.commands!.insertListItemAt as ReturnType; + listsInsertWrapper(editor, { target: target.address, position: 'after', text: 'new item' }); + + expect(insertCmd).toHaveBeenCalledTimes(1); + const args = insertCmd.mock.calls[0]![0] as { sdBlockId: unknown; paraId: unknown }; + expect(typeof args.sdBlockId).toBe('string'); + expect(typeof args.paraId).toBe('string'); + // paraId is derived as `uuid.replace(/-/g, '').slice(0, 8).toUpperCase()`, + // so it must be 8 chars, uppercase, and hyphen-free regardless of the uuid shape. + expect((args.paraId as string).length).toBe(8); + expect(args.paraId).toBe((args.paraId as string).toUpperCase()); + expect(args.paraId).not.toContain('-'); + }); + + it('returns a short docx-style paraId in the receipt nodeId (not a UUID)', () => { + const target = makeProjection({ numId: 1, level: 0 }); + vi.mocked(resolveListItem).mockReturnValueOnce(target); + // Force the resolver-by-sdBlockId path to miss so the wrapper falls back + // to returning the generated paraId directly in the receipt. + vi.mocked(getBlockIndex).mockReturnValueOnce({ candidates: [], byId: new Map(), ambiguous: new Set() } as any); + + const result = listsInsertWrapper(editor, { target: target.address, position: 'after', text: 'new' }); + if (!result.success) throw new Error('expected success'); + + // Receipt nodeId must be the 8-char paraId, not a UUID — the UUID + // sdBlockId does not survive OOXML export/import. + expect(result.item.nodeId.length).toBe(8); + expect(result.item.nodeId).not.toContain('-'); + expect(result.insertionPoint.blockId).toBe(result.item.nodeId); + }); + + it('returns dry-run placeholder and does not call insertListItemAt when dryRun is set', () => { + const target = makeProjection({ numId: 1, level: 0 }); + vi.mocked(resolveListItem).mockReturnValueOnce(target); + + const result = listsInsertWrapper( + editor, + { target: target.address, position: 'before', text: 'dry' }, + { dryRun: true }, + ); + if (!result.success) throw new Error('expected success'); + + expect(result.item.nodeId).toBe('(dry-run)'); + expect(editor.commands!.insertListItemAt).not.toHaveBeenCalled(); + }); + }); + // ========================================================================= // listsAttachWrapper // ========================================================================= @@ -561,6 +623,212 @@ describe('lists-wrappers', () => { }); }); + // ========================================================================= + // listsMergeWrapper + // ========================================================================= + + describe('listsMergeWrapper', () => { + it('merges with previous sequence — skips the strict abstractNumId check (vs join)', () => { + // Target numId=2 with abstract=20; adjacent numId=1 with abstract=10 — DIFFERENT abstracts. + // `lists.join` would refuse this with INCOMPATIBLE_DEFINITIONS; `lists.merge` must succeed. + const target = makeProjection({ + numId: 2, + address: { kind: 'block', nodeType: 'listItem', nodeId: 'target' }, + }); + const adjAnchor = makeProjection({ + numId: 1, + address: { kind: 'block', nodeType: 'listItem', nodeId: 'adj-first' }, + }); + vi.mocked(resolveListItem).mockReturnValueOnce(target); + vi.mocked(findAdjacentSequence).mockReturnValueOnce({ + sequence: [adjAnchor], + numId: 1, + abstractNumId: 10, + } as any); + vi.mocked(getContiguousSequence).mockReturnValueOnce([target]); + vi.mocked(getBlockIndex).mockReturnValueOnce({ candidates: [], byId: new Map(), ambiguous: new Set() } as any); + + const result = listsMergeWrapper(editor, { target: target.address, direction: 'withPrevious' }); + expect(result.success).toBe(true); + expect((result as any).listId).toBe('1:adj-first'); + expect((result as any).absorbedCount).toBe(1); + expect((result as any).removedEmptyBlocks).toBe(0); + }); + + it('merges with next sequence — target absorbs adjacent', () => { + const target = makeProjection({ + numId: 1, + address: { kind: 'block', nodeType: 'listItem', nodeId: 'target' }, + }); + const targetAnchor = makeProjection({ + numId: 1, + address: { kind: 'block', nodeType: 'listItem', nodeId: 'target-first' }, + }); + const adjItem1 = makeProjection({ + numId: 2, + address: { kind: 'block', nodeType: 'listItem', nodeId: 'adj-1' }, + }); + const adjItem2 = makeProjection({ + numId: 2, + address: { kind: 'block', nodeType: 'listItem', nodeId: 'adj-2' }, + }); + vi.mocked(resolveListItem).mockReturnValueOnce(target); + vi.mocked(findAdjacentSequence).mockReturnValueOnce({ + sequence: [adjItem1, adjItem2], + numId: 2, + abstractNumId: 20, + } as any); + vi.mocked(getContiguousSequence).mockReturnValueOnce([targetAnchor, target]); + vi.mocked(getBlockIndex).mockReturnValueOnce({ candidates: [], byId: new Map(), ambiguous: new Set() } as any); + + const result = listsMergeWrapper(editor, { target: target.address, direction: 'withNext' }); + expect(result.success).toBe(true); + expect((result as any).listId).toBe('1:target-first'); + expect((result as any).absorbedCount).toBe(2); // both adj items absorbed + }); + + it('returns NO_ADJACENT_SEQUENCE when no adjacent list exists in the given direction', () => { + const target = makeProjection({ numId: 1 }); + vi.mocked(resolveListItem).mockReturnValueOnce(target); + vi.mocked(findAdjacentSequence).mockReturnValueOnce(null); + + const result = listsMergeWrapper(editor, { target: target.address, direction: 'withPrevious' }); + expect(result.success).toBe(false); + expect((result as any).failure.code).toBe('NO_ADJACENT_SEQUENCE'); + }); + + it('returns NO_OP when target and adjacent already share the same numId', () => { + const target = makeProjection({ + numId: 5, + address: { kind: 'block', nodeType: 'listItem', nodeId: 'target' }, + }); + const adj = makeProjection({ + numId: 5, // same numId — already the same sequence + address: { kind: 'block', nodeType: 'listItem', nodeId: 'adj' }, + }); + vi.mocked(resolveListItem).mockReturnValueOnce(target); + vi.mocked(findAdjacentSequence).mockReturnValueOnce({ + sequence: [adj], + numId: 5, + abstractNumId: 50, + } as any); + vi.mocked(getContiguousSequence).mockReturnValueOnce([target]); + + const result = listsMergeWrapper(editor, { target: target.address, direction: 'withPrevious' }); + expect(result.success).toBe(false); + expect((result as any).failure.code).toBe('NO_OP'); + }); + + it('returns INVALID_TARGET when target has no numId', () => { + const target = makeProjection({ numId: undefined as any }); + vi.mocked(resolveListItem).mockReturnValueOnce(target); + + const result = listsMergeWrapper(editor, { target: target.address, direction: 'withPrevious' }); + expect(result.success).toBe(false); + expect((result as any).failure.code).toBe('INVALID_TARGET'); + }); + + it('returns dry-run placeholder without dispatching the transaction', () => { + const target = makeProjection({ + numId: 2, + address: { kind: 'block', nodeType: 'listItem', nodeId: 'target' }, + }); + const adjAnchor = makeProjection({ + numId: 1, + address: { kind: 'block', nodeType: 'listItem', nodeId: 'adj-first' }, + }); + vi.mocked(resolveListItem).mockReturnValueOnce(target); + vi.mocked(findAdjacentSequence).mockReturnValueOnce({ + sequence: [adjAnchor], + numId: 1, + abstractNumId: 10, + } as any); + vi.mocked(getContiguousSequence).mockReturnValueOnce([target]); + vi.mocked(getBlockIndex).mockReturnValueOnce({ candidates: [], byId: new Map(), ambiguous: new Set() } as any); + + const result = listsMergeWrapper(editor, { target: target.address, direction: 'withPrevious' }, { dryRun: true }); + expect(result.success).toBe(true); + expect((result as any).listId).toBe('1:adj-first'); + expect(editor.view!.dispatch).not.toHaveBeenCalled(); + }); + + it('rejects tracked mode', () => { + const proj = makeProjection(); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + listsMergeWrapper(editor, { target: proj.address, direction: 'withPrevious' }, { changeMode: 'tracked' }); + expect(rejectTrackedMode).toHaveBeenCalledWith('lists.merge', { changeMode: 'tracked' }); + }); + }); + + // ========================================================================= + // listsSplitWrapper + // ========================================================================= + + describe('listsSplitWrapper', () => { + function setupSeparateSucceeds() { + const proj = makeProjection({ + numId: 1, + address: { kind: 'block', nodeType: 'listItem', nodeId: 'target' }, + }); + vi.mocked(resolveListItem).mockReturnValue(proj); + vi.mocked(isFirstInSequence).mockReturnValue(false); + vi.mocked(getAbstractNumId).mockReturnValue(10); + vi.mocked(getSequenceFromTarget).mockReturnValue([proj]); + return proj; + } + + it('separates then restarts numbering at 1 by default', () => { + const proj = setupSeparateSucceeds(); + + const result = listsSplitWrapper(editor, { target: proj.address }); + expect(result.success).toBe(true); + expect((result as any).numId).toBe(43); // from ListHelpers.createNumDefinition mock + expect((result as any).restartedAt).toBe(1); + }); + + it('restartNumbering:false skips the setValue step (raw separate semantics)', () => { + const proj = setupSeparateSucceeds(); + + const result = listsSplitWrapper(editor, { target: proj.address, restartNumbering: false }); + expect(result.success).toBe(true); + expect((result as any).restartedAt).toBeNull(); + }); + + it('propagates NO_OP when separate refuses (target is first in its sequence)', () => { + const proj = makeProjection(); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + vi.mocked(isFirstInSequence).mockReturnValueOnce(true); + + const result = listsSplitWrapper(editor, { target: proj.address }); + expect(result.success).toBe(false); + expect((result as any).failure.code).toBe('NO_OP'); + }); + + it('returns dry-run placeholder with restartedAt:1 by default', () => { + const proj = setupSeparateSucceeds(); + + const result = listsSplitWrapper(editor, { target: proj.address }, { dryRun: true }); + expect(result.success).toBe(true); + expect((result as any).listId).toBe('(dry-run)'); + expect((result as any).restartedAt).toBe(1); + }); + + it('dry-run with restartNumbering:false returns restartedAt:null', () => { + const proj = setupSeparateSucceeds(); + + const result = listsSplitWrapper(editor, { target: proj.address, restartNumbering: false }, { dryRun: true }); + expect(result.success).toBe(true); + expect((result as any).restartedAt).toBeNull(); + }); + + it('rejects tracked mode', () => { + const proj = makeProjection(); + vi.mocked(resolveListItem).mockReturnValueOnce(proj); + listsSplitWrapper(editor, { target: proj.address }, { changeMode: 'tracked' }); + expect(rejectTrackedMode).toHaveBeenCalledWith('lists.split', { changeMode: 'tracked' }); + }); + }); + // ========================================================================= // listsSetLevelWrapper // ========================================================================= diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/lists-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/lists-wrappers.ts index 79b5923e68..c646325e7c 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/lists-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/lists-wrappers.ts @@ -29,6 +29,10 @@ import type { ListsCanJoinResult, ListsSeparateInput, ListsSeparateResult, + ListsMergeInput, + ListsMergeResult, + ListsSplitInput, + ListsSplitResult, ListsSetLevelInput, ListsSetValueInput, ListsContinuePreviousInput, @@ -85,6 +89,7 @@ type InsertListItemAtCommand = (options: { position: 'before' | 'after'; text?: string; sdBlockId?: string; + paraId?: string; tracked?: boolean; }) => boolean; @@ -172,6 +177,13 @@ function resolveInsertedListItem(editor: Editor, sdBlockId: string): ListItemPro ); } +// paraId survives OOXML roundtrips (written as w14:paraId on export); sdBlockId +// does not. Generate an 8-char hex paraId alongside sdBlockId so newly-inserted +// items have a stable public identity that persists across save/reload cycles. +function generateRuntimeParaId(): string { + return uuidv4().replace(/-/g, '').slice(0, 8).toUpperCase(); +} + function withListTarget(editor: Editor, input: ListTargetInput): ListItemProjection { return resolveListItem(editor, input.target); } @@ -324,6 +336,7 @@ export function listsInsertWrapper( } const createdId = uuidv4(); + const createdParaId = generateRuntimeParaId(); let created: ListItemProjection | null = null; const receipt = executeDomainCommand( @@ -334,6 +347,7 @@ export function listsInsertWrapper( position: input.position, text: input.text ?? '', sdBlockId: createdId, + paraId: createdParaId, tracked: mode === 'tracked', }); if (didApply) { @@ -360,12 +374,13 @@ export function listsInsertWrapper( const resolved = created as ListItemProjection | null; if (!resolved) { + // paraId (not sdBlockId) survives OOXML roundtrips, so the caller can reuse it. return { success: true, - item: { kind: 'block', nodeType: 'listItem', nodeId: createdId }, + item: { kind: 'block', nodeType: 'listItem', nodeId: createdParaId }, insertionPoint: { kind: 'text', - blockId: createdId, + blockId: createdParaId, range: { start: 0, end: 0 }, }, }; @@ -887,6 +902,183 @@ export function listsSeparateWrapper( return { success: true, listId: `${newNumId!}:${target.address.nodeId}`, numId: newNumId! }; } +/** + * Compound merge: structurally merge two adjacent list sequences into one. + * + * Unlike lists.join, merge does NOT require identical abstractNumId — absorbed + * items adopt the absorbing sequence's numbering definition. Additionally, + * empty paragraphs between the two sequences are removed so numbering flows + * continuously. + */ +export function listsMergeWrapper(editor: Editor, input: ListsMergeInput, options?: MutationOptions): ListsMergeResult { + rejectTrackedMode('lists.merge', options); + + const target = resolveListItem(editor, input.target); + if (target.numId == null) { + return toListsFailure('INVALID_TARGET', 'Target must have numbering metadata.', { target: input.target }); + } + + const adjacent = findAdjacentSequence(editor, target, input.direction); + if (!adjacent) { + return toListsFailure('NO_ADJACENT_SEQUENCE', 'No adjacent list sequence found in the given direction.', { + target: input.target, + direction: input.direction, + }); + } + + const targetSequence = getContiguousSequence(editor, target); + if (adjacent.numId === target.numId) { + return toListsFailure('NO_OP', 'Target and adjacent items already belong to the same sequence.', { + target: input.target, + }); + } + + let absorbingNumId: number; + let absorbedItems: ListItemProjection[]; + let anchorNodeId: string; + let gapFromPos: number; + let gapToPos: number; + + if (input.direction === 'withPrevious') { + absorbingNumId = adjacent.numId; + absorbedItems = targetSequence; + anchorNodeId = adjacent.sequence[0]?.address.nodeId ?? target.address.nodeId; + const lastOfAdjacent = adjacent.sequence[adjacent.sequence.length - 1]!; + const firstOfTarget = targetSequence[0]!; + gapFromPos = lastOfAdjacent.candidate.pos + lastOfAdjacent.candidate.node.nodeSize; + gapToPos = firstOfTarget.candidate.pos; + } else { + absorbingNumId = target.numId; + absorbedItems = adjacent.sequence; + anchorNodeId = targetSequence[0]?.address.nodeId ?? target.address.nodeId; + const lastOfTarget = targetSequence[targetSequence.length - 1]!; + const firstOfAdjacent = adjacent.sequence[0]!; + gapFromPos = lastOfTarget.candidate.pos + lastOfTarget.candidate.node.nodeSize; + gapToPos = firstOfAdjacent.candidate.pos; + } + + // Top-level only (avoid empty paragraphs inside table cells), and require + // structural emptiness (a paragraph holding an image/break has empty + // textContent but is still meaningful). + const gapEmptyParagraphs: Array<{ pos: number; node: (typeof targetSequence)[0]['candidate']['node'] }> = []; + if (gapFromPos < gapToPos) { + editor.state.doc.forEach((child, offset) => { + if (child.type.name !== 'paragraph') return; + if (offset < gapFromPos) return; + if (offset + child.nodeSize > gapToPos) return; + if (child.childCount > 0) return; + gapEmptyParagraphs.push({ pos: offset, node: child }); + }); + } + + const mergedListId = `${absorbingNumId}:${anchorNodeId}`; + + if (options?.dryRun) { + return { + success: true, + listId: mergedListId, + absorbedCount: absorbedItems.length, + removedEmptyBlocks: gapEmptyParagraphs.length, + }; + } + + const receipt = executeDomainCommand( + editor, + () => { + const { tr } = editor.state; + for (const item of absorbedItems) { + const currentLevel = item.level ?? 0; + updateNumberingProperties( + { numId: absorbingNumId, ilvl: currentLevel }, + item.candidate.node, + item.candidate.pos, + editor, + tr, + ); + } + // Delete empty gap paragraphs in descending position order so earlier + // deletions do not shift subsequent positions. + const sorted = [...gapEmptyParagraphs].sort((a, b) => b.pos - a.pos); + for (const gap of sorted) { + tr.delete(gap.pos, gap.pos + gap.node.nodeSize); + } + dispatchEditorTransaction(editor, tr); + clearIndexCache(editor); + return true; + }, + { expectedRevision: options?.expectedRevision }, + ); + + if (receipt.steps[0]?.effect !== 'changed') { + return toListsFailure('INVALID_TARGET', 'List merge could not be applied.', { + target: input.target, + direction: input.direction, + }); + } + + return { + success: true, + listId: mergedListId, + absorbedCount: absorbedItems.length, + removedEmptyBlocks: gapEmptyParagraphs.length, + }; +} + +/** + * Compound split: separate a list sequence at the target and restart the new + * half's numbering at 1 (by default). + * + * Runs as two sequential steps (separate, then setValue). If the second step + * fails after the first succeeds, the doc is left split without the renumber + * and the caller gets a failure result. Pass restartNumbering: false to skip + * the second step and get raw separate semantics. + */ +export function listsSplitWrapper(editor: Editor, input: ListsSplitInput, options?: MutationOptions): ListsSplitResult { + rejectTrackedMode('lists.split', options); + + const separateResult = listsSeparateWrapper(editor, { target: input.target }, options); + if (!separateResult.success) { + // Failure shape (ListsFailureResult) is shared between Separate and Split, + // but TS can't infer that from the union narrowing alone — cast through. + return separateResult as ListsSplitResult; + } + + const restartNumbering = input.restartNumbering !== false; + if (!restartNumbering) { + return { + success: true, + listId: separateResult.listId, + numId: separateResult.numId, + restartedAt: null, + }; + } + + if (options?.dryRun) { + return { + success: true, + listId: separateResult.listId, + numId: separateResult.numId, + restartedAt: 1, + }; + } + + // The separate step above bumped the revision; reusing the caller's + // expectedRevision here would throw REVISION_MISMATCH and leave the doc + // partially-applied. + const setValueOptions = options ? { ...options, expectedRevision: undefined } : options; + const setValueResult = listsSetValueWrapper(editor, { target: input.target, value: 1 }, setValueOptions); + if (!setValueResult.success) { + return setValueResult as ListsSplitResult; + } + + return { + success: true, + listId: separateResult.listId, + numId: separateResult.numId, + restartedAt: 1, + }; +} + export function listsSetLevelWrapper( editor: Editor, input: ListsSetLevelInput, diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/query-match-adapter.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/query-match-adapter.ts index 82c9ed055e..8dec1af097 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/query-match-adapter.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/query-match-adapter.ts @@ -196,9 +196,29 @@ function buildMatchBlocks( const sorted = [...ranges].sort((a, b) => a.range.start - b.range.start); for (let i = 0; i < sorted.length - 1; i++) { if (sorted[i].range.end < sorted[i + 1].range.start) { + const gapStart = sorted[i].range.end; + const gapEnd = sorted[i + 1].range.start; + const coveringStart = Math.min(...sorted.map((r) => r.range.start)); + const coveringEnd = Math.max(...sorted.map((r) => r.range.end)); throw planError( 'INVALID_INPUT', - `discontiguous text ranges in block ${blockId}: gap between ${sorted[i].range.end} and ${sorted[i + 1].range.start}`, + `discontiguous text ranges in block ${blockId}: gap between ${gapStart} and ${gapEnd}. ` + + `Two or more edits target the same block with untouched text between them and cannot be coalesced safely. ` + + `Fix by: (a) splitting the edits across separate superdoc_mutations batches — preferred, works in both direct and tracked change modes; ` + + `or (b) combining the edits into a single text.rewrite covering offsets ${coveringStart}..${coveringEnd} — direct mode only, since in tracked mode the untouched middle would be recorded as deleted+reinserted.`, + undefined, + { + blockId, + gap: { start: gapStart, end: gapEnd }, + coveringRange: { start: coveringStart, end: coveringEnd }, + rangeCount: sorted.length, + ranges: sorted.map((r) => ({ start: r.range.start, end: r.range.end })), + remediation: { + preferred: 'split-batches', + optionA: 'Split into separate superdoc_mutations batches (works in direct and tracked modes).', + optionB: `Combine into one text.rewrite covering offsets ${coveringStart}..${coveringEnd} (direct mode only — pollutes tracked history).`, + }, + }, ); } }