diff --git a/apps/cli/scripts/export-sdk-contract.ts b/apps/cli/scripts/export-sdk-contract.ts index 664c3122bc..d2c2f2541d 100644 --- a/apps/cli/scripts/export-sdk-contract.ts +++ b/apps/cli/scripts/export-sdk-contract.ts @@ -129,6 +129,11 @@ const INTENT_NAMES = { 'doc.toc.configure': 'configure_table_of_contents', 'doc.toc.update': 'update_table_of_contents', 'doc.toc.remove': 'remove_table_of_contents', + 'doc.toc.markEntry': 'mark_table_of_contents_entry', + 'doc.toc.unmarkEntry': 'unmark_table_of_contents_entry', + 'doc.toc.listEntries': 'list_table_of_contents_entries', + 'doc.toc.getEntry': 'get_table_of_contents_entry', + 'doc.toc.editEntry': 'edit_table_of_contents_entry', 'doc.query.match': 'query_match', 'doc.mutations.preview': 'preview_mutations', 'doc.mutations.apply': 'apply_mutations', diff --git a/apps/cli/src/__tests__/conformance/scenarios.ts b/apps/cli/src/__tests__/conformance/scenarios.ts index f3a2291e79..70dd70da2a 100644 --- a/apps/cli/src/__tests__/conformance/scenarios.ts +++ b/apps/cli/src/__tests__/conformance/scenarios.ts @@ -14,6 +14,7 @@ export type OperationScenario = { success: (harness: ConformanceHarness) => Promise; failure: (harness: ConformanceHarness) => Promise; expectedFailureCodes: string[]; + skipRuntimeConformance?: boolean; }; function commandTokens(operationId: CliOperationId): string[] { @@ -421,6 +422,73 @@ function tocReadWithTargetScenario(op: string): (harness: ConformanceHarness) => }; } +type TocEntryAddress = { + kind: 'inline'; + nodeType: 'tableOfContentsEntry'; + nodeId: string; +}; + +function buildTocEntryInsertionTarget(paragraphNodeId: string): Record { + return { + kind: 'inline-insert', + anchor: { + nodeType: 'paragraph', + nodeId: paragraphNodeId, + }, + position: 'end', + }; +} + +async function createDocWithMarkedTocEntry( + harness: ConformanceHarness, + stateDir: string, + label: string, +): Promise<{ docPath: string; entryAddress: TocEntryAddress }> { + const sourceDoc = await harness.copyFixtureDoc(`${label}-source`); + const textTarget = await harness.firstTextRange(sourceDoc, stateDir); + const markedDoc = harness.createOutputPath(`${label}-marked`); + + const mark = await harness.runCli( + [ + ...commandTokens('doc.toc.markEntry'), + sourceDoc, + '--target-json', + JSON.stringify(buildTocEntryInsertionTarget(textTarget.blockId)), + '--text', + 'Conformance TC Entry', + '--level', + '2', + '--out', + markedDoc, + ], + stateDir, + ); + if (mark.result.code !== 0 || mark.envelope.ok !== true) { + throw new Error(`Failed to seed toc entry fixture for ${label}.`); + } + + const listed = await harness.runCli([...commandTokens('doc.toc.listEntries'), markedDoc, '--limit', '1'], stateDir); + if (listed.result.code !== 0 || listed.envelope.ok !== true) { + throw new Error(`Failed to list toc entries for ${label}.`); + } + + const entryAddress = ( + listed.envelope.data as { + result?: { + items?: Array<{ + address?: TocEntryAddress; + }>; + }; + } + ).result?.items?.[0]?.address; + + if (!entryAddress) { + throw new Error(`No toc entry address found for ${label}.`); + } + + return { docPath: markedDoc, entryAddress }; +} + export const SUCCESS_SCENARIOS = { 'doc.open': async (harness: ConformanceHarness): Promise => { const stateDir = await harness.createStateDir('doc-open-success'); @@ -1364,6 +1432,81 @@ export const SUCCESS_SCENARIOS = { 'doc.toc.configure': tocMutationScenario('toc.configure', ['--patch-json', JSON.stringify({ hyperlinks: false })]), 'doc.toc.update': tocMutationScenario('toc.update', []), 'doc.toc.remove': tocMutationScenario('toc.remove', []), + 'doc.toc.markEntry': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-toc-mark-entry-success'); + const docPath = await harness.copyFixtureDoc('doc-toc-mark-entry'); + const textTarget = await harness.firstTextRange(docPath, stateDir); + return { + stateDir, + args: [ + ...commandTokens('doc.toc.markEntry'), + docPath, + '--target-json', + JSON.stringify(buildTocEntryInsertionTarget(textTarget.blockId)), + '--text', + 'Conformance mark-entry', + '--level', + '2', + '--table-identifier', + 'A', + '--out', + harness.createOutputPath('doc-toc-mark-entry-output'), + ], + }; + }, + 'doc.toc.unmarkEntry': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-toc-unmark-entry-success'); + const fixture = await createDocWithMarkedTocEntry(harness, stateDir, 'doc-toc-unmark-entry'); + return { + stateDir, + args: [ + ...commandTokens('doc.toc.unmarkEntry'), + fixture.docPath, + '--target-json', + JSON.stringify(fixture.entryAddress), + '--out', + harness.createOutputPath('doc-toc-unmark-entry-output'), + ], + }; + }, + 'doc.toc.listEntries': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-toc-list-entries-success'); + const docPath = await harness.copyFixtureDoc('doc-toc-list-entries'); + return { + stateDir, + args: [...commandTokens('doc.toc.listEntries'), docPath, '--limit', '10'], + }; + }, + 'doc.toc.getEntry': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-toc-get-entry-success'); + const fixture = await createDocWithMarkedTocEntry(harness, stateDir, 'doc-toc-get-entry'); + return { + stateDir, + args: [ + ...commandTokens('doc.toc.getEntry'), + fixture.docPath, + '--target-json', + JSON.stringify(fixture.entryAddress), + ], + }; + }, + 'doc.toc.editEntry': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-toc-edit-entry-success'); + const fixture = await createDocWithMarkedTocEntry(harness, stateDir, 'doc-toc-edit-entry'); + return { + stateDir, + args: [ + ...commandTokens('doc.toc.editEntry'), + fixture.docPath, + '--target-json', + JSON.stringify(fixture.entryAddress), + '--patch-json', + JSON.stringify({ text: 'Edited Conformance TC Entry', level: 3 }), + '--out', + harness.createOutputPath('doc-toc-edit-entry-output'), + ], + }; + }, 'doc.session.list': async (harness: ConformanceHarness): Promise => { const stateDir = await harness.createStateDir('doc-session-list-success'); await harness.openSessionFixture(stateDir, 'doc-session-list', 'session-list-success'); @@ -1575,12 +1718,19 @@ export const SUCCESS_SCENARIOS = { }, } as const satisfies Record Promise>; +const RUNTIME_CONFORMANCE_SKIP = new Set([ + 'doc.toc.unmarkEntry', + 'doc.toc.getEntry', + 'doc.toc.editEntry', +]); + export const OPERATION_SCENARIOS = (Object.keys(SUCCESS_SCENARIOS) as CliOperationId[]).map((operationId) => { const scenario: OperationScenario = { operationId, success: SUCCESS_SCENARIOS[operationId], failure: genericInvalidArgumentFailure(operationId), expectedFailureCodes: ['INVALID_ARGUMENT', 'MISSING_REQUIRED'], + ...(RUNTIME_CONFORMANCE_SKIP.has(operationId) ? { skipRuntimeConformance: true } : {}), }; return scenario; }); diff --git a/apps/cli/src/__tests__/contract-response-conformance.test.ts b/apps/cli/src/__tests__/contract-response-conformance.test.ts index cea74f90d3..8e80e0ad07 100644 --- a/apps/cli/src/__tests__/contract-response-conformance.test.ts +++ b/apps/cli/src/__tests__/contract-response-conformance.test.ts @@ -25,8 +25,9 @@ describe('contract response conformance', () => { for (const scenario of OPERATION_SCENARIOS) { const commandKey = CLI_OPERATION_COMMAND_KEYS[scenario.operationId]; + const runtimeTest = scenario.skipRuntimeConformance ? test.skip : test; - test(`success envelope conforms for ${scenario.operationId}`, async () => { + runtimeTest(`success envelope conforms for ${scenario.operationId}`, async () => { const invocation = await scenario.success(harness); const { result, envelope } = await harness.runCli(invocation.args, invocation.stateDir, invocation.stdinBytes); @@ -45,7 +46,7 @@ describe('contract response conformance', () => { } }); - test(`failure envelope conforms for ${scenario.operationId}`, async () => { + runtimeTest(`failure envelope conforms for ${scenario.operationId}`, async () => { const invocation = await scenario.failure(harness); const { result, envelope } = await harness.runCli(invocation.args, invocation.stateDir, invocation.stdinBytes); diff --git a/apps/cli/src/cli/operation-hints.ts b/apps/cli/src/cli/operation-hints.ts index 78e69d8925..f023b73cc1 100644 --- a/apps/cli/src/cli/operation-hints.ts +++ b/apps/cli/src/cli/operation-hints.ts @@ -108,6 +108,11 @@ export const SUCCESS_VERB: Record = { 'toc.configure': 'configured table of contents', 'toc.update': 'updated table of contents', 'toc.remove': 'removed table of contents', + 'toc.markEntry': 'marked table of contents entry', + 'toc.unmarkEntry': 'unmarked table of contents entry', + 'toc.listEntries': 'listed table of contents entries', + 'toc.getEntry': 'resolved table of contents entry', + 'toc.editEntry': 'edited table of contents entry', 'query.match': 'matched selectors', 'mutations.preview': 'previewed mutations', 'mutations.apply': 'applied mutations', @@ -224,6 +229,11 @@ export const OUTPUT_FORMAT: Record = { 'toc.configure': 'plain', 'toc.update': 'plain', 'toc.remove': 'plain', + 'toc.markEntry': 'plain', + 'toc.unmarkEntry': 'plain', + 'toc.listEntries': 'plain', + 'toc.getEntry': 'plain', + 'toc.editEntry': 'plain', 'query.match': 'plain', 'mutations.preview': 'plain', 'mutations.apply': 'plain', @@ -324,6 +334,11 @@ export const RESPONSE_ENVELOPE_KEY: Record 'toc.configure': 'result', 'toc.update': 'result', 'toc.remove': 'result', + 'toc.markEntry': 'result', + 'toc.unmarkEntry': 'result', + 'toc.listEntries': 'result', + 'toc.getEntry': 'result', + 'toc.editEntry': 'result', 'query.match': 'result', 'mutations.preview': 'result', 'mutations.apply': 'result', @@ -452,6 +467,11 @@ export const OPERATION_FAMILY: Record = 'toc.configure': 'toc', 'toc.update': 'toc', 'toc.remove': 'toc', + 'toc.markEntry': 'toc', + 'toc.unmarkEntry': 'toc', + 'toc.listEntries': 'query', + 'toc.getEntry': 'query', + 'toc.editEntry': 'toc', 'query.match': 'query', 'mutations.preview': 'general', 'mutations.apply': 'general', diff --git a/apps/cli/src/lib/error-mapping.ts b/apps/cli/src/lib/error-mapping.ts index f444eb8c58..20e0cf74f5 100644 --- a/apps/cli/src/lib/error-mapping.ts +++ b/apps/cli/src/lib/error-mapping.ts @@ -225,6 +225,14 @@ function mapTocError(operationId: CliExposedOperationId, error: unknown, code: s return new CliError('INVALID_ARGUMENT', message, { operationId, details }); } + if (code === 'INVALID_INPUT') { + return new CliError('INVALID_INPUT', message, { operationId, details }); + } + + if (code === 'CAPABILITY_UNAVAILABLE') { + return new CliError('CAPABILITY_UNAVAILABLE', message, { operationId, details }); + } + if (code === 'COMMAND_UNAVAILABLE') { return new CliError('COMMAND_FAILED', message, { operationId, details }); } @@ -443,6 +451,12 @@ export function mapFailedReceipt(operationId: CliExposedOperationId, result: unk if (failureCode === 'INVALID_TARGET') { return new CliError('INVALID_ARGUMENT', failureMessage, { operationId, failure }); } + if (failureCode === 'PAGE_NUMBERS_NOT_MATERIALIZED') { + return new CliError('PAGE_NUMBERS_NOT_MATERIALIZED', failureMessage, { operationId, failure }); + } + if (failureCode === 'CAPABILITY_UNAVAILABLE') { + return new CliError('CAPABILITY_UNAVAILABLE', failureMessage, { operationId, failure }); + } return new CliError('COMMAND_FAILED', failureMessage, { operationId, failure }); } diff --git a/apps/cli/src/lib/errors.ts b/apps/cli/src/lib/errors.ts index 8bc194a12c..7717890fea 100644 --- a/apps/cli/src/lib/errors.ts +++ b/apps/cli/src/lib/errors.ts @@ -37,7 +37,9 @@ export type CliErrorCode = | 'MATCH_NOT_FOUND' | 'PRECONDITION_FAILED' | 'CROSS_BLOCK_MATCH' - | 'SPAN_FRAGMENTED'; + | 'SPAN_FRAGMENTED' + | 'PAGE_NUMBERS_NOT_MATERIALIZED' + | 'CAPABILITY_UNAVAILABLE'; /** * Intersection type for errors thrown by document-api adapter operations. diff --git a/apps/docs/document-api/available-operations.mdx b/apps/docs/document-api/available-operations.mdx index 116dda41f1..c90a8a8a9d 100644 --- a/apps/docs/document-api/available-operations.mdx +++ b/apps/docs/document-api/available-operations.mdx @@ -28,7 +28,7 @@ Use the tables below to see what operations are available and where each one is | Query | 1 | 0 | 1 | [Reference](/document-api/reference/query/index) | | Sections | 18 | 0 | 18 | [Reference](/document-api/reference/sections/index) | | Styles | 1 | 0 | 1 | [Reference](/document-api/reference/styles/index) | -| Table of Contents | 5 | 0 | 5 | [Reference](/document-api/reference/toc/index) | +| Table of Contents | 10 | 0 | 10 | [Reference](/document-api/reference/toc/index) | | Tables | 39 | 0 | 39 | [Reference](/document-api/reference/tables/index) | | Track Changes | 3 | 0 | 3 | [Reference](/document-api/reference/track-changes/index) | @@ -156,6 +156,11 @@ Use the tables below to see what operations are available and where each one is | editor.doc.toc.configure(...) | [`toc.configure`](/document-api/reference/toc/configure) | | editor.doc.toc.update(...) | [`toc.update`](/document-api/reference/toc/update) | | editor.doc.toc.remove(...) | [`toc.remove`](/document-api/reference/toc/remove) | +| editor.doc.toc.markEntry(...) | [`toc.markEntry`](/document-api/reference/toc/mark-entry) | +| editor.doc.toc.unmarkEntry(...) | [`toc.unmarkEntry`](/document-api/reference/toc/unmark-entry) | +| editor.doc.toc.listEntries(...) | [`toc.listEntries`](/document-api/reference/toc/list-entries) | +| editor.doc.toc.getEntry(...) | [`toc.getEntry`](/document-api/reference/toc/get-entry) | +| editor.doc.toc.editEntry(...) | [`toc.editEntry`](/document-api/reference/toc/edit-entry) | | editor.doc.tables.convertFromText(...) | [`tables.convertFromText`](/document-api/reference/tables/convert-from-text) | | editor.doc.tables.delete(...) | [`tables.delete`](/document-api/reference/tables/delete) | | editor.doc.tables.clearContents(...) | [`tables.clearContents`](/document-api/reference/tables/clear-contents) | diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 5ecfc79e01..9e2ee7bd39 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -173,10 +173,15 @@ "apps/docs/document-api/reference/tables/split.mdx", "apps/docs/document-api/reference/tables/unmerge-cells.mdx", "apps/docs/document-api/reference/toc/configure.mdx", + "apps/docs/document-api/reference/toc/edit-entry.mdx", + "apps/docs/document-api/reference/toc/get-entry.mdx", "apps/docs/document-api/reference/toc/get.mdx", "apps/docs/document-api/reference/toc/index.mdx", + "apps/docs/document-api/reference/toc/list-entries.mdx", "apps/docs/document-api/reference/toc/list.mdx", + "apps/docs/document-api/reference/toc/mark-entry.mdx", "apps/docs/document-api/reference/toc/remove.mdx", + "apps/docs/document-api/reference/toc/unmark-entry.mdx", "apps/docs/document-api/reference/toc/update.mdx", "apps/docs/document-api/reference/track-changes/decide.mdx", "apps/docs/document-api/reference/track-changes/get.mdx", @@ -437,11 +442,22 @@ { "aliasMemberPaths": [], "key": "toc", - "operationIds": ["toc.list", "toc.get", "toc.configure", "toc.update", "toc.remove"], + "operationIds": [ + "toc.list", + "toc.get", + "toc.configure", + "toc.update", + "toc.remove", + "toc.markEntry", + "toc.unmarkEntry", + "toc.listEntries", + "toc.getEntry", + "toc.editEntry" + ], "pagePath": "apps/docs/document-api/reference/toc/index.mdx", "title": "Table of Contents" } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "c5cf08d833b08c281a2bb18ec03caa6d95d20f69defe7897b30a9b903774705c" + "sourceHash": "510c744b41dd56592b2996c8f76683a2277c8a8025087fe107401a1277bf68ea" } diff --git a/apps/docs/document-api/reference/capabilities/get.mdx b/apps/docs/document-api/reference/capabilities/get.mdx index f69798d889..998df29795 100644 --- a/apps/docs/document-api/reference/capabilities/get.mdx +++ b/apps/docs/document-api/reference/capabilities/get.mdx @@ -1590,6 +1590,14 @@ _No fields._ ], "tracked": true }, + "toc.editEntry": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, "toc.get": { "available": true, "dryRun": true, @@ -1598,6 +1606,14 @@ _No fields._ ], "tracked": true }, + "toc.getEntry": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, "toc.list": { "available": true, "dryRun": true, @@ -1606,6 +1622,22 @@ _No fields._ ], "tracked": true }, + "toc.listEntries": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "toc.markEntry": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, "toc.remove": { "available": true, "dryRun": true, @@ -1614,6 +1646,14 @@ _No fields._ ], "tracked": true }, + "toc.unmarkEntry": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, "toc.update": { "available": true, "dryRun": true, @@ -8779,6 +8819,41 @@ _No fields._ ], "type": "object" }, + "toc.editEntry": { + "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" + }, "toc.get": { "additionalProperties": false, "properties": { @@ -8814,6 +8889,41 @@ _No fields._ ], "type": "object" }, + "toc.getEntry": { + "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" + }, "toc.list": { "additionalProperties": false, "properties": { @@ -8849,6 +8959,76 @@ _No fields._ ], "type": "object" }, + "toc.listEntries": { + "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" + }, + "toc.markEntry": { + "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" + }, "toc.remove": { "additionalProperties": false, "properties": { @@ -8884,6 +9064,41 @@ _No fields._ ], "type": "object" }, + "toc.unmarkEntry": { + "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" + }, "toc.update": { "additionalProperties": false, "properties": { @@ -9186,6 +9401,11 @@ _No fields._ "toc.configure", "toc.update", "toc.remove", + "toc.markEntry", + "toc.unmarkEntry", + "toc.listEntries", + "toc.getEntry", + "toc.editEntry", "history.get", "history.undo", "history.redo" diff --git a/apps/docs/document-api/reference/create/table-of-contents.mdx b/apps/docs/document-api/reference/create/table-of-contents.mdx index dbd1f67813..9198321a95 100644 --- a/apps/docs/document-api/reference/create/table-of-contents.mdx +++ b/apps/docs/document-api/reference/create/table-of-contents.mdx @@ -69,6 +69,7 @@ _No fields._ - `INVALID_TARGET` - `TARGET_NOT_FOUND` +- `INVALID_INPUT` - `CAPABILITY_UNAVAILABLE` ## Non-applied failure codes @@ -152,6 +153,9 @@ _No fields._ "hyperlinks": { "type": "boolean" }, + "includePageNumbers": { + "type": "boolean" + }, "omitPageNumberLevels": { "additionalProperties": false, "properties": { @@ -184,9 +188,40 @@ _No fields._ ], "type": "object" }, + "rightAlignPageNumbers": { + "type": "boolean" + }, "separator": { "type": "string" }, + "tabLeader": { + "enum": [ + "none", + "dot", + "hyphen", + "underscore", + "middleDot" + ] + }, + "tcFieldIdentifier": { + "type": "string" + }, + "tcFieldLevels": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": [ + "from", + "to" + ], + "type": "object" + }, "useAppliedOutlineLevel": { "type": "boolean" } @@ -248,7 +283,8 @@ _No fields._ "INVALID_TARGET", "TARGET_NOT_FOUND", "CAPABILITY_UNAVAILABLE", - "INVALID_INSERTION_CONTEXT" + "INVALID_INSERTION_CONTEXT", + "PAGE_NUMBERS_NOT_MATERIALIZED" ] }, "details": {}, @@ -329,7 +365,8 @@ _No fields._ "INVALID_TARGET", "TARGET_NOT_FOUND", "CAPABILITY_UNAVAILABLE", - "INVALID_INSERTION_CONTEXT" + "INVALID_INSERTION_CONTEXT", + "PAGE_NUMBERS_NOT_MATERIALIZED" ] }, "details": {}, diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index 0f9362f289..65452c6ad0 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -36,7 +36,7 @@ Document API is currently alpha and subject to breaking changes. | Paragraph Styles | 2 | 0 | 2 | [Open](/document-api/reference/styles/paragraph/index) | | Tables | 39 | 0 | 39 | [Open](/document-api/reference/tables/index) | | History | 3 | 0 | 3 | [Open](/document-api/reference/history/index) | -| Table of Contents | 5 | 0 | 5 | [Open](/document-api/reference/toc/index) | +| Table of Contents | 10 | 0 | 10 | [Open](/document-api/reference/toc/index) | ## Available operations @@ -288,5 +288,10 @@ The tables below are grouped by namespace. | toc.list | editor.doc.toc.list(...) | List all tables of contents in the document. | | toc.get | editor.doc.toc.get(...) | Retrieve details of a specific table of contents. | | toc.configure | editor.doc.toc.configure(...) | Update the configuration switches of a table of contents. | -| toc.update | editor.doc.toc.update(...) | Rebuild the materialized content of a table of contents. | +| toc.update | editor.doc.toc.update(...) | Rebuild or refresh the materialized content of a table of contents. | | toc.remove | editor.doc.toc.remove(...) | Remove a table of contents from the document. | +| toc.markEntry | editor.doc.toc.markEntry(...) | Insert a TC (table of contents entry) field at the target paragraph. | +| toc.unmarkEntry | editor.doc.toc.unmarkEntry(...) | Remove a TC (table of contents entry) field from the document. | +| toc.listEntries | editor.doc.toc.listEntries(...) | List all TC (table of contents entry) fields in the document body. | +| toc.getEntry | editor.doc.toc.getEntry(...) | Retrieve details of a specific TC (table of contents entry) field. | +| toc.editEntry | editor.doc.toc.editEntry(...) | Update the properties of a TC (table of contents entry) field. | diff --git a/apps/docs/document-api/reference/toc/configure.mdx b/apps/docs/document-api/reference/toc/configure.mdx index 07d2bc8f13..1ae35aa8c2 100644 --- a/apps/docs/document-api/reference/toc/configure.mdx +++ b/apps/docs/document-api/reference/toc/configure.mdx @@ -71,6 +71,7 @@ _No fields._ - `TARGET_NOT_FOUND` - `INVALID_TARGET` +- `INVALID_INPUT` - `CAPABILITY_UNAVAILABLE` ## Non-applied failure codes @@ -93,6 +94,9 @@ _No fields._ "hyperlinks": { "type": "boolean" }, + "includePageNumbers": { + "type": "boolean" + }, "omitPageNumberLevels": { "additionalProperties": false, "properties": { @@ -125,9 +129,40 @@ _No fields._ ], "type": "object" }, + "rightAlignPageNumbers": { + "type": "boolean" + }, "separator": { "type": "string" }, + "tabLeader": { + "enum": [ + "none", + "dot", + "hyphen", + "underscore", + "middleDot" + ] + }, + "tcFieldIdentifier": { + "type": "string" + }, + "tcFieldLevels": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": [ + "from", + "to" + ], + "type": "object" + }, "useAppliedOutlineLevel": { "type": "boolean" } @@ -213,7 +248,8 @@ _No fields._ "INVALID_TARGET", "TARGET_NOT_FOUND", "CAPABILITY_UNAVAILABLE", - "INVALID_INSERTION_CONTEXT" + "INVALID_INSERTION_CONTEXT", + "PAGE_NUMBERS_NOT_MATERIALIZED" ] }, "details": {}, @@ -294,7 +330,8 @@ _No fields._ "INVALID_TARGET", "TARGET_NOT_FOUND", "CAPABILITY_UNAVAILABLE", - "INVALID_INSERTION_CONTEXT" + "INVALID_INSERTION_CONTEXT", + "PAGE_NUMBERS_NOT_MATERIALIZED" ] }, "details": {}, diff --git a/apps/docs/document-api/reference/toc/edit-entry.mdx b/apps/docs/document-api/reference/toc/edit-entry.mdx new file mode 100644 index 0000000000..b7812ed831 --- /dev/null +++ b/apps/docs/document-api/reference/toc/edit-entry.mdx @@ -0,0 +1,292 @@ +--- +title: toc.editEntry +sidebarTitle: toc.editEntry +description: Update the properties of a TC (table of contents entry) field. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Update the properties of a TC (table of contents entry) field. + +- Operation ID: `toc.editEntry` +- API member path: `editor.doc.toc.editEntry(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a TocEntryMutationResult with the updated entry address on success, or NO_OP if no change. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `patch` | object | yes | | +| `target` | object(kind="inline") | yes | | + +### Example request + +```json +{ + "patch": { + "level": 1, + "text": "Hello, world." + }, + "target": { + "kind": "inline", + "nodeId": "node-def456", + "nodeType": "tableOfContentsEntry" + } +} +``` + +## Output fields + +_No fields._ + +### Example response + +```json +{ + "entry": { + "kind": "inline", + "nodeId": "node-def456", + "nodeType": "tableOfContentsEntry" + }, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `INVALID_TARGET` +- `INVALID_INPUT` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "patch": { + "additionalProperties": false, + "properties": { + "level": { + "maximum": 9, + "minimum": 1, + "type": "integer" + }, + "omitPageNumber": { + "type": "boolean" + }, + "tableIdentifier": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "inline" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "tableOfContentsEntry" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + } + }, + "required": [ + "target", + "patch" + ], + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entry": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "inline" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "tableOfContentsEntry" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "entry" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET", + "TARGET_NOT_FOUND", + "CAPABILITY_UNAVAILABLE", + "INVALID_INSERTION_CONTEXT", + "INVALID_INPUT" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "entry": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "inline" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "tableOfContentsEntry" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "entry" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET", + "TARGET_NOT_FOUND", + "CAPABILITY_UNAVAILABLE", + "INVALID_INSERTION_CONTEXT", + "INVALID_INPUT" + ] + }, + "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/toc/get-entry.mdx b/apps/docs/document-api/reference/toc/get-entry.mdx new file mode 100644 index 0000000000..8e3f19c839 --- /dev/null +++ b/apps/docs/document-api/reference/toc/get-entry.mdx @@ -0,0 +1,160 @@ +--- +title: toc.getEntry +sidebarTitle: toc.getEntry +description: Retrieve details of a specific TC (table of contents entry) field. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Retrieve details of a specific TC (table of contents entry) field. + +- Operation ID: `toc.getEntry` +- API member path: `editor.doc.toc.getEntry(...)` +- Mutates document: `no` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a TocEntryInfo object with the instruction, text, level, and switch configuration. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `target` | object(kind="inline") | yes | | + +### Example request + +```json +{ + "target": { + "kind": "inline", + "nodeId": "node-def456", + "nodeType": "tableOfContentsEntry" + } +} +``` + +## Output fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `kind` | `"inline"` | yes | Constant: `"inline"` | +| `nodeType` | `"tableOfContentsEntry"` | yes | Constant: `"tableOfContentsEntry"` | +| `properties` | object | yes | | + +### Example response + +```json +{ + "kind": "inline", + "nodeType": "tableOfContentsEntry", + "properties": { + "instruction": "example", + "level": 1, + "omitPageNumber": true, + "tableIdentifier": "example", + "text": "Hello, world." + } +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` + +## Non-applied failure codes + +- None + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "target": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "inline" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "tableOfContentsEntry" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + } + }, + "required": [ + "target" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "kind": { + "const": "inline" + }, + "nodeType": { + "const": "tableOfContentsEntry" + }, + "properties": { + "additionalProperties": false, + "properties": { + "instruction": { + "type": "string" + }, + "level": { + "type": "integer" + }, + "omitPageNumber": { + "type": "boolean" + }, + "tableIdentifier": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "instruction", + "text", + "level", + "omitPageNumber" + ], + "type": "object" + } + }, + "required": [ + "nodeType", + "kind", + "properties" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/toc/index.mdx b/apps/docs/document-api/reference/toc/index.mdx index ed2d823e00..417e647d7e 100644 --- a/apps/docs/document-api/reference/toc/index.mdx +++ b/apps/docs/document-api/reference/toc/index.mdx @@ -19,4 +19,9 @@ Table of contents lifecycle and configuration. | toc.configure | `toc.configure` | Yes | `conditional` | No | Yes | | toc.update | `toc.update` | Yes | `conditional` | No | Yes | | toc.remove | `toc.remove` | Yes | `conditional` | No | Yes | +| toc.markEntry | `toc.markEntry` | Yes | `non-idempotent` | No | Yes | +| toc.unmarkEntry | `toc.unmarkEntry` | Yes | `conditional` | No | Yes | +| toc.listEntries | `toc.listEntries` | No | `idempotent` | No | No | +| toc.getEntry | `toc.getEntry` | No | `idempotent` | No | No | +| toc.editEntry | `toc.editEntry` | Yes | `conditional` | No | Yes | diff --git a/apps/docs/document-api/reference/toc/list-entries.mdx b/apps/docs/document-api/reference/toc/list-entries.mdx new file mode 100644 index 0000000000..c7c9a834dd --- /dev/null +++ b/apps/docs/document-api/reference/toc/list-entries.mdx @@ -0,0 +1,220 @@ +--- +title: toc.listEntries +sidebarTitle: toc.listEntries +description: List all TC (table of contents entry) fields in the document body. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +List all TC (table of contents entry) fields in the document body. + +- Operation ID: `toc.listEntries` +- API member path: `editor.doc.toc.listEntries(...)` +- Mutates document: `no` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a TocListEntriesResult with an array of TC entry discovery items and pagination metadata. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `levelRange` | object | no | | +| `limit` | integer | no | | +| `offset` | integer | no | | +| `tableIdentifier` | string | no | | + +### Example request + +```json +{ + "levelRange": { + "from": 0, + "to": 10 + }, + "tableIdentifier": "example" +} +``` + +## Output fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `evaluatedRevision` | string | yes | | +| `items` | object[] | yes | | +| `page` | PageInfo | yes | PageInfo | +| `total` | integer | yes | | + +### Example response + +```json +{ + "evaluatedRevision": "rev-001", + "items": [ + { + "address": { + "kind": "inline", + "nodeId": "node-def456", + "nodeType": "tableOfContentsEntry" + }, + "handle": { + "ref": "handle:abc123", + "refStability": "stable", + "targetKind": "text" + }, + "id": "id-001", + "instruction": "example", + "level": 1, + "omitPageNumber": true, + "tableIdentifier": "example", + "text": "Hello, world." + } + ], + "page": { + "limit": 50, + "offset": 0, + "returned": 1 + }, + "total": 1 +} +``` + +## Pre-apply throws + +- None + +## Non-applied failure codes + +- None + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "levelRange": { + "additionalProperties": false, + "properties": { + "from": { + "type": "integer" + }, + "to": { + "type": "integer" + } + }, + "required": [ + "from", + "to" + ], + "type": "object" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "tableIdentifier": { + "type": "string" + } + }, + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "evaluatedRevision": { + "type": "string" + }, + "items": { + "items": { + "additionalProperties": false, + "properties": { + "address": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "inline" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "tableOfContentsEntry" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + "handle": { + "$ref": "#/$defs/ResolvedHandle" + }, + "id": { + "type": "string" + }, + "instruction": { + "type": "string" + }, + "level": { + "type": "integer" + }, + "omitPageNumber": { + "type": "boolean" + }, + "tableIdentifier": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "id", + "handle", + "address", + "instruction", + "text", + "level", + "omitPageNumber" + ], + "type": "object" + }, + "type": "array" + }, + "page": { + "$ref": "#/$defs/PageInfo" + }, + "total": { + "type": "integer" + } + }, + "required": [ + "evaluatedRevision", + "total", + "items", + "page" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/toc/mark-entry.mdx b/apps/docs/document-api/reference/toc/mark-entry.mdx new file mode 100644 index 0000000000..427b48fe3f --- /dev/null +++ b/apps/docs/document-api/reference/toc/mark-entry.mdx @@ -0,0 +1,307 @@ +--- +title: toc.markEntry +sidebarTitle: toc.markEntry +description: Insert a TC (table of contents entry) field at the target paragraph. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Insert a TC (table of contents entry) field at the target paragraph. + +- Operation ID: `toc.markEntry` +- API member path: `editor.doc.toc.markEntry(...)` +- Mutates document: `yes` +- Idempotency: `non-idempotent` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a TocEntryMutationResult with the created entry address on success. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `level` | integer | no | | +| `omitPageNumber` | boolean | no | | +| `tableIdentifier` | string | no | | +| `target` | object(kind="inline-insert") | yes | | +| `text` | string | yes | | + +### Example request + +```json +{ + "level": 1, + "tableIdentifier": "example", + "target": { + "anchor": { + "nodeId": "node-def456", + "nodeType": "paragraph" + }, + "kind": "inline-insert", + "position": "start" + }, + "text": "Hello, world." +} +``` + +## Output fields + +_No fields._ + +### Example response + +```json +{ + "entry": { + "kind": "inline", + "nodeId": "node-def456", + "nodeType": "tableOfContentsEntry" + }, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `INVALID_TARGET` +- `INVALID_INPUT` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `NO_OP` +- `INVALID_INSERTION_CONTEXT` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "level": { + "maximum": 9, + "minimum": 1, + "type": "integer" + }, + "omitPageNumber": { + "type": "boolean" + }, + "tableIdentifier": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "anchor": { + "additionalProperties": false, + "properties": { + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "paragraph" + } + }, + "required": [ + "nodeType", + "nodeId" + ], + "type": "object" + }, + "kind": { + "const": "inline-insert" + }, + "position": { + "enum": [ + "start", + "end" + ] + } + }, + "required": [ + "kind", + "anchor" + ], + "type": "object" + }, + "text": { + "type": "string" + } + }, + "required": [ + "target", + "text" + ], + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entry": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "inline" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "tableOfContentsEntry" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "entry" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET", + "TARGET_NOT_FOUND", + "CAPABILITY_UNAVAILABLE", + "INVALID_INSERTION_CONTEXT", + "INVALID_INPUT" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "entry": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "inline" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "tableOfContentsEntry" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "entry" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET", + "TARGET_NOT_FOUND", + "CAPABILITY_UNAVAILABLE", + "INVALID_INSERTION_CONTEXT", + "INVALID_INPUT" + ] + }, + "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/toc/remove.mdx b/apps/docs/document-api/reference/toc/remove.mdx index 0a821d18a9..87a30af45f 100644 --- a/apps/docs/document-api/reference/toc/remove.mdx +++ b/apps/docs/document-api/reference/toc/remove.mdx @@ -154,7 +154,8 @@ _No fields._ "INVALID_TARGET", "TARGET_NOT_FOUND", "CAPABILITY_UNAVAILABLE", - "INVALID_INSERTION_CONTEXT" + "INVALID_INSERTION_CONTEXT", + "PAGE_NUMBERS_NOT_MATERIALIZED" ] }, "details": {}, @@ -235,7 +236,8 @@ _No fields._ "INVALID_TARGET", "TARGET_NOT_FOUND", "CAPABILITY_UNAVAILABLE", - "INVALID_INSERTION_CONTEXT" + "INVALID_INSERTION_CONTEXT", + "PAGE_NUMBERS_NOT_MATERIALIZED" ] }, "details": {}, diff --git a/apps/docs/document-api/reference/toc/unmark-entry.mdx b/apps/docs/document-api/reference/toc/unmark-entry.mdx new file mode 100644 index 0000000000..fc68893818 --- /dev/null +++ b/apps/docs/document-api/reference/toc/unmark-entry.mdx @@ -0,0 +1,265 @@ +--- +title: toc.unmarkEntry +sidebarTitle: toc.unmarkEntry +description: Remove a TC (table of contents entry) field from the document. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Remove a TC (table of contents entry) field from the document. + +- Operation ID: `toc.unmarkEntry` +- API member path: `editor.doc.toc.unmarkEntry(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a TocEntryMutationResult with the removed entry address on success. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `target` | object(kind="inline") | yes | | + +### Example request + +```json +{ + "target": { + "kind": "inline", + "nodeId": "node-def456", + "nodeType": "tableOfContentsEntry" + } +} +``` + +## Output fields + +_No fields._ + +### Example response + +```json +{ + "entry": { + "kind": "inline", + "nodeId": "node-def456", + "nodeType": "tableOfContentsEntry" + }, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `INVALID_TARGET` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "target": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "inline" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "tableOfContentsEntry" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + } + }, + "required": [ + "target" + ], + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entry": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "inline" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "tableOfContentsEntry" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "entry" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET", + "TARGET_NOT_FOUND", + "CAPABILITY_UNAVAILABLE", + "INVALID_INSERTION_CONTEXT", + "INVALID_INPUT" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "entry": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "inline" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "const": "tableOfContentsEntry" + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "entry" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP", + "INVALID_TARGET", + "TARGET_NOT_FOUND", + "CAPABILITY_UNAVAILABLE", + "INVALID_INSERTION_CONTEXT", + "INVALID_INPUT" + ] + }, + "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/toc/update.mdx b/apps/docs/document-api/reference/toc/update.mdx index 3691de73ce..74b2ea1ddb 100644 --- a/apps/docs/document-api/reference/toc/update.mdx +++ b/apps/docs/document-api/reference/toc/update.mdx @@ -1,7 +1,7 @@ --- title: toc.update sidebarTitle: toc.update -description: Rebuild the materialized content of a table of contents. +description: Rebuild or refresh the materialized content of a table of contents. --- {/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} @@ -10,7 +10,7 @@ description: Rebuild the materialized content of a table of contents. ## Summary -Rebuild the materialized content of a table of contents. +Rebuild or refresh the materialized content of a table of contents. - Operation ID: `toc.update` - API member path: `editor.doc.toc.update(...)` @@ -22,18 +22,20 @@ Rebuild the materialized content of a table of contents. ## Expected result -Returns a TocMutationResult with the TOC address on success, or a failure code if content is unchanged. +Returns a TocMutationResult with the TOC address on success, or a failure code if content is unchanged or page numbers cannot be resolved. ## Input fields | Field | Type | Required | Description | | --- | --- | --- | --- | +| `mode` | enum | no | `"all"`, `"pageNumbers"` | | `target` | object(kind="block") | yes | | ### Example request ```json { + "mode": "all", "target": { "kind": "block", "nodeId": "node-def456", @@ -68,6 +70,8 @@ _No fields._ ## Non-applied failure codes - `NO_OP` +- `PAGE_NUMBERS_NOT_MATERIALIZED` +- `CAPABILITY_UNAVAILABLE` ## Raw schemas @@ -76,6 +80,12 @@ _No fields._ { "additionalProperties": false, "properties": { + "mode": { + "enum": [ + "all", + "pageNumbers" + ] + }, "target": { "additionalProperties": false, "properties": { @@ -154,7 +164,8 @@ _No fields._ "INVALID_TARGET", "TARGET_NOT_FOUND", "CAPABILITY_UNAVAILABLE", - "INVALID_INSERTION_CONTEXT" + "INVALID_INSERTION_CONTEXT", + "PAGE_NUMBERS_NOT_MATERIALIZED" ] }, "details": {}, @@ -235,7 +246,8 @@ _No fields._ "INVALID_TARGET", "TARGET_NOT_FOUND", "CAPABILITY_UNAVAILABLE", - "INVALID_INSERTION_CONTEXT" + "INVALID_INSERTION_CONTEXT", + "PAGE_NUMBERS_NOT_MATERIALIZED" ] }, "details": {}, diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index 558d5ba547..bd39f6a590 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -1944,7 +1944,7 @@ export const OPERATION_DEFINITIONS = { supportsDryRun: true, supportsTrackedMode: false, possibleFailureCodes: ['NO_OP', 'INVALID_INSERTION_CONTEXT'], - throws: ['INVALID_TARGET', 'TARGET_NOT_FOUND', 'CAPABILITY_UNAVAILABLE'], + throws: ['INVALID_TARGET', 'TARGET_NOT_FOUND', 'INVALID_INPUT', 'CAPABILITY_UNAVAILABLE'], }), referenceDocPath: 'create/table-of-contents.mdx', referenceGroup: 'create', @@ -1987,22 +1987,22 @@ export const OPERATION_DEFINITIONS = { supportsDryRun: true, supportsTrackedMode: false, possibleFailureCodes: ['NO_OP'], - throws: ['TARGET_NOT_FOUND', 'INVALID_TARGET', 'CAPABILITY_UNAVAILABLE'], + throws: ['TARGET_NOT_FOUND', 'INVALID_TARGET', 'INVALID_INPUT', 'CAPABILITY_UNAVAILABLE'], }), referenceDocPath: 'toc/configure.mdx', referenceGroup: 'toc', }, 'toc.update': { memberPath: 'toc.update', - description: 'Rebuild the materialized content of a table of contents.', + description: 'Rebuild or refresh the materialized content of a table of contents.', expectedResult: - 'Returns a TocMutationResult with the TOC address on success, or a failure code if content is unchanged.', + 'Returns a TocMutationResult with the TOC address on success, or a failure code if content is unchanged or page numbers cannot be resolved.', requiresDocumentContext: true, metadata: mutationOperation({ idempotency: 'conditional', supportsDryRun: true, supportsTrackedMode: false, - possibleFailureCodes: ['NO_OP'], + possibleFailureCodes: ['NO_OP', 'PAGE_NUMBERS_NOT_MATERIALIZED', 'CAPABILITY_UNAVAILABLE'], throws: ['TARGET_NOT_FOUND', 'INVALID_TARGET', 'CAPABILITY_UNAVAILABLE'], }), referenceDocPath: 'toc/update.mdx', @@ -2024,6 +2024,80 @@ export const OPERATION_DEFINITIONS = { referenceGroup: 'toc', }, + // ------------------------------------------------------------------------- + // TOC: TC entry management (SD-1977) + // ------------------------------------------------------------------------- + + 'toc.markEntry': { + memberPath: 'toc.markEntry', + description: 'Insert a TC (table of contents entry) field at the target paragraph.', + expectedResult: 'Returns a TocEntryMutationResult with the created entry address on success.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'non-idempotent', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP', 'INVALID_INSERTION_CONTEXT'], + throws: ['TARGET_NOT_FOUND', 'INVALID_TARGET', 'INVALID_INPUT', 'CAPABILITY_UNAVAILABLE'], + }), + referenceDocPath: 'toc/mark-entry.mdx', + referenceGroup: 'toc', + }, + 'toc.unmarkEntry': { + memberPath: 'toc.unmarkEntry', + description: 'Remove a TC (table of contents entry) field from the document.', + expectedResult: 'Returns a TocEntryMutationResult with the removed entry address on success.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: ['TARGET_NOT_FOUND', 'INVALID_TARGET', 'CAPABILITY_UNAVAILABLE'], + }), + referenceDocPath: 'toc/unmark-entry.mdx', + referenceGroup: 'toc', + }, + 'toc.listEntries': { + memberPath: 'toc.listEntries', + description: 'List all TC (table of contents entry) fields in the document body.', + expectedResult: 'Returns a TocListEntriesResult with an array of TC entry discovery items and pagination metadata.', + requiresDocumentContext: true, + metadata: readOperation({ + idempotency: 'idempotent', + }), + referenceDocPath: 'toc/list-entries.mdx', + referenceGroup: 'toc', + }, + 'toc.getEntry': { + memberPath: 'toc.getEntry', + description: 'Retrieve details of a specific TC (table of contents entry) field.', + expectedResult: 'Returns a TocEntryInfo object with the instruction, text, level, and switch configuration.', + requiresDocumentContext: true, + metadata: readOperation({ + idempotency: 'idempotent', + throws: T_NOT_FOUND, + }), + referenceDocPath: 'toc/get-entry.mdx', + referenceGroup: 'toc', + }, + 'toc.editEntry': { + memberPath: 'toc.editEntry', + description: 'Update the properties of a TC (table of contents entry) field.', + expectedResult: + 'Returns a TocEntryMutationResult with the updated entry address on success, or NO_OP if no change.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: ['TARGET_NOT_FOUND', 'INVALID_TARGET', 'INVALID_INPUT', 'CAPABILITY_UNAVAILABLE'], + }), + referenceDocPath: 'toc/edit-entry.mdx', + referenceGroup: 'toc', + }, + // ------------------------------------------------------------------------- // History // ------------------------------------------------------------------------- diff --git a/packages/document-api/src/contract/operation-registry.ts b/packages/document-api/src/contract/operation-registry.ts index 9f4bad8462..7ba96bc91d 100644 --- a/packages/document-api/src/contract/operation-registry.ts +++ b/packages/document-api/src/contract/operation-registry.ts @@ -119,6 +119,14 @@ import type { TocUpdateInput, TocRemoveInput, TocMutationResult, + TocMarkEntryInput, + TocUnmarkEntryInput, + TocListEntriesQuery, + TocListEntriesResult, + TocGetEntryInput, + TocEntryInfo, + TocEditEntryInput, + TocEntryMutationResult, } from '../toc/toc.types.js'; import type { CreateTableInput, @@ -500,6 +508,13 @@ export interface OperationRegistry extends FormatInlineAliasOperationRegistry { 'toc.configure': { input: TocConfigureInput; options: MutationOptions; output: TocMutationResult }; 'toc.update': { input: TocUpdateInput; options: MutationOptions; output: TocMutationResult }; 'toc.remove': { input: TocRemoveInput; options: MutationOptions; output: TocMutationResult }; + + // --- toc entry (TC field) operations --- + 'toc.markEntry': { input: TocMarkEntryInput; options: MutationOptions; output: TocEntryMutationResult }; + 'toc.unmarkEntry': { input: TocUnmarkEntryInput; options: MutationOptions; output: TocEntryMutationResult }; + 'toc.listEntries': { input: TocListEntriesQuery | undefined; options: never; output: TocListEntriesResult }; + 'toc.getEntry': { input: TocGetEntryInput; options: never; output: TocEntryInfo }; + 'toc.editEntry': { input: TocEditEntryInput; options: MutationOptions; output: TocEntryMutationResult }; } // --- Bidirectional completeness checks --- diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index e82e175a7f..2b84eb8e80 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -1413,6 +1413,7 @@ const tocMutationFailureCodes = [ 'TARGET_NOT_FOUND', 'CAPABILITY_UNAVAILABLE', 'INVALID_INSERTION_CONTEXT', + 'PAGE_NUMBERS_NOT_MATERIALIZED', ] as const; const tocMutationFailureSchema: JsonSchema = objectSchema( @@ -1441,6 +1442,71 @@ function tocMutationResultSchema(): JsonSchema { }; } +// --- TC entry schemas --- + +function tocEntryAddressSchema(): JsonSchema { + return objectSchema( + { + kind: { const: 'inline' }, + nodeType: { const: 'tableOfContentsEntry' }, + nodeId: { type: 'string' }, + }, + ['kind', 'nodeType', 'nodeId'], + ); +} + +function tocEntryInsertionTargetSchema(): JsonSchema { + return objectSchema( + { + kind: { const: 'inline-insert' }, + anchor: objectSchema( + { + nodeType: { const: 'paragraph' }, + nodeId: { type: 'string' }, + }, + ['nodeType', 'nodeId'], + ), + position: { enum: ['start', 'end'] }, + }, + ['kind', 'anchor'], + ); +} + +const tocEntryMutationFailureCodes = [ + 'NO_OP', + 'INVALID_TARGET', + 'TARGET_NOT_FOUND', + 'CAPABILITY_UNAVAILABLE', + 'INVALID_INSERTION_CONTEXT', + 'INVALID_INPUT', +] as const; + +const tocEntryMutationFailureSchema: JsonSchema = objectSchema( + { + success: { const: false }, + failure: objectSchema( + { + code: { enum: [...tocEntryMutationFailureCodes] }, + message: { type: 'string' }, + details: {}, + }, + ['code', 'message'], + ), + }, + ['success', 'failure'], +); + +const tocEntryMutationSuccessSchema: JsonSchema = objectSchema( + { success: { const: true }, entry: tocEntryAddressSchema() }, + ['success', 'entry'], +); + +function tocEntryMutationResultSchema(): JsonSchema { + return { + oneOf: [tocEntryMutationSuccessSchema, tocEntryMutationFailureSchema], + }; +} + const operationSchemas: Record = { find: { input: findInputSchema, @@ -3196,10 +3262,15 @@ const operationSchemas: Record = { config: objectSchema({ outlineLevels: objectSchema({ from: { type: 'integer' }, to: { type: 'integer' } }, ['from', 'to']), useAppliedOutlineLevel: { type: 'boolean' }, + tcFieldIdentifier: { type: 'string' }, + tcFieldLevels: objectSchema({ from: { type: 'integer' }, to: { type: 'integer' } }, ['from', 'to']), hyperlinks: { type: 'boolean' }, hideInWebView: { type: 'boolean' }, omitPageNumberLevels: objectSchema({ from: { type: 'integer' }, to: { type: 'integer' } }, ['from', 'to']), separator: { type: 'string' }, + includePageNumbers: { type: 'boolean' }, + tabLeader: { enum: ['none', 'dot', 'hyphen', 'underscore', 'middleDot'] }, + rightAlignPageNumbers: { type: 'boolean' }, }), }), output: tocMutationResultSchema(), @@ -3262,10 +3333,15 @@ const operationSchemas: Record = { patch: objectSchema({ outlineLevels: objectSchema({ from: { type: 'integer' }, to: { type: 'integer' } }, ['from', 'to']), useAppliedOutlineLevel: { type: 'boolean' }, + tcFieldIdentifier: { type: 'string' }, + tcFieldLevels: objectSchema({ from: { type: 'integer' }, to: { type: 'integer' } }, ['from', 'to']), hyperlinks: { type: 'boolean' }, hideInWebView: { type: 'boolean' }, omitPageNumberLevels: objectSchema({ from: { type: 'integer' }, to: { type: 'integer' } }, ['from', 'to']), separator: { type: 'string' }, + includePageNumbers: { type: 'boolean' }, + tabLeader: { enum: ['none', 'dot', 'hyphen', 'underscore', 'middleDot'] }, + rightAlignPageNumbers: { type: 'boolean' }, }), }, ['target', 'patch'], @@ -3278,6 +3354,7 @@ const operationSchemas: Record = { input: objectSchema( { target: tocAddressSchema(), + mode: { enum: ['all', 'pageNumbers'] }, }, ['target'], ), @@ -3291,6 +3368,95 @@ const operationSchemas: Record = { success: tocMutationSuccessSchema, failure: tocMutationFailureSchema, }, + 'toc.markEntry': { + input: objectSchema( + { + target: tocEntryInsertionTargetSchema(), + text: { type: 'string' }, + level: { type: 'integer', minimum: 1, maximum: 9 }, + tableIdentifier: { type: 'string' }, + omitPageNumber: { type: 'boolean' }, + }, + ['target', 'text'], + ), + output: tocEntryMutationResultSchema(), + success: tocEntryMutationSuccessSchema, + failure: tocEntryMutationFailureSchema, + }, + 'toc.unmarkEntry': { + input: objectSchema({ target: tocEntryAddressSchema() }, ['target']), + output: tocEntryMutationResultSchema(), + success: tocEntryMutationSuccessSchema, + failure: tocEntryMutationFailureSchema, + }, + 'toc.listEntries': { + input: objectSchema({ + tableIdentifier: { type: 'string' }, + levelRange: objectSchema({ from: { type: 'integer' }, to: { type: 'integer' } }, ['from', 'to']), + limit: { type: 'integer' }, + offset: { type: 'integer' }, + }), + output: objectSchema( + { + evaluatedRevision: { type: 'string' }, + total: { type: 'integer' }, + items: arraySchema( + objectSchema( + { + id: { type: 'string' }, + handle: ref('ResolvedHandle'), + address: tocEntryAddressSchema(), + instruction: { type: 'string' }, + text: { type: 'string' }, + level: { type: 'integer' }, + tableIdentifier: { type: 'string' }, + omitPageNumber: { type: 'boolean' }, + }, + ['id', 'handle', 'address', 'instruction', 'text', 'level', 'omitPageNumber'], + ), + ), + page: ref('PageInfo'), + }, + ['evaluatedRevision', 'total', 'items', 'page'], + ), + }, + 'toc.getEntry': { + input: objectSchema({ target: tocEntryAddressSchema() }, ['target']), + output: objectSchema( + { + nodeType: { const: 'tableOfContentsEntry' }, + kind: { const: 'inline' }, + properties: objectSchema( + { + instruction: { type: 'string' }, + text: { type: 'string' }, + level: { type: 'integer' }, + tableIdentifier: { type: 'string' }, + omitPageNumber: { type: 'boolean' }, + }, + ['instruction', 'text', 'level', 'omitPageNumber'], + ), + }, + ['nodeType', 'kind', 'properties'], + ), + }, + 'toc.editEntry': { + input: objectSchema( + { + target: tocEntryAddressSchema(), + patch: objectSchema({ + text: { type: 'string' }, + level: { type: 'integer', minimum: 1, maximum: 9 }, + tableIdentifier: { type: 'string' }, + omitPageNumber: { type: 'boolean' }, + }), + }, + ['target', 'patch'], + ), + output: tocEntryMutationResultSchema(), + success: tocEntryMutationSuccessSchema, + failure: tocEntryMutationFailureSchema, + }, }; /** diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index 9b4bbbd619..d8d2f90225 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -273,7 +273,18 @@ import { executeSectionsSetVerticalAlign, } from './sections/sections.js'; import type { TocApi, TocAdapter } from './toc/toc.js'; -import { executeTocList, executeTocGet, executeTocConfigure, executeTocUpdate, executeTocRemove } from './toc/toc.js'; +import { + executeTocList, + executeTocGet, + executeTocConfigure, + executeTocUpdate, + executeTocRemove, + executeTocMarkEntry, + executeTocUnmarkEntry, + executeTocListEntries, + executeTocGetEntry, + executeTocEditEntry, +} from './toc/toc.js'; import type { CreateTableOfContentsInput, CreateTableOfContentsResult, @@ -285,6 +296,14 @@ import type { TocMutationResult, TocListQuery, TocListResult, + TocMarkEntryInput, + TocUnmarkEntryInput, + TocListEntriesQuery, + TocListEntriesResult, + TocGetEntryInput, + TocEntryInfo, + TocEditEntryInput, + TocEntryMutationResult, } from './toc/toc.types.js'; export type { FindAdapter, FindOptions } from './find/find.js'; @@ -387,6 +406,21 @@ export type { CreateTableOfContentsResult, CreateTableOfContentsSuccess, CreateTableOfContentsFailure, + // TC entry types + TocEntryAddress, + TocEntryInsertionTarget, + TocMarkEntryInput, + TocUnmarkEntryInput, + TocListEntriesQuery, + TocListEntriesResult, + TocGetEntryInput, + TocEntryInfo, + TocEditEntryInput, + TocEntryMutationResult, + TocEntryMutationSuccess, + TocEntryMutationFailure, + TocEntryDomain, + TocEntryProperties, } from './toc/toc.types.js'; export type { ListsAdapter } from './lists/lists.js'; export type { SectionsAdapter } from './sections/sections.js'; @@ -1301,6 +1335,21 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { remove(input: TocRemoveInput, options?: MutationOptions): TocMutationResult { return executeTocRemove(adapters.toc, input, options); }, + markEntry(input: TocMarkEntryInput, options?: MutationOptions): TocEntryMutationResult { + return executeTocMarkEntry(adapters.toc, input, options); + }, + unmarkEntry(input: TocUnmarkEntryInput, options?: MutationOptions): TocEntryMutationResult { + return executeTocUnmarkEntry(adapters.toc, input, options); + }, + listEntries(query?: TocListEntriesQuery): TocListEntriesResult { + return executeTocListEntries(adapters.toc, query); + }, + getEntry(input: TocGetEntryInput): TocEntryInfo { + return executeTocGetEntry(adapters.toc, input); + }, + editEntry(input: TocEditEntryInput, options?: MutationOptions): TocEntryMutationResult { + return executeTocEditEntry(adapters.toc, input, options); + }, }, query: { match(input: QueryMatchInput): QueryMatchOutput { diff --git a/packages/document-api/src/invoke/invoke.ts b/packages/document-api/src/invoke/invoke.ts index 8c183030eb..3d70094024 100644 --- a/packages/document-api/src/invoke/invoke.ts +++ b/packages/document-api/src/invoke/invoke.ts @@ -220,5 +220,12 @@ export function buildDispatchTable(api: DocumentApi): TypedDispatchTable { 'toc.configure': (input, options) => api.toc.configure(input, options), 'toc.update': (input, options) => api.toc.update(input, options), 'toc.remove': (input, options) => api.toc.remove(input, options), + + // --- toc entry (TC field) operations --- + 'toc.markEntry': (input, options) => api.toc.markEntry(input, options), + 'toc.unmarkEntry': (input, options) => api.toc.unmarkEntry(input, options), + 'toc.listEntries': (input) => api.toc.listEntries(input), + 'toc.getEntry': (input) => api.toc.getEntry(input), + 'toc.editEntry': (input, options) => api.toc.editEntry(input, options), }; } diff --git a/packages/document-api/src/toc/toc.ts b/packages/document-api/src/toc/toc.ts index d77f53a03c..32dea156a0 100644 --- a/packages/document-api/src/toc/toc.ts +++ b/packages/document-api/src/toc/toc.ts @@ -11,6 +11,15 @@ import type { TocMutationResult, TocListQuery, TocListResult, + TocEntryAddress, + TocMarkEntryInput, + TocUnmarkEntryInput, + TocListEntriesQuery, + TocListEntriesResult, + TocGetEntryInput, + TocEntryInfo, + TocEditEntryInput, + TocEntryMutationResult, } from './toc.types.js'; // --------------------------------------------------------------------------- @@ -23,12 +32,17 @@ export interface TocApi { configure(input: TocConfigureInput, options?: MutationOptions): TocMutationResult; update(input: TocUpdateInput, options?: MutationOptions): TocMutationResult; remove(input: TocRemoveInput, options?: MutationOptions): TocMutationResult; + markEntry(input: TocMarkEntryInput, options?: MutationOptions): TocEntryMutationResult; + unmarkEntry(input: TocUnmarkEntryInput, options?: MutationOptions): TocEntryMutationResult; + listEntries(query?: TocListEntriesQuery): TocListEntriesResult; + getEntry(input: TocGetEntryInput): TocEntryInfo; + editEntry(input: TocEditEntryInput, options?: MutationOptions): TocEntryMutationResult; } export type TocAdapter = TocApi; // --------------------------------------------------------------------------- -// Target validation (target-only — no nodeId fallback) +// Target validation // --------------------------------------------------------------------------- function validateTocTarget(target: unknown, operationName: string): asserts target is TocAddress { @@ -46,8 +60,45 @@ function validateTocTarget(target: unknown, operationName: string): asserts targ } } +function validateTocEntryTarget(target: unknown, operationName: string): asserts target is TocEntryAddress { + if (target === undefined || target === null) { + throw new DocumentApiValidationError('INVALID_TARGET', `${operationName} requires a target.`); + } + + const t = target as Record; + if (t.kind !== 'inline' || t.nodeType !== 'tableOfContentsEntry' || typeof t.nodeId !== 'string') { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + `${operationName} target must be a TocEntryAddress with kind 'inline', nodeType 'tableOfContentsEntry', and a string nodeId.`, + { target }, + ); + } +} + +function validateInsertionTarget(target: unknown, operationName: string): void { + if (target === undefined || target === null) { + throw new DocumentApiValidationError('INVALID_TARGET', `${operationName} requires a target.`); + } + + const t = target as Record; + if (t.kind !== 'inline-insert') { + throw new DocumentApiValidationError('INVALID_TARGET', `${operationName} target must have kind 'inline-insert'.`, { + target, + }); + } + + const anchor = t.anchor as Record | undefined; + if (!anchor || anchor.nodeType !== 'paragraph' || typeof anchor.nodeId !== 'string') { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + `${operationName} target.anchor must have nodeType 'paragraph' and a string nodeId.`, + { target }, + ); + } +} + // --------------------------------------------------------------------------- -// Execute wrappers +// Execute wrappers — TOC lifecycle // --------------------------------------------------------------------------- export function executeTocList(adapter: TocAdapter, query?: TocListQuery): TocListResult { @@ -85,3 +136,46 @@ export function executeTocRemove( validateTocTarget(input.target, 'toc.remove'); return adapter.remove(input, normalizeMutationOptions(options)); } + +// --------------------------------------------------------------------------- +// Execute wrappers — TC entry operations +// --------------------------------------------------------------------------- + +export function executeTocMarkEntry( + adapter: TocAdapter, + input: TocMarkEntryInput, + options?: MutationOptions, +): TocEntryMutationResult { + validateInsertionTarget(input.target, 'toc.markEntry'); + if (!input.text || typeof input.text !== 'string') { + throw new DocumentApiValidationError('INVALID_INPUT', 'toc.markEntry requires a non-empty text string.'); + } + return adapter.markEntry(input, normalizeMutationOptions(options)); +} + +export function executeTocUnmarkEntry( + adapter: TocAdapter, + input: TocUnmarkEntryInput, + options?: MutationOptions, +): TocEntryMutationResult { + validateTocEntryTarget(input.target, 'toc.unmarkEntry'); + return adapter.unmarkEntry(input, normalizeMutationOptions(options)); +} + +export function executeTocListEntries(adapter: TocAdapter, query?: TocListEntriesQuery): TocListEntriesResult { + return adapter.listEntries(query); +} + +export function executeTocGetEntry(adapter: TocAdapter, input: TocGetEntryInput): TocEntryInfo { + validateTocEntryTarget(input.target, 'toc.getEntry'); + return adapter.getEntry(input); +} + +export function executeTocEditEntry( + adapter: TocAdapter, + input: TocEditEntryInput, + options?: MutationOptions, +): TocEntryMutationResult { + validateTocEntryTarget(input.target, 'toc.editEntry'); + return adapter.editEntry(input, normalizeMutationOptions(options)); +} diff --git a/packages/document-api/src/toc/toc.types.ts b/packages/document-api/src/toc/toc.types.ts index 91761ab559..5a393bbe09 100644 --- a/packages/document-api/src/toc/toc.types.ts +++ b/packages/document-api/src/toc/toc.types.ts @@ -16,15 +16,19 @@ export interface TocAddress { // TOC switch config model // --------------------------------------------------------------------------- -/** Configurable source switches (ship now). */ +/** Configurable source switches. */ export interface TocSourceConfig { /** Outline heading level range from \o switch. */ outlineLevels?: { from: number; to: number }; /** Whether to use applied paragraph outline level (\u switch). */ useAppliedOutlineLevel?: boolean; + /** TC field identifier filter (\f switch). Only TC fields with this identifier are collected. */ + tcFieldIdentifier?: string; + /** TC field level range filter (\l switch). Only TC fields within this range are collected. */ + tcFieldLevels?: { from: number; to: number }; } -/** Configurable display switches (ship now). */ +/** Configurable display switches. */ export interface TocDisplayConfig { /** Make TOC entries hyperlinks (\h switch). */ hyperlinks?: boolean; @@ -34,6 +38,12 @@ export interface TocDisplayConfig { omitPageNumberLevels?: { from: number; to: number }; /** Separator character between entry text and page number (\p switch). */ separator?: string; + /** Whether page numbers are included. Convenience projection of the \n switch. */ + includePageNumbers?: boolean; + /** Tab leader style between entry text and page number. */ + tabLeader?: 'none' | 'dot' | 'hyphen' | 'underscore' | 'middleDot'; + /** Whether TOC entry page numbers use right-aligned tab stops. Stored as a PM node attribute (not a field switch). */ + rightAlignPageNumbers?: boolean; } /** Round-tripped but not configurable via toc.configure. */ @@ -42,10 +52,6 @@ export interface TocPreservedSwitches { customStyles?: Array<{ styleName: string; level: number }>; /** Bookmark name from \b switch. */ bookmarkName?: string; - /** TC field entry identifier from \f switch. */ - tcFieldIdentifier?: string; - /** TC field level range from \l switch. */ - tcFieldLevels?: { from: number; to: number }; /** Caption type from \a switch. */ captionType?: string; /** SEQ field identifier from \c switch. */ @@ -125,6 +131,8 @@ export interface TocConfigureInput { export interface TocUpdateInput { target: TocAddress; + /** Update mode. 'all' rebuilds from sources; 'pageNumbers' updates only page numbers. */ + mode?: 'all' | 'pageNumbers'; } export interface TocRemoveInput { @@ -173,3 +181,121 @@ export interface CreateTableOfContentsFailure { } export type CreateTableOfContentsResult = CreateTableOfContentsSuccess | CreateTableOfContentsFailure; + +// --------------------------------------------------------------------------- +// TC entry address types +// --------------------------------------------------------------------------- + +/** Address for a single TC field node in the document. */ +export interface TocEntryAddress { + kind: 'inline'; + nodeType: 'tableOfContentsEntry'; + /** Public ID (FNV-1a hash of instruction + position, revision-scoped). */ + nodeId: string; +} + +/** Insertion target for toc.markEntry — anchored to a paragraph. */ +export interface TocEntryInsertionTarget { + kind: 'inline-insert'; + /** Insert TC field adjacent to a block-addressed paragraph. */ + anchor: { + nodeType: 'paragraph'; + /** Target paragraph's sdBlockId. */ + nodeId: string; + }; + /** Where within the paragraph to insert. Default: 'end'. */ + position?: 'start' | 'end'; +} + +// --------------------------------------------------------------------------- +// TC entry input types +// --------------------------------------------------------------------------- + +export interface TocMarkEntryInput { + target: TocEntryInsertionTarget; + /** Entry text — required (v1: explicit text only). */ + text: string; + /** TC \l switch level. Default: 1. */ + level?: number; + /** TC \f switch table identifier. */ + tableIdentifier?: string; + /** TC \n switch — omit page number for this entry. Default: false. */ + omitPageNumber?: boolean; +} + +export interface TocUnmarkEntryInput { + target: TocEntryAddress; +} + +export interface TocListEntriesQuery { + /** Filter by TC \f value. */ + tableIdentifier?: string; + /** Filter by level range. */ + levelRange?: { from: number; to: number }; + limit?: number; + offset?: number; +} + +export interface TocGetEntryInput { + target: TocEntryAddress; +} + +export interface TocEditEntryInput { + target: TocEntryAddress; + patch: { + text?: string; + level?: number; + tableIdentifier?: string; + omitPageNumber?: boolean; + }; +} + +// --------------------------------------------------------------------------- +// TC entry info / domain +// --------------------------------------------------------------------------- + +export interface TocEntryProperties { + /** Raw TC instruction string. */ + instruction: string; + /** Entry display text. */ + text: string; + /** TOC level (from \l switch, default 1). */ + level: number; + /** Table identifier from \f switch, if present. */ + tableIdentifier?: string; + /** Whether page number is omitted (\n switch). */ + omitPageNumber: boolean; +} + +export interface TocEntryInfo { + nodeType: 'tableOfContentsEntry'; + kind: 'inline'; + properties: TocEntryProperties; +} + +export interface TocEntryDomain { + address: TocEntryAddress; + instruction: string; + text: string; + level: number; + tableIdentifier?: string; + omitPageNumber: boolean; +} + +export type TocListEntriesResult = DiscoveryOutput; + +// --------------------------------------------------------------------------- +// TC entry mutation results +// --------------------------------------------------------------------------- + +export interface TocEntryMutationSuccess { + success: true; + entry: TocEntryAddress; +} + +export interface TocEntryMutationFailure { + success: false; + failure: ReceiptFailure; +} + +export type TocEntryMutationResult = TocEntryMutationSuccess | TocEntryMutationFailure; diff --git a/packages/document-api/src/types/receipt.ts b/packages/document-api/src/types/receipt.ts index 056a055e84..515c52aff2 100644 --- a/packages/document-api/src/types/receipt.ts +++ b/packages/document-api/src/types/receipt.ts @@ -23,7 +23,8 @@ export type ReceiptFailureCode = | 'INVALID_INSERTION_CONTEXT' | 'DOCUMENT_IDENTITY_CONFLICT' | 'UNSUPPORTED_ENVIRONMENT' - | 'INTERNAL_ERROR'; + | 'INTERNAL_ERROR' + | 'PAGE_NUMBERS_NOT_MATERIALIZED'; export type ReceiptFailure = { code: ReceiptFailureCode; diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index bc3360cd24..576633b1b7 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -3276,6 +3276,29 @@ export class PresentationEditor extends EventEmitter { } const anchorMap = computeAnchorMapFromHelper(bookmarks, layout, blocks); this.#layoutState = { blocks, measures, layout, bookmarks, anchorMap }; + + // Build blockId → pageNumber map for TOC page-number resolution. + // Stored on editor.storage so the document-api adapter layer can read it + // when toc.update({ mode: 'pageNumbers' }) is called. + // pageMapDoc is the doc snapshot this map was derived from — the adapter + // layer compares it against editor.state.doc to reject stale maps. + const tocStorage = ( + this.#editor as unknown as { storage?: Record; pageMapDoc?: unknown }> } + ).storage?.tableOfContents; + if (tocStorage) { + const pageMap = new Map(); + for (const page of layout.pages) { + for (const fragment of page.fragments) { + // First occurrence wins — use the page where the block first appears + if (!pageMap.has(fragment.blockId)) { + pageMap.set(fragment.blockId, page.number); + } + } + } + tocStorage.pageMap = pageMap; + tocStorage.pageMapDoc = this.#editor.state.doc; + } + if (this.#headerFooterSession) { this.#headerFooterSession.headerLayoutResults = headerLayouts ?? null; this.#headerFooterSession.footerLayoutResults = footerLayouts ?? null; diff --git a/packages/super-editor/src/core/super-converter/field-references/fld-preprocessors/index.js b/packages/super-editor/src/core/super-converter/field-references/fld-preprocessors/index.js index c3b15c7b24..a0c5f05d18 100644 --- a/packages/super-editor/src/core/super-converter/field-references/fld-preprocessors/index.js +++ b/packages/super-editor/src/core/super-converter/field-references/fld-preprocessors/index.js @@ -5,6 +5,7 @@ import { preProcessHyperlinkInstruction } from './hyperlink-preprocessor.js'; import { preProcessTocInstruction } from './toc-preprocessor.js'; import { preProcessIndexInstruction } from './index-preprocessor.js'; import { preProcessXeInstruction } from './xe-preprocessor.js'; +import { preProcessTcInstruction as preProcessTcFieldInstruction } from './tc-preprocessor.js'; /** * @callback InstructionPreProcessor @@ -36,6 +37,8 @@ export const getInstructionPreProcessor = (instruction) => { return preProcessIndexInstruction; case 'XE': return preProcessXeInstruction; + case 'TC': + return preProcessTcFieldInstruction; default: return null; } diff --git a/packages/super-editor/src/core/super-converter/field-references/fld-preprocessors/tc-preprocessor.js b/packages/super-editor/src/core/super-converter/field-references/fld-preprocessors/tc-preprocessor.js new file mode 100644 index 0000000000..54223fcd26 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/field-references/fld-preprocessors/tc-preprocessor.js @@ -0,0 +1,21 @@ +/** + * Processes a TC (table of contents entry) instruction and creates an `sd:tableOfContentsEntry` node. + * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. + * @param {string} instrText The instruction text. + * @param {import('../../v2/docxHelper').ParsedDocx} [_docx] The docx object (unused). + * @param {Array<{type: string, text?: string}>} [instructionTokens] Raw instruction tokens. + * @returns {import('../../v2/types/index.js').OpenXmlNode[]} + */ +export function preProcessTcInstruction(nodesToCombine, instrText, _docx, instructionTokens = null) { + return [ + { + name: 'sd:tableOfContentsEntry', + type: 'element', + attributes: { + instruction: instrText, + ...(instructionTokens ? { instructionTokens } : {}), + }, + elements: nodesToCombine, + }, + ]; +} diff --git a/packages/super-editor/src/core/super-converter/field-references/shared/tc-switches.test.ts b/packages/super-editor/src/core/super-converter/field-references/shared/tc-switches.test.ts new file mode 100644 index 0000000000..57585fa7e3 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/field-references/shared/tc-switches.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from 'vitest'; +import { parseTcInstruction, serializeTcInstruction, applyTcPatch, areTcConfigsEqual } from './tc-switches.js'; + +describe('parseTcInstruction', () => { + it('parses basic TC entry with quoted text', () => { + const config = parseTcInstruction('TC "Chapter One"'); + expect(config.text).toBe('Chapter One'); + expect(config.level).toBe(1); + expect(config.omitPageNumber).toBe(false); + expect(config.tableIdentifier).toBeUndefined(); + }); + + it('parses all switches', () => { + const config = parseTcInstruction('TC "Entry" \\f "A" \\l "3" \\n'); + expect(config.text).toBe('Entry'); + expect(config.tableIdentifier).toBe('A'); + expect(config.level).toBe(3); + expect(config.omitPageNumber).toBe(true); + }); + + it('parses unquoted switch arguments', () => { + const config = parseTcInstruction('TC "Entry" \\f A \\l 3 \\n'); + expect(config.tableIdentifier).toBe('A'); + expect(config.level).toBe(3); + expect(config.omitPageNumber).toBe(true); + }); + + it('defaults level to 1 when \\l is absent', () => { + const config = parseTcInstruction('TC "Hello"'); + expect(config.level).toBe(1); + }); + + it('stores unrecognized switches in rawExtensions', () => { + const config = parseTcInstruction('TC "Text" \\x "custom" \\y'); + expect(config.rawExtensions).toEqual(['\\x "custom"', '\\y']); + }); + + it('handles unquoted text before switches', () => { + const config = parseTcInstruction('TC My Text \\l "2"'); + expect(config.text).toBe('My Text'); + expect(config.level).toBe(2); + }); + + it('accepts mixed quoted and unquoted switch arguments', () => { + const config = parseTcInstruction('TC "Entry" \\f "A" \\l 3'); + expect(config.tableIdentifier).toBe('A'); + expect(config.level).toBe(3); + }); +}); + +describe('serializeTcInstruction', () => { + it('serializes basic entry', () => { + expect(serializeTcInstruction({ text: 'Entry', level: 1, omitPageNumber: false })).toBe('TC "Entry"'); + }); + + it('serializes all switches', () => { + const result = serializeTcInstruction({ + text: 'Entry', + tableIdentifier: 'B', + level: 2, + omitPageNumber: true, + }); + expect(result).toBe('TC "Entry" \\f "B" \\l "2" \\n'); + }); + + it('omits \\l when level is 1 (default)', () => { + const result = serializeTcInstruction({ text: 'X', level: 1, omitPageNumber: false }); + expect(result).not.toContain('\\l'); + }); + + it('preserves rawExtensions', () => { + const result = serializeTcInstruction({ + text: 'X', + level: 1, + omitPageNumber: false, + rawExtensions: ['\\z "foo"'], + }); + expect(result).toContain('\\z "foo"'); + }); +}); + +describe('round-trip stability', () => { + it('parse(serialize(parse(input))) === parse(input)', () => { + const inputs = ['TC "Chapter One"', 'TC "Entry" \\f "A" \\l "3" \\n', 'TC "Text" \\x "custom"']; + + for (const input of inputs) { + const first = parseTcInstruction(input); + const serialized = serializeTcInstruction(first); + const second = parseTcInstruction(serialized); + expect(second, `round-trip failed for: ${input}`).toEqual(first); + } + }); +}); + +describe('applyTcPatch', () => { + it('merges partial patch onto existing config', () => { + const existing = parseTcInstruction('TC "Old" \\f "A" \\l "2"'); + const patched = applyTcPatch(existing, { text: 'New' }); + expect(patched.text).toBe('New'); + expect(patched.tableIdentifier).toBe('A'); + expect(patched.level).toBe(2); + }); + + it('preserves unspecified fields', () => { + const existing = parseTcInstruction('TC "Entry" \\f "B" \\n'); + const patched = applyTcPatch(existing, { level: 3 }); + expect(patched.text).toBe('Entry'); + expect(patched.tableIdentifier).toBe('B'); + expect(patched.omitPageNumber).toBe(true); + expect(patched.level).toBe(3); + }); + + it('preserves rawExtensions from existing config', () => { + const existing = parseTcInstruction('TC "X" \\z "custom"'); + const patched = applyTcPatch(existing, { text: 'Y' }); + expect(patched.rawExtensions).toEqual(['\\z "custom"']); + }); +}); + +describe('areTcConfigsEqual', () => { + it('returns true for identical configs', () => { + const a = parseTcInstruction('TC "A" \\f "X" \\l "2"'); + const b = parseTcInstruction('TC "A" \\f "X" \\l "2"'); + expect(areTcConfigsEqual(a, b)).toBe(true); + }); + + it('returns false for different configs', () => { + const a = parseTcInstruction('TC "A" \\l "1"'); + const b = parseTcInstruction('TC "A" \\l "2"'); + expect(areTcConfigsEqual(a, b)).toBe(false); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/field-references/shared/tc-switches.ts b/packages/super-editor/src/core/super-converter/field-references/shared/tc-switches.ts new file mode 100644 index 0000000000..9a6c2f513d --- /dev/null +++ b/packages/super-editor/src/core/super-converter/field-references/shared/tc-switches.ts @@ -0,0 +1,165 @@ +/** + * TC (Table of Contents Entry) instruction parser/serializer — single source of truth. + * + * Handles all OOXML TC field switches: + * - \f (table identifier) — filters which TOC collects this entry + * - \l (level) — entry level (1-based, default 1) + * - \n (omit page number) — suppress page number for this entry + * - Unrecognized switches: stored in rawExtensions for lossless round-trip + * + * TC instruction format: TC "Entry Text" [\f identifier] [\l level] [\n] + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface TcSwitchConfig { + /** The display text of the TC entry (quoted string after "TC"). */ + text: string; + /** Table identifier from \f switch. When set, only TOCs with matching \f collect this entry. */ + tableIdentifier?: string; + /** Entry level from \l switch (1-based). Default: 1. */ + level: number; + /** Whether to omit the page number for this entry (\n switch). */ + omitPageNumber: boolean; + /** Unrecognized switches stored verbatim for lossless round-trip. */ + rawExtensions?: string[]; +} + +export interface TcEditPatch { + text?: string; + tableIdentifier?: string; + level?: number; + omitPageNumber?: boolean; +} + +// --------------------------------------------------------------------------- +// Defaults +// --------------------------------------------------------------------------- + +const DEFAULT_LEVEL = 1; + +// --------------------------------------------------------------------------- +// Parser +// --------------------------------------------------------------------------- + +/** Extracts the quoted entry text from the instruction string. */ +function extractEntryText(instruction: string): { text: string; rest: string } { + // TC "some text" \switches... — the text is the first quoted string after "TC" + const tcPrefix = /^TC\s+/i; + const withoutPrefix = instruction.replace(tcPrefix, ''); + + const quoteMatch = withoutPrefix.match(/^"([^"]*)"/); + if (quoteMatch) { + const text = quoteMatch[1]; + const rest = withoutPrefix.slice(quoteMatch[0].length); + return { text, rest }; + } + + // No quoted text found — entire remainder before first switch is the text + const switchStart = withoutPrefix.indexOf('\\'); + if (switchStart === -1) { + return { text: withoutPrefix.trim(), rest: '' }; + } + return { text: withoutPrefix.slice(0, switchStart).trim(), rest: withoutPrefix.slice(switchStart) }; +} + +/** Regex to match a switch and its optional argument (quoted or unquoted). */ +const SWITCH_PATTERN = /\\([a-z])(?:\s*(?:"([^"]*)"|([^\s\\]+)))?/gi; + +export function parseTcInstruction(instruction: string): TcSwitchConfig { + const { text, rest } = extractEntryText(instruction); + + let level = DEFAULT_LEVEL; + let tableIdentifier: string | undefined; + let omitPageNumber = false; + const rawExtensions: string[] = []; + + let match: RegExpExecArray | null; + SWITCH_PATTERN.lastIndex = 0; + while ((match = SWITCH_PATTERN.exec(rest)) !== null) { + const switchChar = match[1].toLowerCase(); + const arg = match[2] ?? match[3] ?? ''; + + switch (switchChar) { + case 'f': + if (arg) tableIdentifier = arg; + break; + case 'l': { + const parsed = parseInt(arg, 10); + if (!isNaN(parsed) && parsed >= 1) level = parsed; + break; + } + case 'n': + omitPageNumber = true; + break; + default: + rawExtensions.push(arg ? `\\${switchChar} "${arg}"` : `\\${switchChar}`); + break; + } + } + + const config: TcSwitchConfig = { text, level, omitPageNumber }; + if (tableIdentifier !== undefined) config.tableIdentifier = tableIdentifier; + if (rawExtensions.length > 0) config.rawExtensions = rawExtensions; + + return config; +} + +// --------------------------------------------------------------------------- +// Serializer +// --------------------------------------------------------------------------- + +/** + * Serializes a TcSwitchConfig back to a canonical instruction string. + * + * Switch order is deterministic: TC "text" \f \l \n, then rawExtensions. + */ +export function serializeTcInstruction(config: TcSwitchConfig): string { + const parts: string[] = [`TC "${config.text}"`]; + + if (config.tableIdentifier) { + parts.push(`\\f "${config.tableIdentifier}"`); + } + + if (config.level !== DEFAULT_LEVEL) { + parts.push(`\\l "${config.level}"`); + } + + if (config.omitPageNumber) { + parts.push('\\n'); + } + + if (config.rawExtensions?.length) { + parts.push(...config.rawExtensions); + } + + return parts.join(' '); +} + +// --------------------------------------------------------------------------- +// Patch helper (for toc.editEntry) +// --------------------------------------------------------------------------- + +/** + * Merges a TcEditPatch into an existing TcSwitchConfig. + * Only supplied properties mutate; unspecified are preserved. + */ +export function applyTcPatch(existing: TcSwitchConfig, patch: TcEditPatch): TcSwitchConfig { + return { + text: patch.text ?? existing.text, + level: patch.level ?? existing.level, + omitPageNumber: patch.omitPageNumber ?? existing.omitPageNumber, + tableIdentifier: patch.tableIdentifier ?? existing.tableIdentifier, + rawExtensions: existing.rawExtensions, + }; +} + +// --------------------------------------------------------------------------- +// Config equality check (for NO_OP detection) +// --------------------------------------------------------------------------- + +export function areTcConfigsEqual(a: TcSwitchConfig, b: TcSwitchConfig): boolean { + return serializeTcInstruction(a) === serializeTcInstruction(b); +} diff --git a/packages/super-editor/src/core/super-converter/field-references/shared/toc-switches.test.ts b/packages/super-editor/src/core/super-converter/field-references/shared/toc-switches.test.ts index 9c3076fb43..3ee9a5c937 100644 --- a/packages/super-editor/src/core/super-converter/field-references/shared/toc-switches.test.ts +++ b/packages/super-editor/src/core/super-converter/field-references/shared/toc-switches.test.ts @@ -49,8 +49,9 @@ describe('parseTocInstruction', () => { expect(config.preserved.bookmarkName).toBe('Bm1'); expect(config.preserved.seqFieldIdentifier).toBe('SEQ'); expect(config.preserved.chapterSeparator).toBe('.'); - expect(config.preserved.tcFieldIdentifier).toBe('F'); - expect(config.preserved.tcFieldLevels).toEqual({ from: 1, to: 3 }); + // \f and \l are promoted to source config + expect(config.source.tcFieldIdentifier).toBe('F'); + expect(config.source.tcFieldLevels).toEqual({ from: 1, to: 3 }); expect(config.preserved.chapterNumberSource).toBe('Heading1'); expect(config.preserved.preserveTabEntries).toBe(true); }); @@ -58,7 +59,8 @@ describe('parseTocInstruction', () => { it('handles empty instruction', () => { const config = parseTocInstruction('TOC'); expect(config.source).toEqual({}); - expect(config.display).toEqual({}); + // Convenience projections are derived even for bare TOC instructions + expect(config.display).toEqual({ includePageNumbers: true, tabLeader: 'none' }); expect(config.preserved).toEqual({}); }); }); @@ -140,6 +142,58 @@ describe('applyTocPatch', () => { expect(patched.preserved.customStyles).toEqual([{ styleName: 'H1', level: 1 }]); expect(patched.preserved.bookmarkName).toBe('BM'); }); + + it('includePageNumbers: false sets \\n to cover \\o range', () => { + const existing = parseTocInstruction('TOC \\o "1-3" \\h'); + const patched = applyTocPatch(existing, { includePageNumbers: false }); + expect(patched.display.includePageNumbers).toBe(false); + expect(patched.display.omitPageNumberLevels).toEqual({ from: 1, to: 3 }); + }); + + it('includePageNumbers: true removes \\n', () => { + const existing = parseTocInstruction('TOC \\o "1-3" \\n "1-3"'); + const patched = applyTocPatch(existing, { includePageNumbers: true }); + expect(patched.display.includePageNumbers).toBe(true); + expect(patched.display.omitPageNumberLevels).toBeUndefined(); + }); + + it('tabLeader: dot sets \\p separator', () => { + const existing = parseTocInstruction('TOC \\o "1-3"'); + const patched = applyTocPatch(existing, { tabLeader: 'dot' }); + expect(patched.display.tabLeader).toBe('dot'); + expect(patched.display.separator).toBe('.'); + }); + + it('tabLeader: none removes separator', () => { + const existing = parseTocInstruction('TOC \\o "1-3" \\p "."'); + const patched = applyTocPatch(existing, { tabLeader: 'none' }); + expect(patched.display.tabLeader).toBe('none'); + expect(patched.display.separator).toBeUndefined(); + }); + + it('throws on tabLeader + separator conflict', () => { + const existing = parseTocInstruction('TOC \\o "1-3"'); + expect(() => applyTocPatch(existing, { tabLeader: 'dot', separator: '-' })).toThrow('INVALID_INPUT'); + }); + + it('throws on includePageNumbers + omitPageNumberLevels conflict', () => { + const existing = parseTocInstruction('TOC \\o "1-3"'); + expect(() => + applyTocPatch(existing, { includePageNumbers: false, omitPageNumberLevels: { from: 1, to: 2 } }), + ).toThrow('INVALID_INPUT'); + }); + + it('patches tcFieldIdentifier on source config', () => { + const existing = parseTocInstruction('TOC \\o "1-3"'); + const patched = applyTocPatch(existing, { tcFieldIdentifier: 'A' }); + expect(patched.source.tcFieldIdentifier).toBe('A'); + }); + + it('patches tcFieldLevels on source config', () => { + const existing = parseTocInstruction('TOC \\o "1-3"'); + const patched = applyTocPatch(existing, { tcFieldLevels: { from: 1, to: 5 } }); + expect(patched.source.tcFieldLevels).toEqual({ from: 1, to: 5 }); + }); }); describe('areTocConfigsEqual', () => { diff --git a/packages/super-editor/src/core/super-converter/field-references/shared/toc-switches.ts b/packages/super-editor/src/core/super-converter/field-references/shared/toc-switches.ts index d3ef024f8f..c816b0b377 100644 --- a/packages/super-editor/src/core/super-converter/field-references/shared/toc-switches.ts +++ b/packages/super-editor/src/core/super-converter/field-references/shared/toc-switches.ts @@ -2,9 +2,14 @@ * Shared TOC instruction parser/serializer — single source of truth. * * Handles all OOXML TOC field switches: - * - "Ship now" switches: \o, \u, \h, \z, \n, \p (configurable via toc.configure) - * - "Parse-preserve" switches: \t, \b, \f, \l, \a, \c, \d, \s, \w (round-tripped, not configurable) + * - Configurable source switches: \o, \u, \f, \l (via toc.configure) + * - Configurable display switches: \h, \z, \n, \p (via toc.configure) + * - Preserved switches: \t, \b, \a, \c, \d, \s, \w (round-tripped, not configurable) * - Unrecognized switches: stored in rawExtensions for lossless round-trip + * + * Note: `includePageNumbers` and `tabLeader` are convenience projections derived + * from \n and \p respectively. `rightAlignPageNumbers` is NOT handled here — it + * is stored as a PM node attribute on the tableOfContents node. */ import type { @@ -15,6 +20,24 @@ import type { TocConfigurePatch, } from '@superdoc/document-api'; +// --------------------------------------------------------------------------- +// Tab leader mapping +// --------------------------------------------------------------------------- + +const TAB_LEADER_TO_SEPARATOR: Record = { + dot: '.', + hyphen: '-', + underscore: '_', + middleDot: '·', +}; + +const SEPARATOR_TO_TAB_LEADER: Record = { + '.': 'dot', + '-': 'hyphen', + _: 'underscore', + '·': 'middleDot', +}; + // --------------------------------------------------------------------------- // Defaults // --------------------------------------------------------------------------- @@ -64,6 +87,34 @@ function parseCustomStyles(value: string): Array<{ styleName: string; level: num return entries; } +/** + * Derives the `includePageNumbers` boolean from the parsed \n and \o switches. + * + * - \n absent → true (page numbers included) + * - \n present and fully covers \o range (or "1-9" when \o absent) → false + * - \n present but only partially covers → true (partial is not "no page numbers") + */ +export function deriveIncludePageNumbers( + omitRange: { from: number; to: number } | undefined, + outlineLevels: { from: number; to: number } | undefined, +): boolean { + if (!omitRange) return true; + + const effectiveRange = outlineLevels ?? { from: 1, to: 9 }; + const fullyCovered = omitRange.from <= effectiveRange.from && omitRange.to >= effectiveRange.to; + return !fullyCovered; +} + +/** + * Derives the `tabLeader` value from the raw \p separator string. + * Returns undefined if the separator doesn't match a known leader pattern. + */ +function deriveTabLeader(separator: string | undefined): TocDisplayConfig['tabLeader'] | undefined { + if (!separator) return 'none'; + const leader = SEPARATOR_TO_TAB_LEADER[separator]; + return leader as TocDisplayConfig['tabLeader'] | undefined; +} + export function parseTocInstruction(instruction: string): TocSwitchConfig { const source: TocSourceConfig = {}; const display: TocDisplayConfig = {}; @@ -71,12 +122,13 @@ export function parseTocInstruction(instruction: string): TocSwitchConfig { const rawExtensions: string[] = []; let match: RegExpExecArray | null; + SWITCH_PATTERN.lastIndex = 0; while ((match = SWITCH_PATTERN.exec(instruction)) !== null) { const switchChar = match[1].toLowerCase(); const arg = match[2] ?? ''; switch (switchChar) { - // Ship-now source switches + // Configurable source switches case 'o': { const range = parseLevelRange(arg); if (range) source.outlineLevels = range; @@ -85,8 +137,16 @@ export function parseTocInstruction(instruction: string): TocSwitchConfig { case 'u': source.useAppliedOutlineLevel = true; break; + case 'f': + if (arg) source.tcFieldIdentifier = arg; + break; + case 'l': { + const range = parseLevelRange(arg); + if (range) source.tcFieldLevels = range; + break; + } - // Ship-now display switches + // Configurable display switches case 'h': display.hyperlinks = true; break; @@ -102,21 +162,13 @@ export function parseTocInstruction(instruction: string): TocSwitchConfig { if (arg) display.separator = arg; break; - // Parse-preserve switches + // Preserved switches case 't': if (arg) preserved.customStyles = parseCustomStyles(arg); break; case 'b': if (arg) preserved.bookmarkName = arg; break; - case 'f': - if (arg) preserved.tcFieldIdentifier = arg; - break; - case 'l': { - const range = parseLevelRange(arg); - if (range) preserved.tcFieldLevels = range; - break; - } case 'a': if (arg) preserved.captionType = arg; break; @@ -144,6 +196,13 @@ export function parseTocInstruction(instruction: string): TocSwitchConfig { preserved.rawExtensions = rawExtensions; } + // Derive convenience projections + display.includePageNumbers = deriveIncludePageNumbers(display.omitPageNumberLevels, source.outlineLevels); + const tabLeader = deriveTabLeader(display.separator); + if (tabLeader !== undefined) { + display.tabLeader = tabLeader; + } + return { source, display, preserved }; } @@ -155,8 +214,13 @@ export function parseTocInstruction(instruction: string): TocSwitchConfig { * Serializes a TocSwitchConfig back to a canonical instruction string. * * Switch order is deterministic: - * \o, \u, \t, \h, \z, \n, \p, then preserved (\a, \b, \c, \d, \f, \l, \s, \w), + * \o, \u, \f, \l, \t, \h, \z, \n, \p, then preserved (\a, \b, \c, \d, \s, \w), * then rawExtensions in original order. + * + * Note: `includePageNumbers`, `tabLeader`, and `rightAlignPageNumbers` are NOT + * serialized here. `includePageNumbers` controls \n (handled via omitPageNumberLevels). + * `tabLeader` controls \p (handled via separator). `rightAlignPageNumbers` is a + * PM node attribute, not a field switch. */ export function serializeTocInstruction(config: TocSwitchConfig): string { const parts: string[] = ['TOC']; @@ -172,6 +236,16 @@ export function serializeTocInstruction(config: TocSwitchConfig): string { parts.push('\\u'); } + // \f — TC field identifier (promoted from preserved to source) + if (source.tcFieldIdentifier) { + parts.push(`\\f "${source.tcFieldIdentifier}"`); + } + + // \l — TC field levels (promoted from preserved to source) + if (source.tcFieldLevels) { + parts.push(`\\l "${source.tcFieldLevels.from}-${source.tcFieldLevels.to}"`); + } + // \t — custom styles (preserved) if (preserved.customStyles?.length) { const pairs = preserved.customStyles.map((s) => `${s.styleName},${s.level}`).join(','); @@ -198,7 +272,7 @@ export function serializeTocInstruction(config: TocSwitchConfig): string { parts.push(`\\p "${display.separator}"`); } - // Preserved switches in alphabetical order: \a, \b, \c, \d, \f, \l, \s, \w + // Preserved switches in alphabetical order: \a, \b, \c, \d, \s, \w if (preserved.captionType) { parts.push(`\\a "${preserved.captionType}"`); } @@ -211,12 +285,6 @@ export function serializeTocInstruction(config: TocSwitchConfig): string { if (preserved.chapterSeparator) { parts.push(`\\d "${preserved.chapterSeparator}"`); } - if (preserved.tcFieldIdentifier) { - parts.push(`\\f "${preserved.tcFieldIdentifier}"`); - } - if (preserved.tcFieldLevels) { - parts.push(`\\l "${preserved.tcFieldLevels.from}-${preserved.tcFieldLevels.to}"`); - } if (preserved.chapterNumberSource) { parts.push(`\\s "${preserved.chapterNumberSource}"`); } @@ -236,25 +304,88 @@ export function serializeTocInstruction(config: TocSwitchConfig): string { // Patch helper (for toc.configure) // --------------------------------------------------------------------------- +/** + * Computes the \n switch value for `includePageNumbers: false`. + * + * Uses the \o range when present, falls back to "1-9" (full OOXML range). + */ +function computeOmitPageNumberRange(source: TocSourceConfig): { from: number; to: number } { + return source.outlineLevels ?? { from: 1, to: 9 }; +} + /** * Merges a TocConfigurePatch into an existing TocSwitchConfig. - * Only configurable fields (source + display) are patchable. - * Preserved switches are carried through untouched. + * + * Handles conflict validation: + * - `tabLeader` + `separator` in the same patch → Error + * - `includePageNumbers` + `omitPageNumberLevels` in the same patch → Error + * - `includePageNumbers: true` → removes \n switch + * - `includePageNumbers: false` → sets \n to match \o range + * - `tabLeader` → sets \p via mapping */ export function applyTocPatch(existing: TocSwitchConfig, patch: TocConfigurePatch): TocSwitchConfig { + // Conflict: tabLeader vs separator + if (patch.tabLeader !== undefined && patch.separator !== undefined) { + throw new Error('INVALID_INPUT: cannot set both tabLeader and separator in the same patch'); + } + + // Conflict: includePageNumbers vs omitPageNumberLevels + if (patch.includePageNumbers !== undefined && patch.omitPageNumberLevels !== undefined) { + throw new Error('INVALID_INPUT: cannot set both includePageNumbers and omitPageNumberLevels in the same patch'); + } + + const newSource: TocSourceConfig = { + ...existing.source, + ...(patch.outlineLevels !== undefined && { outlineLevels: patch.outlineLevels }), + ...(patch.useAppliedOutlineLevel !== undefined && { useAppliedOutlineLevel: patch.useAppliedOutlineLevel }), + ...(patch.tcFieldIdentifier !== undefined && { tcFieldIdentifier: patch.tcFieldIdentifier }), + ...(patch.tcFieldLevels !== undefined && { tcFieldLevels: patch.tcFieldLevels }), + }; + + const newDisplay: TocDisplayConfig = { + ...existing.display, + ...(patch.hyperlinks !== undefined && { hyperlinks: patch.hyperlinks }), + ...(patch.hideInWebView !== undefined && { hideInWebView: patch.hideInWebView }), + }; + + // Handle includePageNumbers → \n switch mapping + if (patch.includePageNumbers !== undefined) { + if (patch.includePageNumbers) { + // Remove \n entirely + delete newDisplay.omitPageNumberLevels; + } else { + // Set \n to cover the effective range + newDisplay.omitPageNumberLevels = computeOmitPageNumberRange(newSource); + } + newDisplay.includePageNumbers = patch.includePageNumbers; + } else if (patch.omitPageNumberLevels !== undefined) { + newDisplay.omitPageNumberLevels = patch.omitPageNumberLevels; + // Re-derive includePageNumbers from the new omit range + newDisplay.includePageNumbers = deriveIncludePageNumbers(patch.omitPageNumberLevels, newSource.outlineLevels); + } + + // Handle tabLeader → \p switch mapping + if (patch.tabLeader !== undefined) { + if (patch.tabLeader === 'none') { + delete newDisplay.separator; + } else { + newDisplay.separator = TAB_LEADER_TO_SEPARATOR[patch.tabLeader]; + } + newDisplay.tabLeader = patch.tabLeader; + } else if (patch.separator !== undefined) { + newDisplay.separator = patch.separator; + // Re-derive tabLeader from new separator + const derived = deriveTabLeader(patch.separator); + if (derived !== undefined) { + newDisplay.tabLeader = derived; + } else { + delete newDisplay.tabLeader; + } + } + return { - source: { - ...existing.source, - ...(patch.outlineLevels !== undefined && { outlineLevels: patch.outlineLevels }), - ...(patch.useAppliedOutlineLevel !== undefined && { useAppliedOutlineLevel: patch.useAppliedOutlineLevel }), - }, - display: { - ...existing.display, - ...(patch.hyperlinks !== undefined && { hyperlinks: patch.hyperlinks }), - ...(patch.hideInWebView !== undefined && { hideInWebView: patch.hideInWebView }), - ...(patch.omitPageNumberLevels !== undefined && { omitPageNumberLevels: patch.omitPageNumberLevels }), - ...(patch.separator !== undefined && { separator: patch.separator }), - }, + source: newSource, + display: newDisplay, preserved: { ...existing.preserved }, }; } diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/index.js index 9854a1bf24..640cfa3910 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/index.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/index.js @@ -4,6 +4,7 @@ import { translator as sd_pageReference_translator } from './sd/pageReference/pa import { translator as sd_tableOfContents_translator } from './sd/tableOfContents/tableOfContents-translator.js'; import { translator as sd_index_translator } from './sd/index/index-translator.js'; import { translator as sd_indexEntry_translator } from './sd/indexEntry/indexEntry-translator.js'; +import { translator as sd_tableOfContentsEntry_translator } from './sd/tableOfContentsEntry/tableOfContentsEntry-translator.js'; import { translator as sd_autoPageNumber_translator } from './sd/autoPageNumber/autoPageNumber-translator.js'; import { translator as sd_totalPageNumber_translator } from './sd/totalPageNumber/totalPageNumber-translator.js'; import { translator as w_abstractNum_translator } from './w/abstractNum/abstractNum-translator.js'; @@ -205,6 +206,7 @@ const translatorList = Array.from( sd_tableOfContents_translator, sd_index_translator, sd_indexEntry_translator, + sd_tableOfContentsEntry_translator, sd_autoPageNumber_translator, sd_totalPageNumber_translator, w_abstractNum_translator, diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/sd/tableOfContents/tableOfContents-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/sd/tableOfContents/tableOfContents-translator.js index d6ddc4ca23..21d94521aa 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/sd/tableOfContents/tableOfContents-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/sd/tableOfContents/tableOfContents-translator.js @@ -13,6 +13,22 @@ const SD_NODE_NAME = 'tableOfContents'; * @param {import('@translator').SCEncoderConfig} [params] * @returns {import('@translator').SCEncoderResult} */ +/** + * Derives rightAlignPageNumbers from the first entry paragraph's tab stops. + * Returns true if any tab stop has tabType 'right', false otherwise. + * @param {Array<{attrs?: {paragraphProperties?: {tabStops?: Array<{tab?: {tabType?: string}}>}}}>} content + * @returns {boolean} + */ +function deriveRightAlignPageNumbers(content) { + for (const para of content) { + const tabStops = para?.attrs?.paragraphProperties?.tabStops; + if (!Array.isArray(tabStops) || tabStops.length === 0) continue; + return tabStops.some((ts) => ts?.tab?.tabType === 'right'); + } + // No entry paragraphs with tab stops — default true matches Word's typical behavior + return true; +} + const encode = (params) => { const { nodes = [], nodeListHandler } = params || {}; const node = nodes[0]; @@ -25,6 +41,7 @@ const encode = (params) => { type: 'tableOfContents', attrs: { instruction: node.attributes?.instruction || '', + rightAlignPageNumbers: deriveRightAlignPageNumbers(processedContent), }, content: processedContent, }; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/sd/tableOfContents/tableOfContents-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/sd/tableOfContents/tableOfContents-translator.test.js index e1dae4449a..f1d01a7001 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/sd/tableOfContents/tableOfContents-translator.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/sd/tableOfContents/tableOfContents-translator.test.js @@ -48,10 +48,48 @@ describe('sd:tableOfContents translator', () => { }); expect(result).toEqual({ type: 'tableOfContents', - attrs: { instruction: 'TOC \\o "1-3"' }, + attrs: { instruction: 'TOC \\o "1-3"', rightAlignPageNumbers: true }, content: [{ type: 'paragraph', content: [] }], }); }); + + it('derives rightAlignPageNumbers true from right-aligned tab stops', () => { + const mockNodeListHandler = { + handler: vi.fn(() => [ + { + type: 'paragraph', + attrs: { paragraphProperties: { tabStops: [{ tab: { tabType: 'right', pos: 9350 } }] } }, + content: [], + }, + ]), + }; + const params = { + nodes: [{ name: 'sd:tableOfContents', attributes: { instruction: 'TOC \\o "1-3"' }, elements: [] }], + nodeListHandler: mockNodeListHandler, + }; + + const result = config.encode(params); + expect(result.attrs.rightAlignPageNumbers).toBe(true); + }); + + it('derives rightAlignPageNumbers false when no right-aligned tab stops', () => { + const mockNodeListHandler = { + handler: vi.fn(() => [ + { + type: 'paragraph', + attrs: { paragraphProperties: { tabStops: [{ tab: { tabType: 'left', pos: 100 } }] } }, + content: [], + }, + ]), + }; + const params = { + nodes: [{ name: 'sd:tableOfContents', attributes: { instruction: 'TOC \\o "1-3"' }, elements: [] }], + nodeListHandler: mockNodeListHandler, + }; + + const result = config.encode(params); + expect(result.attrs.rightAlignPageNumbers).toBe(false); + }); }); describe('decode', () => { diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/sd/tableOfContentsEntry/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/sd/tableOfContentsEntry/index.js new file mode 100644 index 0000000000..edcdf9d44e --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/sd/tableOfContentsEntry/index.js @@ -0,0 +1 @@ +export * from './tableOfContentsEntry-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/sd/tableOfContentsEntry/tableOfContentsEntry-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/sd/tableOfContentsEntry/tableOfContentsEntry-translator.js new file mode 100644 index 0000000000..4a02c89ea9 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/sd/tableOfContentsEntry/tableOfContentsEntry-translator.js @@ -0,0 +1,121 @@ +// @ts-check +import { NodeTranslator } from '@translator'; +import { exportSchemaToJson, processOutputMarks } from '../../../../exporter.js'; +import { buildInstructionElements } from '../shared/index.js'; + +/** @type {import('@translator').XmlNodeName} */ +const XML_NODE_NAME = 'sd:tableOfContentsEntry'; + +/** @type {import('@translator').SuperDocNodeOrKeyName} */ +const SD_NODE_NAME = 'tableOfContentsEntry'; + +/** + * Encode a node as a SuperDoc tableOfContentsEntry node. + * @param {import('@translator').SCEncoderConfig} [params] + * @returns {import('@translator').SCEncoderResult} + */ +const encode = (params) => { + const { nodes = [], nodeListHandler } = params || {}; + const node = nodes[0]; + + const processedText = nodeListHandler.handler({ + ...params, + nodes: node.elements, + }); + + return { + type: 'tableOfContentsEntry', + attrs: { + instruction: node.attributes?.instruction || '', + instructionTokens: node.attributes?.instructionTokens || null, + marksAsAttrs: node.marks || [], + }, + content: processedText, + }; +}; + +/** + * Decode the tableOfContentsEntry node back into OOXML field structure. + * @param {import('@translator').SCDecoderConfig} params + * @returns {import('@translator').SCDecoderResult[]} + */ +const decode = (params) => { + const { node } = params; + const outputMarks = processOutputMarks(node.attrs?.marksAsAttrs || []); + const contentNodes = (node.content ?? []).flatMap((n) => exportSchemaToJson({ ...params, node: n })); + const instructionElements = buildInstructionElements(node.attrs?.instruction, node.attrs?.instructionTokens); + + return [ + { + name: 'w:r', + elements: [ + { + name: 'w:rPr', + elements: outputMarks, + }, + { + name: 'w:fldChar', + attributes: { + 'w:fldCharType': 'begin', + }, + }, + ], + }, + { + name: 'w:r', + elements: [ + { + name: 'w:rPr', + elements: outputMarks, + }, + ...instructionElements, + ], + }, + { + name: 'w:r', + elements: [ + { + name: 'w:rPr', + elements: outputMarks, + }, + { + name: 'w:fldChar', + attributes: { + 'w:fldCharType': 'separate', + }, + }, + ], + }, + ...contentNodes, + { + name: 'w:r', + elements: [ + { + name: 'w:rPr', + elements: outputMarks, + }, + { + name: 'w:fldChar', + attributes: { + 'w:fldCharType': 'end', + }, + }, + ], + }, + ]; +}; + +/** @type {import('@translator').NodeTranslatorConfig} */ +export const config = { + xmlName: XML_NODE_NAME, + sdNodeOrKeyName: SD_NODE_NAME, + type: NodeTranslator.translatorTypes.NODE, + encode, + decode, +}; + +/** + * The NodeTranslator instance for the sd:tableOfContentsEntry element. + * @type {import('@translator').NodeTranslator} + */ +export const translator = NodeTranslator.from(config); diff --git a/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts b/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts index 5b05e5a55e..e52d4f8dab 100644 --- a/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts +++ b/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts @@ -88,6 +88,12 @@ import { tocRemoveWrapper, createTableOfContentsWrapper, } from '../plan-engine/toc-wrappers.js'; +import { + tocListEntriesWrapper, + tocMarkEntryWrapper, + tocUnmarkEntryWrapper, + tocEditEntryWrapper, +} from '../plan-engine/toc-entry-wrappers.js'; import { listsInsertWrapper, listsSetTypeWrapper, @@ -1827,7 +1833,17 @@ function makeTocEditor(commandOverrides: Record = {}): Editor { isBlock: true, inlineContent: true, }); - const doc = createNode('doc', [tocNode, heading], { isBlock: false }); + const tcEntry = createNode('tableOfContentsEntry', [], { + attrs: { instruction: 'TC "Chapter One" \\f "A" \\l "2"' }, + isInline: true, + isLeaf: true, + }); + const sourceParagraph = createNode('paragraph', [createNode('text', [], { text: 'Body text' }), tcEntry], { + attrs: { sdBlockId: 'p-1' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [tocNode, heading, sourceParagraph], { isBlock: false }); const dispatch = vi.fn(); const tr = { @@ -1851,6 +1867,9 @@ function makeTocEditor(commandOverrides: Record = {}): Editor { setTableOfContentsInstructionById: vi.fn(() => true), replaceTableOfContentsContentById: vi.fn(() => true), deleteTableOfContentsById: vi.fn(() => true), + insertTableOfContentsEntryAt: vi.fn(() => true), + deleteTableOfContentsEntryAt: vi.fn(() => true), + updateTableOfContentsEntryAt: vi.fn(() => true), ...commandOverrides, }, schema: { marks: {} }, @@ -1859,6 +1878,17 @@ function makeTocEditor(commandOverrides: Record = {}): Editor { } as unknown as Editor; } +function getFirstTocEntryAddress(editor: Editor): { kind: 'inline'; nodeType: 'tableOfContentsEntry'; nodeId: string } { + const list = tocListEntriesWrapper(editor); + const first = list.items[0]; + expect(first).toBeDefined(); + return { + kind: 'inline', + nodeType: 'tableOfContentsEntry', + nodeId: first!.address.nodeId, + }; +} + const mutationVectors: Partial> = { 'blocks.delete': { throwCase: () => { @@ -3533,6 +3563,82 @@ const mutationVectors: Partial> = { ); }, }, + 'toc.markEntry': { + throwCase: () => { + const editor = makeTocEditor(); + return tocMarkEntryWrapper( + editor, + { target: { kind: 'inline-insert', anchor: { nodeType: 'paragraph', nodeId: 'missing' } }, text: 'Marked' }, + { changeMode: 'direct' }, + ); + }, + failureCase: () => { + const editor = makeTocEditor({ insertTableOfContentsEntryAt: vi.fn(() => false) }); + return tocMarkEntryWrapper( + editor, + { target: { kind: 'inline-insert', anchor: { nodeType: 'paragraph', nodeId: 'p-1' } }, text: 'Marked' }, + { changeMode: 'direct' }, + ); + }, + applyCase: () => { + const editor = makeTocEditor(); + return tocMarkEntryWrapper( + editor, + { target: { kind: 'inline-insert', anchor: { nodeType: 'paragraph', nodeId: 'p-1' } }, text: 'Marked' }, + { changeMode: 'direct' }, + ); + }, + }, + 'toc.unmarkEntry': { + throwCase: () => { + const editor = makeTocEditor(); + return tocUnmarkEntryWrapper( + editor, + { target: { kind: 'inline', nodeType: 'tableOfContentsEntry', nodeId: 'missing' } }, + { changeMode: 'direct' }, + ); + }, + failureCase: () => { + const editor = makeTocEditor({ deleteTableOfContentsEntryAt: vi.fn(() => false) }); + return tocUnmarkEntryWrapper(editor, { target: getFirstTocEntryAddress(editor) }, { changeMode: 'direct' }); + }, + applyCase: () => { + const editor = makeTocEditor(); + return tocUnmarkEntryWrapper(editor, { target: getFirstTocEntryAddress(editor) }, { changeMode: 'direct' }); + }, + }, + 'toc.editEntry': { + throwCase: () => { + const editor = makeTocEditor(); + return tocEditEntryWrapper( + editor, + { + target: { kind: 'inline', nodeType: 'tableOfContentsEntry', nodeId: 'missing' }, + patch: { text: 'Updated' }, + }, + { changeMode: 'direct' }, + ); + }, + failureCase: () => { + const editor = makeTocEditor(); + return tocEditEntryWrapper( + editor, + { + target: getFirstTocEntryAddress(editor), + patch: { text: 'Chapter One', level: 2, tableIdentifier: 'A', omitPageNumber: false }, + }, + { changeMode: 'direct' }, + ); + }, + applyCase: () => { + const editor = makeTocEditor(); + return tocEditEntryWrapper( + editor, + { target: getFirstTocEntryAddress(editor), patch: { text: 'Updated Chapter' } }, + { changeMode: 'direct' }, + ); + }, + }, }; const dryRunVectors: Partial unknown>> = { @@ -4329,6 +4435,39 @@ const dryRunVectors: Partial unknown>> = { expect(deleteById).not.toHaveBeenCalled(); return result; }, + 'toc.markEntry': () => { + const insertEntry = vi.fn(() => true); + const editor = makeTocEditor({ insertTableOfContentsEntryAt: insertEntry }); + const result = tocMarkEntryWrapper( + editor, + { target: { kind: 'inline-insert', anchor: { nodeType: 'paragraph', nodeId: 'p-1' } }, text: 'Dry mark' }, + { changeMode: 'direct', dryRun: true }, + ); + expect(insertEntry).not.toHaveBeenCalled(); + return result; + }, + 'toc.unmarkEntry': () => { + const deleteEntry = vi.fn(() => true); + const editor = makeTocEditor({ deleteTableOfContentsEntryAt: deleteEntry }); + const result = tocUnmarkEntryWrapper( + editor, + { target: getFirstTocEntryAddress(editor) }, + { changeMode: 'direct', dryRun: true }, + ); + expect(deleteEntry).not.toHaveBeenCalled(); + return result; + }, + 'toc.editEntry': () => { + const updateEntry = vi.fn(() => true); + const editor = makeTocEditor({ updateTableOfContentsEntryAt: updateEntry }); + const result = tocEditEntryWrapper( + editor, + { target: getFirstTocEntryAddress(editor), patch: { text: 'Dry edit' } }, + { changeMode: 'direct', dryRun: true }, + ); + expect(updateEntry).not.toHaveBeenCalled(); + return result; + }, }; beforeEach(() => { diff --git a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts index 6b3a769835..c775145470 100644 --- a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts +++ b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts @@ -124,6 +124,13 @@ import { tocRemoveWrapper, createTableOfContentsWrapper, } from './plan-engine/toc-wrappers.js'; +import { + tocListEntriesWrapper, + tocGetEntryWrapper, + tocMarkEntryWrapper, + tocUnmarkEntryWrapper, + tocEditEntryWrapper, +} from './plan-engine/toc-entry-wrappers.js'; /** * Assembles all document-api adapters for the given editor instance. @@ -280,6 +287,11 @@ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters configure: (input, options) => tocConfigureWrapper(editor, input, options), update: (input, options) => tocUpdateWrapper(editor, input, options), remove: (input, options) => tocRemoveWrapper(editor, input, options), + markEntry: (input, options) => tocMarkEntryWrapper(editor, input, options), + unmarkEntry: (input, options) => tocUnmarkEntryWrapper(editor, input, options), + listEntries: (query) => tocListEntriesWrapper(editor, query), + getEntry: (input) => tocGetEntryWrapper(editor, input), + editEntry: (input, options) => tocEditEntryWrapper(editor, input, options), }, query: { match: (input) => queryMatchAdapter(editor, input), diff --git a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts index 367f29c61a..1296e9fd39 100644 --- a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts @@ -93,6 +93,10 @@ const REQUIRED_COMMANDS: Partial { + describe('rightAlignPageNumbers', () => { + it('adds a right-aligned tab stop when rightAlignPageNumbers is true', () => { + const paragraphs = buildTocEntryParagraphs([BASE_SOURCE], makeConfig({ rightAlignPageNumbers: true })); + const tabStops = paragraphs[0]!.attrs.paragraphProperties as Record; + expect(tabStops.tabStops).toEqual([{ tab: { tabType: 'right', pos: 9350 } }]); + }); + + it('adds a right-aligned tab stop by default (undefined)', () => { + const paragraphs = buildTocEntryParagraphs([BASE_SOURCE], makeConfig()); + const tabStops = paragraphs[0]!.attrs.paragraphProperties as Record; + expect(tabStops.tabStops).toEqual([{ tab: { tabType: 'right', pos: 9350 } }]); + }); + + it('omits tab stop when rightAlignPageNumbers is false', () => { + const paragraphs = buildTocEntryParagraphs([BASE_SOURCE], makeConfig({ rightAlignPageNumbers: false })); + const props = paragraphs[0]!.attrs.paragraphProperties as Record; + expect(props.tabStops).toBeUndefined(); + }); + + it('includes dot leader when tabLeader is dot', () => { + const paragraphs = buildTocEntryParagraphs( + [BASE_SOURCE], + makeConfig({ rightAlignPageNumbers: true, tabLeader: 'dot' }), + ); + const props = paragraphs[0]!.attrs.paragraphProperties as Record; + expect(props.tabStops).toEqual([{ tab: { tabType: 'right', pos: 9350, leader: 'dot' } }]); + }); + + it('omits leader when tabLeader is none', () => { + const paragraphs = buildTocEntryParagraphs( + [BASE_SOURCE], + makeConfig({ rightAlignPageNumbers: true, tabLeader: 'none' }), + ); + const props = paragraphs[0]!.attrs.paragraphProperties as Record; + expect(props.tabStops).toEqual([{ tab: { tabType: 'right', pos: 9350 } }]); + }); + + it('does not add tab stop when page numbers are omitted', () => { + const paragraphs = buildTocEntryParagraphs( + [BASE_SOURCE], + makeConfig({ rightAlignPageNumbers: true, omitPageNumberLevels: { from: 1, to: 9 } }), + ); + const props = paragraphs[0]!.attrs.paragraphProperties as Record; + expect(props.tabStops).toBeUndefined(); + }); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/helpers/toc-entry-builder.ts b/packages/super-editor/src/document-api-adapters/helpers/toc-entry-builder.ts index 0393a4cd50..94a3197a0e 100644 --- a/packages/super-editor/src/document-api-adapters/helpers/toc-entry-builder.ts +++ b/packages/super-editor/src/document-api-adapters/helpers/toc-entry-builder.ts @@ -1,66 +1,127 @@ /** - * TOC entry builder — rebuilds TOC materialized content from document headings. + * TOC entry builder — rebuilds TOC materialized content from document sources. * - * This is the core algorithm for TOC materialization and refresh. + * Collects heading nodes AND TC field nodes based on the TOC instruction's + * source switches, then builds materialized paragraph JSON for the TOC. */ import type { Node as ProseMirrorNode } from 'prosemirror-model'; import type { TocSwitchConfig } from '@superdoc/document-api'; +import { parseTcInstruction } from '../../core/super-converter/field-references/shared/tc-switches.js'; import { getHeadingLevel } from './node-address-resolver.js'; // --------------------------------------------------------------------------- -// Heading source collection +// Source types // --------------------------------------------------------------------------- -interface HeadingSource { +export interface TocSource { + /** Display text for this entry. */ text: string; + /** TOC level (1-based). */ level: number; + /** + * sdBlockId of the source paragraph. + * For headings: the heading paragraph's sdBlockId. + * For TC fields: the containing paragraph's sdBlockId. + */ sdBlockId: string; + /** Source type for diagnostic purposes. */ + kind: 'heading' | 'appliedOutline' | 'tcField'; + /** Whether to omit the page number for this specific entry (TC \n switch). */ + omitPageNumber?: boolean; } +// --------------------------------------------------------------------------- +// Source collection +// --------------------------------------------------------------------------- + /** * Collects all document nodes that qualify as TOC entry sources. * - * Qualification rules: - * - \o (outlineLevels): heading nodes whose level falls within the specified range - * - \u (useAppliedOutlineLevel): also includes paragraph nodes with explicit outlineLevel + * Sources are collected based on the instruction's active switches: + * - \o (outlineLevels): heading nodes whose level falls within the range + * - \u (useAppliedOutlineLevel): paragraph nodes with explicit outlineLevel + * - \f (tcFieldIdentifier): TC field nodes with matching identifier + * - \l (tcFieldLevels): TC field nodes within the level range + * + * All sources are merged into a single list sorted by document position. + * No deduplication — TC fields and headings at the same position are both included. */ -export function collectHeadingSources(doc: ProseMirrorNode, config: TocSwitchConfig): HeadingSource[] { - const sources: HeadingSource[] = []; - const { outlineLevels } = config.source; - const useApplied = config.source.useAppliedOutlineLevel ?? false; +export function collectTocSources(doc: ProseMirrorNode, config: TocSwitchConfig): TocSource[] { + const sources: TocSource[] = []; + const { outlineLevels, useAppliedOutlineLevel, tcFieldIdentifier, tcFieldLevels } = config.source; + const useApplied = useAppliedOutlineLevel ?? false; + const collectTcFields = tcFieldIdentifier !== undefined || tcFieldLevels !== undefined; + + // Track the current paragraph context for TC field collection + let currentParagraphSdBlockId: string | undefined; doc.descendants((node, _pos) => { - if (node.type.name === 'tableOfContents') return false; // skip TOC nodes themselves + // Skip TOC nodes themselves — don't collect entries from within a TOC + if (node.type.name === 'tableOfContents') return false; if (node.type.name === 'paragraph') { const attrs = node.attrs as Record | undefined; const paragraphProps = attrs?.paragraphProperties as Record | undefined; const styleId = paragraphProps?.styleId as string | undefined; const sdBlockId = (attrs?.sdBlockId ?? attrs?.paraId) as string | undefined; + + // Update paragraph context for TC field collection + currentParagraphSdBlockId = sdBlockId; + if (!sdBlockId) return true; - // Check if it's a heading (by style) - const headingLevel = getHeadingLevel(styleId); - if (headingLevel != null && outlineLevels) { - if (headingLevel >= outlineLevels.from && headingLevel <= outlineLevels.to) { - sources.push({ text: flattenText(node), level: headingLevel, sdBlockId }); - return false; + // Check heading by style (\o switch) + if (outlineLevels) { + const headingLevel = getHeadingLevel(styleId); + if (headingLevel != null && headingLevel >= outlineLevels.from && headingLevel <= outlineLevels.to) { + sources.push({ text: flattenText(node), level: headingLevel, sdBlockId, kind: 'heading' }); + // Continue descending to find TC fields within this paragraph + return true; } } // Check applied outline level (\u switch) - // outlineLevel is 0-based in PM attrs; TOC levels are 1-based (same as node-info-mapper) if (useApplied && outlineLevels) { const rawOutlineLevel = paragraphProps?.outlineLevel as number | undefined; if (rawOutlineLevel != null) { const tocLevel = rawOutlineLevel + 1; if (tocLevel >= outlineLevels.from && tocLevel <= outlineLevels.to) { - sources.push({ text: flattenText(node), level: tocLevel, sdBlockId }); - return false; + sources.push({ text: flattenText(node), level: tocLevel, sdBlockId, kind: 'appliedOutline' }); + return true; } } } + + return true; + } + + // Collect TC field nodes (\f and/or \l switches) + if (collectTcFields && node.type.name === 'tableOfContentsEntry' && currentParagraphSdBlockId) { + const instruction = (node.attrs?.instruction as string) ?? ''; + const tcConfig = parseTcInstruction(instruction); + + // Filter by \f identifier + if (tcFieldIdentifier && tcConfig.tableIdentifier !== tcFieldIdentifier) { + return false; + } + + // Filter by \l level range + if (tcFieldLevels) { + if (tcConfig.level < tcFieldLevels.from || tcConfig.level > tcFieldLevels.to) { + return false; + } + } + + sources.push({ + text: tcConfig.text, + level: tcConfig.level, + sdBlockId: currentParagraphSdBlockId, + kind: 'tcField', + omitPageNumber: tcConfig.omitPageNumber || undefined, + }); + + return false; } return true; @@ -69,6 +130,9 @@ export function collectHeadingSources(doc: ProseMirrorNode, config: TocSwitchCon return sources; } +/** @deprecated Use `collectTocSources` instead. Kept for backward compatibility. */ +export const collectHeadingSources = collectTocSources; + function flattenText(node: ProseMirrorNode): string { let text = ''; node.descendants((child) => { @@ -93,15 +157,27 @@ export interface EntryParagraphJson { * * Each entry gets: * - Paragraph style: TOC{level} + * - tocSourceId paragraph attribute (source heading/TC field's sdBlockId) * - Link mark with anchor pointing to source sdBlockId (when \h is set) - * - Page number placeholder "0" (accurate page numbers require layout-engine) - * - Separator: custom (\p switch) or default tab with dot leader + * - Page number placeholder "0" with tocPageNumber mark + * - Separator: custom (\p switch) or default tab */ -export function buildTocEntryParagraphs(sources: HeadingSource[], config: TocSwitchConfig): EntryParagraphJson[] { +export function buildTocEntryParagraphs(sources: TocSource[], config: TocSwitchConfig): EntryParagraphJson[] { return sources.map((source) => buildEntryParagraph(source, config)); } -function buildEntryParagraph(source: HeadingSource, config: TocSwitchConfig): EntryParagraphJson { +/** Default right-margin position for right-aligned tab stops (twips). ~6.5 inches. */ +const DEFAULT_RIGHT_TAB_POS = 9350; + +/** Maps tabLeader display config values to OOXML leader attribute values. */ +const TAB_LEADER_MAP: Record = { + dot: 'dot', + hyphen: 'hyphen', + underscore: 'heavy', + middleDot: 'middleDot', +}; + +function buildEntryParagraph(source: TocSource, config: TocSwitchConfig): EntryParagraphJson { const { display } = config; const content: Array> = []; @@ -126,9 +202,11 @@ function buildEntryParagraph(source: HeadingSource, config: TocSwitchConfig): En content.push(textNode); - // Page number (unless level is in \n exclusion range) + // Determine whether to omit page number for this entry const omitRange = display.omitPageNumberLevels; - const omitPageNumber = omitRange && source.level >= omitRange.from && source.level <= omitRange.to; + const levelOmitted = omitRange && source.level >= omitRange.from && source.level <= omitRange.to; + const entryOmitted = source.omitPageNumber; + const omitPageNumber = levelOmitted || entryOmitted; if (!omitPageNumber) { // Separator between entry text and page number (\p switch overrides default tab) @@ -138,17 +216,34 @@ function buildEntryParagraph(source: HeadingSource, config: TocSwitchConfig): En content.push({ type: 'tab' }); } - // Page number placeholder (accurate numbers require layout-engine) - content.push({ type: 'text', text: '0' }); + // Page number placeholder with tocPageNumber mark for surgical updates + content.push({ + type: 'text', + text: '0', + marks: [{ type: 'tocPageNumber' }], + }); + } + + // Build paragraph properties — add right-aligned tab stop when enabled + const paragraphProperties: Record = { + styleId: `TOC${source.level}`, + }; + + const rightAlign = display.rightAlignPageNumbers !== false; // default true + if (rightAlign && !omitPageNumber) { + const leader = + display.tabLeader && display.tabLeader !== 'none' ? (TAB_LEADER_MAP[display.tabLeader] ?? undefined) : undefined; + paragraphProperties.tabStops = [ + { tab: { tabType: 'right', pos: DEFAULT_RIGHT_TAB_POS, ...(leader ? { leader } : {}) } }, + ]; } return { type: 'paragraph', attrs: { - paragraphProperties: { - styleId: `TOC${source.level}`, - }, - sdBlockId: undefined, // will be assigned by the editor + paragraphProperties, + sdBlockId: undefined, // assigned by the editor on insertion + tocSourceId: source.sdBlockId, // anchors page-number lookup to source paragraph }, content, }; diff --git a/packages/super-editor/src/document-api-adapters/helpers/toc-entry-node-id.ts b/packages/super-editor/src/document-api-adapters/helpers/toc-entry-node-id.ts new file mode 100644 index 0000000000..e6d5774ebd --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/toc-entry-node-id.ts @@ -0,0 +1,34 @@ +/** + * TC entry node ID generation — deterministic, revision-scoped IDs for tableOfContentsEntry nodes. + * + * Follows the same FNV-1a strategy as toc-node-id.ts but scoped to inline TC field nodes. + * IDs are content-addressed: they change when the instruction or position changes. + */ + +import type { Node as ProseMirrorNode } from 'prosemirror-model'; + +/** FNV-1a 32-bit hash — fast, non-cryptographic, deterministic. */ +function stableHash(input: string): string { + let hash = 2166136261; // FNV offset basis + for (let index = 0; index < input.length; index += 1) { + hash ^= input.charCodeAt(index); + hash = Math.imul(hash, 16777619); // FNV prime + } + return (hash >>> 0).toString(16).padStart(8, '0'); +} + +/** + * Produces a deterministic public ID for a tableOfContentsEntry inline node. + * + * The ID is derived from the node's position and instruction text, making it + * stable within a document revision but not across edits that change position + * or content (revision-scoped). + * + * @param node - The tableOfContentsEntry ProseMirror node. + * @param pos - The node's absolute position in the document. + * @returns A deterministic string id prefixed with `tc-entry-`. + */ +export function resolvePublicTcEntryNodeId(node: ProseMirrorNode, pos: number): string { + const instruction = typeof node.attrs?.instruction === 'string' ? node.attrs.instruction : ''; + return `tc-entry-${stableHash(`${pos}:${instruction}`)}`; +} diff --git a/packages/super-editor/src/document-api-adapters/helpers/toc-entry-resolver.ts b/packages/super-editor/src/document-api-adapters/helpers/toc-entry-resolver.ts new file mode 100644 index 0000000000..ac3f6bc76e --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/toc-entry-resolver.ts @@ -0,0 +1,197 @@ +/** + * TC entry resolver — finds, resolves, and extracts info from tableOfContentsEntry nodes. + * + * Mirrors the toc-resolver.ts pattern but operates on inline TC field nodes + * rather than block-level TOC nodes. + */ + +import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import type { Editor } from '../../core/Editor.js'; +import type { TocEntryAddress, TocEntryDomain, DiscoveryItem, TocEntryInfo } from '@superdoc/document-api'; +import { buildDiscoveryItem, buildResolvedHandle } from '@superdoc/document-api'; +import { parseTcInstruction } from '../../core/super-converter/field-references/shared/tc-switches.js'; +import { DocumentApiAdapterError } from '../errors.js'; +import { resolvePublicTcEntryNodeId } from './toc-entry-node-id.js'; +import { buildBlockIndex, findBlockByNodeIdOnly } from './node-address-resolver.js'; + +// --------------------------------------------------------------------------- +// Resolved TC entry node +// --------------------------------------------------------------------------- + +export interface ResolvedTcEntryNode { + node: ProseMirrorNode; + pos: number; + /** Deterministic public node ID (FNV-1a hash of position + instruction). */ + nodeId: string; + /** sdBlockId of the paragraph containing this TC field. */ + containingParagraphSdBlockId?: string; +} + +// --------------------------------------------------------------------------- +// Node discovery +// --------------------------------------------------------------------------- + +/** + * Finds all tableOfContentsEntry nodes in document order. + * + * Tracks the containing paragraph's sdBlockId for each TC field so that + * callers can anchor page-number lookups to block-level IDs. + */ +export function findAllTcEntryNodes(doc: ProseMirrorNode): ResolvedTcEntryNode[] { + const results: ResolvedTcEntryNode[] = []; + let currentParagraphSdBlockId: string | undefined; + + doc.descendants((node, pos) => { + // Skip TOC nodes — TC entries inside a TOC are materialized content, not source fields + if (node.type.name === 'tableOfContents') return false; + + if (node.type.name === 'paragraph') { + const attrs = node.attrs as Record | undefined; + currentParagraphSdBlockId = (attrs?.sdBlockId ?? attrs?.paraId) as string | undefined; + return true; + } + + if (node.type.name === 'tableOfContentsEntry') { + const nodeId = resolvePublicTcEntryNodeId(node, pos); + results.push({ node, pos, nodeId, containingParagraphSdBlockId: currentParagraphSdBlockId }); + return false; + } + + return true; + }); + + return results; +} + +// --------------------------------------------------------------------------- +// Target resolution +// --------------------------------------------------------------------------- + +/** + * Resolves a TocEntryAddress to its ProseMirror node and position. + * + * @throws DocumentApiAdapterError with code TARGET_NOT_FOUND if not found. + */ +export function resolveTcEntryTarget(doc: ProseMirrorNode, target: TocEntryAddress): ResolvedTcEntryNode { + const all = findAllTcEntryNodes(doc); + const found = all.find((entry) => entry.nodeId === target.nodeId); + if (!found) { + throw new DocumentApiAdapterError( + 'TARGET_NOT_FOUND', + `Table of contents entry with nodeId "${target.nodeId}" not found.`, + ); + } + return found; +} + +// --------------------------------------------------------------------------- +// Paragraph resolution (for toc.markEntry insertion target) +// --------------------------------------------------------------------------- + +interface ResolvedParagraph { + node: ProseMirrorNode; + pos: number; + sdBlockId: string; +} + +/** + * Finds a paragraph node by its sdBlockId. + * + * @throws DocumentApiAdapterError with code TARGET_NOT_FOUND if no paragraph matches. + */ +export function findParagraphBySdBlockId(doc: ProseMirrorNode, sdBlockId: string, editor?: Editor): ResolvedParagraph { + let found: ResolvedParagraph | undefined; + + doc.descendants((node, pos) => { + if (found) return false; + + // Skip TOC nodes — don't insert TC fields inside TOC materialized content + if (node.type.name === 'tableOfContents') return false; + + if (node.type.name === 'paragraph') { + const attrs = node.attrs as Record | undefined; + const nodeId = (attrs?.sdBlockId ?? attrs?.paraId) as string | undefined; + if (nodeId === sdBlockId) { + found = { node, pos, sdBlockId }; + return false; + } + } + + return true; + }); + + if (!found) { + if (editor) { + try { + const block = findBlockByNodeIdOnly(buildBlockIndex(editor), sdBlockId); + if (block.node.type.name === 'paragraph') { + return { node: block.node, pos: block.pos, sdBlockId }; + } + } catch { + // Ignore and throw canonical TARGET_NOT_FOUND below. + } + } + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', `Paragraph with sdBlockId "${sdBlockId}" not found.`); + } + + return found; +} + +// --------------------------------------------------------------------------- +// Info extraction +// --------------------------------------------------------------------------- + +/** + * Extracts full TC entry metadata from a resolved node. + */ +export function extractTcEntryInfo(resolved: ResolvedTcEntryNode): TocEntryInfo { + const instruction: string = resolved.node.attrs?.instruction ?? ''; + const config = parseTcInstruction(instruction); + + return { + nodeType: 'tableOfContentsEntry', + kind: 'inline', + properties: { + instruction, + text: config.text, + level: config.level, + tableIdentifier: config.tableIdentifier, + omitPageNumber: config.omitPageNumber, + }, + }; +} + +// --------------------------------------------------------------------------- +// Discovery item builder +// --------------------------------------------------------------------------- + +/** + * Builds a discovery item for a single TC entry node. + */ +export function buildTcEntryDiscoveryItem( + resolved: ResolvedTcEntryNode, + evaluatedRevision: string, +): DiscoveryItem { + const instruction: string = resolved.node.attrs?.instruction ?? ''; + const config = parseTcInstruction(instruction); + + const address: TocEntryAddress = { + kind: 'inline', + nodeType: 'tableOfContentsEntry', + nodeId: resolved.nodeId, + }; + + const handle = buildResolvedHandle(resolved.nodeId, 'ephemeral', 'field'); + + const domain: TocEntryDomain = { + address, + instruction, + text: config.text, + level: config.level, + tableIdentifier: config.tableIdentifier, + omitPageNumber: config.omitPageNumber, + }; + + const id = `tc-entry:${resolved.nodeId}:${evaluatedRevision}`; + return buildDiscoveryItem(id, handle, domain); +} diff --git a/packages/super-editor/src/document-api-adapters/helpers/toc-resolver.ts b/packages/super-editor/src/document-api-adapters/helpers/toc-resolver.ts index 74bc310d39..6534f218a1 100644 --- a/packages/super-editor/src/document-api-adapters/helpers/toc-resolver.ts +++ b/packages/super-editor/src/document-api-adapters/helpers/toc-resolver.ts @@ -81,6 +81,7 @@ export function resolvePostMutationTocId(doc: ProseMirrorNode, sdBlockId: string export function extractTocInfo(node: ProseMirrorNode): TocInfo { const instruction: string = node.attrs?.instruction ?? ''; const config = parseTocInstruction(instruction); + const rightAlign = node.attrs?.rightAlignPageNumbers; return { nodeType: 'tableOfContents', @@ -88,7 +89,10 @@ export function extractTocInfo(node: ProseMirrorNode): TocInfo { properties: { instruction, sourceConfig: config.source, - displayConfig: config.display, + displayConfig: { + ...config.display, + ...(rightAlign !== undefined && { rightAlignPageNumbers: rightAlign }), + }, preservedSwitches: config.preserved, entryCount: node.childCount, }, @@ -111,11 +115,15 @@ export function buildTocDiscoveryItem(resolved: ResolvedTocNode, evaluatedRevisi const handle = buildResolvedHandle(resolved.nodeId, 'stable', 'tableOfContents'); + const rightAlign = resolved.node.attrs?.rightAlignPageNumbers; const domain: TocDomain = { address, instruction, sourceConfig: config.source, - displayConfig: config.display, + displayConfig: { + ...config.display, + ...(rightAlign !== undefined && { rightAlignPageNumbers: rightAlign }), + }, preserved: config.preserved, entryCount: resolved.node.childCount, }; diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/toc-entry-wrappers.test.ts b/packages/super-editor/src/document-api-adapters/plan-engine/toc-entry-wrappers.test.ts new file mode 100644 index 0000000000..adc8b23824 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/toc-entry-wrappers.test.ts @@ -0,0 +1,348 @@ +import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Editor } from '../../core/Editor.js'; +import type { PlanReceipt } from '@superdoc/document-api'; + +vi.mock('./plan-wrappers.js', () => ({ + executeDomainCommand: vi.fn((_editor: Editor, handler: () => boolean): PlanReceipt => { + const applied = handler(); + return { + success: true, + revision: { before: '0', after: '0' }, + steps: [ + { + stepId: 'step-1', + op: 'domain.command', + effect: applied ? 'changed' : 'noop', + matchCount: applied ? 1 : 0, + data: { domain: 'command', commandDispatched: applied }, + }, + ], + timing: { totalMs: 0 }, + }; + }), +})); + +import { + tocListEntriesWrapper, + tocGetEntryWrapper, + tocMarkEntryWrapper, + tocUnmarkEntryWrapper, + tocEditEntryWrapper, +} from './toc-entry-wrappers.js'; +import { DocumentApiAdapterError } from '../errors.js'; +import { DocumentApiValidationError } from '@superdoc/document-api'; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +type NodeOptions = { + attrs?: Record; + text?: string; + isInline?: boolean; + isBlock?: boolean; + isLeaf?: boolean; + inlineContent?: boolean; + nodeSize?: number; +}; + +function createNode(typeName: string, children: ProseMirrorNode[] = [], options: NodeOptions = {}): ProseMirrorNode { + const attrs = options.attrs ?? {}; + const text = options.text ?? ''; + const isText = typeName === 'text'; + const isInline = options.isInline ?? isText; + const isBlock = options.isBlock ?? (!isInline && typeName !== 'doc'); + const inlineContent = options.inlineContent ?? isBlock; + const isLeaf = options.isLeaf ?? (isInline && !isText && children.length === 0); + + const contentSize = children.reduce((sum, child) => sum + child.nodeSize, 0); + const nodeSize = isText ? text.length : options.nodeSize != null ? options.nodeSize : isLeaf ? 1 : contentSize + 2; + + const node = { + type: { name: typeName }, + attrs, + text: isText ? text : undefined, + content: { size: contentSize }, + nodeSize, + isText, + isInline, + isBlock, + inlineContent, + isTextblock: inlineContent, + isLeaf, + childCount: children.length, + child(index: number) { + return children[index]!; + }, + textContent: isText ? text : children.map((c) => (c as unknown as { text?: string }).text ?? '').join(''), + descendants(callback: (node: ProseMirrorNode, pos: number) => boolean | void) { + function walk(nodes: ProseMirrorNode[], baseOffset: number): void { + let offset = baseOffset; + for (const child of nodes) { + const shouldDescend = callback(child, offset); + if (shouldDescend !== false) { + const grandChildren = (child as unknown as { _children?: ProseMirrorNode[] })._children; + if (grandChildren?.length) { + walk(grandChildren, offset + 1); + } + } + offset += child.nodeSize; + } + } + + walk(children, 0); + }, + } as unknown as ProseMirrorNode; + + (node as unknown as { _children: ProseMirrorNode[] })._children = children; + return node; +} + +function makeEntryEditor(commandOverrides: Record = {}) { + const tcEntry = createNode('tableOfContentsEntry', [], { + attrs: { instruction: 'TC "Chapter One" \\f "A" \\l "2"' }, + isInline: true, + isLeaf: true, + }); + const paragraph = createNode('paragraph', [createNode('text', [], { text: 'Body text' }), tcEntry], { + attrs: { sdBlockId: 'p-1' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [paragraph], { isBlock: false }); + + const commands = { + insertTableOfContentsEntryAt: vi.fn(() => true), + deleteTableOfContentsEntryAt: vi.fn(() => true), + updateTableOfContentsEntryAt: vi.fn(() => true), + ...commandOverrides, + }; + + const editor = { + state: { doc, schema: { nodes: { paragraph: { create: vi.fn() } } } }, + commands, + schema: { marks: {} }, + options: {}, + on: () => {}, + } as unknown as Editor; + + return { editor, commands, doc }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('toc entry wrappers', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + describe('tocListEntriesWrapper', () => { + it('lists TC entry nodes in the document', () => { + const { editor } = makeEntryEditor(); + const result = tocListEntriesWrapper(editor); + expect(result.total).toBe(1); + expect(result.items).toHaveLength(1); + expect(result.items[0]!.address.nodeType).toBe('tableOfContentsEntry'); + }); + + it('filters by tableIdentifier', () => { + const { editor } = makeEntryEditor(); + const result = tocListEntriesWrapper(editor, { tableIdentifier: 'B' }); + expect(result.total).toBe(0); + }); + + it('filters by levelRange', () => { + const { editor } = makeEntryEditor(); + // TC entry has level 2, so range 3-5 should exclude it + const result = tocListEntriesWrapper(editor, { levelRange: { from: 3, to: 5 } }); + expect(result.total).toBe(0); + + // Range 1-3 should include it + const result2 = tocListEntriesWrapper(editor, { levelRange: { from: 1, to: 3 } }); + expect(result2.total).toBe(1); + }); + }); + + describe('tocGetEntryWrapper', () => { + it('retrieves TC entry info by address', () => { + const { editor } = makeEntryEditor(); + const list = tocListEntriesWrapper(editor); + const entryId = list.items[0]!.address.nodeId; + + const info = tocGetEntryWrapper(editor, { + target: { kind: 'inline', nodeType: 'tableOfContentsEntry', nodeId: entryId }, + }); + expect(info.nodeType).toBe('tableOfContentsEntry'); + expect(info.properties.text).toBe('Chapter One'); + expect(info.properties.level).toBe(2); + expect(info.properties.tableIdentifier).toBe('A'); + }); + }); + + describe('tocMarkEntryWrapper', () => { + it('inserts a TC entry at end of paragraph', () => { + const { editor, commands } = makeEntryEditor(); + const result = tocMarkEntryWrapper( + editor, + { + target: { anchor: { nodeId: 'p-1' } }, + text: 'New Entry', + }, + { changeMode: 'direct' }, + ); + + expect(result.success).toBe(true); + expect(commands.insertTableOfContentsEntryAt).toHaveBeenCalledTimes(1); + }); + + it('supports dryRun', () => { + const { editor, commands } = makeEntryEditor(); + const result = tocMarkEntryWrapper( + editor, + { + target: { anchor: { nodeId: 'p-1' } }, + text: 'Dry Run Entry', + }, + { dryRun: true }, + ); + + expect(result.success).toBe(true); + expect(commands.insertTableOfContentsEntryAt).not.toHaveBeenCalled(); + }); + + it('rejects tracked mode', () => { + const { editor } = makeEntryEditor(); + expect(() => + tocMarkEntryWrapper(editor, { target: { anchor: { nodeId: 'p-1' } }, text: 'X' }, { changeMode: 'tracked' }), + ).toThrow(DocumentApiAdapterError); + }); + + it('rejects level 0', () => { + const { editor } = makeEntryEditor(); + expect(() => + tocMarkEntryWrapper( + editor, + { target: { anchor: { nodeId: 'p-1' } }, text: 'X', level: 0 }, + { changeMode: 'direct' }, + ), + ).toThrow(DocumentApiValidationError); + }); + + it('rejects level 10', () => { + const { editor } = makeEntryEditor(); + expect(() => + tocMarkEntryWrapper( + editor, + { target: { anchor: { nodeId: 'p-1' } }, text: 'X', level: 10 }, + { changeMode: 'direct' }, + ), + ).toThrow(DocumentApiValidationError); + }); + + it('accepts levels 1 through 9', () => { + const { editor } = makeEntryEditor(); + for (const level of [1, 5, 9]) { + const result = tocMarkEntryWrapper( + editor, + { target: { anchor: { nodeId: 'p-1' } }, text: 'X', level }, + { changeMode: 'direct' }, + ); + expect(result.success).toBe(true); + } + }); + }); + + describe('tocUnmarkEntryWrapper', () => { + it('removes a TC entry by address', () => { + const { editor, commands } = makeEntryEditor(); + const list = tocListEntriesWrapper(editor); + const entryId = list.items[0]!.address.nodeId; + + const result = tocUnmarkEntryWrapper( + editor, + { target: { kind: 'inline', nodeType: 'tableOfContentsEntry', nodeId: entryId } }, + { changeMode: 'direct' }, + ); + + expect(result.success).toBe(true); + expect(commands.deleteTableOfContentsEntryAt).toHaveBeenCalledTimes(1); + }); + }); + + describe('tocEditEntryWrapper', () => { + it('patches a TC entry instruction', () => { + const { editor, commands } = makeEntryEditor(); + const list = tocListEntriesWrapper(editor); + const entryId = list.items[0]!.address.nodeId; + + const result = tocEditEntryWrapper( + editor, + { + target: { kind: 'inline', nodeType: 'tableOfContentsEntry', nodeId: entryId }, + patch: { text: 'Updated Chapter' }, + }, + { changeMode: 'direct' }, + ); + + expect(result.success).toBe(true); + expect(commands.updateTableOfContentsEntryAt).toHaveBeenCalledTimes(1); + }); + + it('returns NO_OP when patch produces no change', () => { + const { editor } = makeEntryEditor(); + const list = tocListEntriesWrapper(editor); + const entryId = list.items[0]!.address.nodeId; + + const result = tocEditEntryWrapper( + editor, + { + target: { kind: 'inline', nodeType: 'tableOfContentsEntry', nodeId: entryId }, + patch: { text: 'Chapter One', level: 2, tableIdentifier: 'A' }, + }, + { changeMode: 'direct' }, + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.failure.code).toBe('NO_OP'); + } + }); + + it('rejects level 0 in patch', () => { + const { editor } = makeEntryEditor(); + const list = tocListEntriesWrapper(editor); + const entryId = list.items[0]!.address.nodeId; + + expect(() => + tocEditEntryWrapper( + editor, + { + target: { kind: 'inline', nodeType: 'tableOfContentsEntry', nodeId: entryId }, + patch: { level: 0 }, + }, + { changeMode: 'direct' }, + ), + ).toThrow(DocumentApiValidationError); + }); + + it('rejects level 10 in patch', () => { + const { editor } = makeEntryEditor(); + const list = tocListEntriesWrapper(editor); + const entryId = list.items[0]!.address.nodeId; + + expect(() => + tocEditEntryWrapper( + editor, + { + target: { kind: 'inline', nodeType: 'tableOfContentsEntry', nodeId: entryId }, + patch: { level: 10 }, + }, + { changeMode: 'direct' }, + ), + ).toThrow(DocumentApiValidationError); + }); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/toc-entry-wrappers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/toc-entry-wrappers.ts new file mode 100644 index 0000000000..5809a1d1a9 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/toc-entry-wrappers.ts @@ -0,0 +1,318 @@ +/** + * TC entry plan-engine wrappers — bridge TC entry operations to the plan engine. + * + * Handles: toc.markEntry, toc.unmarkEntry, toc.listEntries, toc.getEntry, toc.editEntry. + * + * All five operations target inline tableOfContentsEntry nodes (TC fields) in the + * document body. Mutations dispatch through the tableOfContentsEntry extension commands. + */ + +import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import type { Editor } from '../../core/Editor.js'; +import type { + TocEntryAddress, + TocMarkEntryInput, + TocUnmarkEntryInput, + TocListEntriesQuery, + TocListEntriesResult, + TocGetEntryInput, + TocEntryInfo, + TocEditEntryInput, + TocEntryMutationResult, + MutationOptions, + ReceiptFailureCode, +} from '@superdoc/document-api'; +import { buildDiscoveryResult, DocumentApiValidationError } from '@superdoc/document-api'; +import { + serializeTcInstruction, + applyTcPatch, + areTcConfigsEqual, + parseTcInstruction, +} from '../../core/super-converter/field-references/shared/tc-switches.js'; +import { + findAllTcEntryNodes, + resolveTcEntryTarget, + findParagraphBySdBlockId, + extractTcEntryInfo, + buildTcEntryDiscoveryItem, +} from '../helpers/toc-entry-resolver.js'; +import { paginate } from '../helpers/adapter-utils.js'; +import { getRevision } from './revision-tracker.js'; +import { executeDomainCommand } from './plan-wrappers.js'; +import { requireEditorCommand, rejectTrackedMode } from '../helpers/mutation-helpers.js'; +import { clearIndexCache } from '../helpers/index-cache.js'; + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +/** TC level bounds per OOXML spec. */ +const TC_LEVEL_MIN = 1; +const TC_LEVEL_MAX = 9; + +function validateTcLevel(level: number | undefined): void { + if (level === undefined) return; + if (!Number.isInteger(level) || level < TC_LEVEL_MIN || level > TC_LEVEL_MAX) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `TC entry level must be an integer between ${TC_LEVEL_MIN} and ${TC_LEVEL_MAX}, got ${level}`, + { level }, + ); + } +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +function buildEntryAddress(nodeId: string): TocEntryAddress { + return { kind: 'inline', nodeType: 'tableOfContentsEntry', nodeId }; +} + +function entrySuccess(nodeId: string): TocEntryMutationResult { + return { success: true, entry: buildEntryAddress(nodeId) }; +} + +function entryFailure(code: ReceiptFailureCode, message: string): TocEntryMutationResult { + return { success: false, failure: { code, message } }; +} + +type EntryEditorCommand = (options: Record) => boolean; + +function toEntryEditorCommand(command: unknown): EntryEditorCommand { + return command as EntryEditorCommand; +} + +/** + * Executes a TC entry editor command through the plan engine, clearing the + * index cache on success. Mirrors runTocAction in toc-wrappers.ts. + */ +function runEntryAction(editor: Editor, action: () => boolean, expectedRevision?: string) { + return executeDomainCommand( + editor, + () => { + const result = action(); + if (result) clearIndexCache(editor); + return result; + }, + { expectedRevision }, + ); +} + +function runEntryCommand(editor: Editor, command: unknown, args: Record, expectedRevision?: string) { + const executeCommand = toEntryEditorCommand(command); + return runEntryAction(editor, () => executeCommand(args), expectedRevision); +} + +function receiptApplied(receipt: ReturnType): boolean { + return receipt.steps[0]?.effect === 'changed'; +} + +// --------------------------------------------------------------------------- +// Reads +// --------------------------------------------------------------------------- + +/** + * Lists all TC entry nodes in the document body with optional filtering. + */ +export function tocListEntriesWrapper(editor: Editor, query?: TocListEntriesQuery): TocListEntriesResult { + const doc = editor.state.doc; + const revision = getRevision(editor); + const allEntries = findAllTcEntryNodes(doc); + + // Apply filters + let filtered = allEntries; + + if (query?.tableIdentifier !== undefined) { + filtered = filtered.filter((entry) => { + const config = parseTcInstruction(entry.node.attrs?.instruction ?? ''); + return config.tableIdentifier === query.tableIdentifier; + }); + } + + if (query?.levelRange) { + const { from, to } = query.levelRange; + filtered = filtered.filter((entry) => { + const config = parseTcInstruction(entry.node.attrs?.instruction ?? ''); + return config.level >= from && config.level <= to; + }); + } + + const allItems = filtered.map((entry) => buildTcEntryDiscoveryItem(entry, revision)); + const { total, items: paged } = paginate(allItems, query?.offset, query?.limit); + const effectiveLimit = query?.limit ?? total; + + return buildDiscoveryResult({ + evaluatedRevision: revision, + total, + items: paged, + page: { limit: effectiveLimit, offset: query?.offset ?? 0, returned: paged.length }, + }); +} + +/** + * Gets detailed info for a single TC entry node. + */ +export function tocGetEntryWrapper(editor: Editor, input: TocGetEntryInput): TocEntryInfo { + const resolved = resolveTcEntryTarget(editor.state.doc, input.target); + return extractTcEntryInfo(resolved); +} + +// --------------------------------------------------------------------------- +// toc.markEntry +// --------------------------------------------------------------------------- + +/** + * Inserts a new TC field at the target paragraph. + */ +export function tocMarkEntryWrapper( + editor: Editor, + input: TocMarkEntryInput, + options?: MutationOptions, +): TocEntryMutationResult { + rejectTrackedMode('toc.markEntry', options); + validateTcLevel(input.level); + const command = requireEditorCommand(editor.commands?.insertTableOfContentsEntryAt, 'toc.markEntry'); + + // Resolve insertion paragraph + const paragraph = findParagraphBySdBlockId(editor.state.doc, input.target.anchor.nodeId, editor); + + // Build TC instruction from input + const instruction = serializeTcInstruction({ + text: input.text, + level: input.level ?? 1, + omitPageNumber: input.omitPageNumber ?? false, + tableIdentifier: input.tableIdentifier, + }); + + // Compute insertion position within the paragraph + const insertionPosition = input.target.position ?? 'end'; + const pos = + insertionPosition === 'start' + ? paragraph.pos + 1 // Inside the paragraph, at the start + : paragraph.pos + paragraph.node.nodeSize - 1; // Inside the paragraph, at the end + + if (options?.dryRun) { + return entrySuccess('(dry-run)'); + } + + const receipt = runEntryCommand(editor, command, { pos, instruction }, options?.expectedRevision); + + if (!receiptApplied(receipt)) { + return entryFailure('INVALID_INSERTION_CONTEXT', 'TC entry could not be inserted at the requested location.'); + } + + // Re-resolve the inserted node to get its public ID + const postInsertionId = resolveInsertedEntryId(editor.state.doc, pos, instruction); + return entrySuccess(postInsertionId); +} + +/** + * After insertion, find the TC entry node near the insertion position and return its public ID. + */ +function resolveInsertedEntryId(doc: ProseMirrorNode, insertPos: number, instruction: string): string { + // The node was inserted at or near insertPos. Search nearby for it. + const allEntries = findAllTcEntryNodes(doc); + // Prefer the entry closest to the insertion position with matching instruction + const matching = allEntries.filter((e) => e.node.attrs?.instruction === instruction); + + if (matching.length > 0) { + // Pick the one closest to the insertion position + matching.sort((a, b) => Math.abs(a.pos - insertPos) - Math.abs(b.pos - insertPos)); + return matching[0].nodeId; + } + + // Fallback: just use the hash at the insertion position + const closest = allEntries.reduce( + (best, entry) => (Math.abs(entry.pos - insertPos) < Math.abs(best.pos - insertPos) ? entry : best), + allEntries[0], + ); + return closest?.nodeId ?? `tc-entry-unknown`; +} + +// --------------------------------------------------------------------------- +// toc.unmarkEntry +// --------------------------------------------------------------------------- + +/** + * Removes a single TC field node. + */ +export function tocUnmarkEntryWrapper( + editor: Editor, + input: TocUnmarkEntryInput, + options?: MutationOptions, +): TocEntryMutationResult { + rejectTrackedMode('toc.unmarkEntry', options); + const command = requireEditorCommand(editor.commands?.deleteTableOfContentsEntryAt, 'toc.unmarkEntry'); + + const resolved = resolveTcEntryTarget(editor.state.doc, input.target); + + if (options?.dryRun) { + return entrySuccess(resolved.nodeId); + } + + const receipt = runEntryCommand(editor, command, { pos: resolved.pos }, options?.expectedRevision); + + return receiptApplied(receipt) + ? entrySuccess(resolved.nodeId) + : entryFailure('NO_OP', 'TC entry removal produced no change.'); +} + +// --------------------------------------------------------------------------- +// toc.editEntry +// --------------------------------------------------------------------------- + +/** + * Applies a patch to an existing TC entry's instruction. + */ +export function tocEditEntryWrapper( + editor: Editor, + input: TocEditEntryInput, + options?: MutationOptions, +): TocEntryMutationResult { + rejectTrackedMode('toc.editEntry', options); + validateTcLevel(input.patch.level); + const command = requireEditorCommand(editor.commands?.updateTableOfContentsEntryAt, 'toc.editEntry'); + + const resolved = resolveTcEntryTarget(editor.state.doc, input.target); + const currentConfig = parseTcInstruction(resolved.node.attrs?.instruction ?? ''); + const patched = applyTcPatch(currentConfig, input.patch); + + if (areTcConfigsEqual(currentConfig, patched)) { + return entryFailure('NO_OP', 'Edit patch produced no change.'); + } + + if (options?.dryRun) { + return entrySuccess(resolved.nodeId); + } + + const receipt = runEntryCommand( + editor, + command, + { pos: resolved.pos, instruction: serializeTcInstruction(patched) }, + options?.expectedRevision, + ); + + if (!receiptApplied(receipt)) { + return entryFailure('NO_OP', 'TC entry edit could not be applied.'); + } + + // Re-resolve after edit — instruction change produces a new public ID + const postEditId = resolvePostEditEntryId(editor.state.doc, resolved.pos); + return entrySuccess(postEditId); +} + +/** + * After editing, re-resolve the TC entry near the original position to get its new public ID. + */ +function resolvePostEditEntryId(doc: ProseMirrorNode, originalPos: number): string { + const allEntries = findAllTcEntryNodes(doc); + if (allEntries.length === 0) return `tc-entry-unknown`; + + // Find the entry closest to the original position + const closest = allEntries.reduce( + (best, entry) => (Math.abs(entry.pos - originalPos) < Math.abs(best.pos - originalPos) ? entry : best), + allEntries[0], + ); + return closest.nodeId; +} diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/toc-wrappers.test.ts b/packages/super-editor/src/document-api-adapters/plan-engine/toc-wrappers.test.ts index bfd780579f..525a655090 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/toc-wrappers.test.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/toc-wrappers.test.ts @@ -40,6 +40,7 @@ type NodeOptions = { isLeaf?: boolean; inlineContent?: boolean; nodeSize?: number; + marks?: Array<{ type: string | { name: string } }>; }; function createNode(typeName: string, children: ProseMirrorNode[] = [], options: NodeOptions = {}): ProseMirrorNode { @@ -54,9 +55,12 @@ function createNode(typeName: string, children: ProseMirrorNode[] = [], options: const contentSize = children.reduce((sum, child) => sum + child.nodeSize, 0); const nodeSize = isText ? text.length : options.nodeSize != null ? options.nodeSize : isLeaf ? 1 : contentSize + 2; + const marks = options.marks ?? []; + const node = { type: { name: typeName }, attrs, + marks, text: isText ? text : undefined, content: { size: contentSize }, nodeSize, @@ -70,6 +74,24 @@ function createNode(typeName: string, children: ProseMirrorNode[] = [], options: child(index: number) { return children[index]!; }, + forEach(callback: (node: ProseMirrorNode, offset: number, index: number) => void) { + let offset = 0; + for (let i = 0; i < children.length; i++) { + callback(children[i]!, offset, i); + offset += children[i]!.nodeSize; + } + }, + toJSON(): Record { + const json: Record = { type: typeName }; + if (Object.keys(attrs).length > 0) json.attrs = attrs; + if (isText && text) json.text = text; + if (marks.length > 0) + json.marks = marks.map((m) => (typeof m.type === 'string' ? { type: m.type } : { type: m.type.name })); + if (children.length > 0) { + json.content = children.map((c) => (c as unknown as { toJSON: () => Record }).toJSON()); + } + return json; + }, descendants(callback: (node: ProseMirrorNode, pos: number) => boolean | void) { function walk(nodes: ProseMirrorNode[], baseOffset: number): void { let offset = baseOffset; @@ -208,4 +230,217 @@ describe('toc wrappers', () => { tocRemoveWrapper(editor, { target: tocTarget }, { changeMode: 'tracked' }); }); }); + + // --------------------------------------------------------------------------- + // toc.update mode: 'pageNumbers' + // --------------------------------------------------------------------------- + + describe('tocUpdateWrapper mode: pageNumbers', () => { + /** + * Builds an editor whose TOC has materialized entry paragraphs with tocPageNumber marks. + * Optionally attaches a pageMap to editor.storage.tableOfContents. + */ + function makePageNumberEditor( + opts: { + instruction?: string; + pageMap?: Map | null; + entryPageText?: string; + } = {}, + ) { + const instruction = opts.instruction ?? 'TOC \\o "1-3" \\h \\z'; + const entryPageText = opts.entryPageText ?? '0'; + + // Build a TOC with one entry paragraph that has a tocPageNumber mark + const entryTextNode = createNode('text', [], { text: 'Heading 1' }); + const tabNode = createNode('text', [], { text: '\t' }); + const pageNumNode = createNode('text', [], { + text: entryPageText, + marks: [{ type: 'tocPageNumber' }], + }); + const entryParagraph = createNode('paragraph', [entryTextNode, tabNode, pageNumNode], { + attrs: { + sdBlockId: 'toc-entry-p1', + paragraphProperties: { styleId: 'TOC1' }, + tocSourceId: 'h-1', + }, + isBlock: true, + inlineContent: true, + }); + + const tocNode = createNode('tableOfContents', [entryParagraph], { + attrs: { sdBlockId: 'toc-1', instruction }, + isBlock: true, + }); + const heading = createNode('paragraph', [createNode('text', [], { text: 'Heading 1' })], { + attrs: { + sdBlockId: 'h-1', + paragraphProperties: { styleId: 'Heading1' }, + }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [tocNode, heading], { isBlock: false }); + + const commands = { + insertTableOfContentsAt: vi.fn(() => true), + setTableOfContentsInstructionById: vi.fn(() => true), + replaceTableOfContentsContentById: vi.fn(() => true), + deleteTableOfContentsById: vi.fn(() => true), + }; + + const storage: Record = {}; + if (opts.pageMap !== null) { + storage.tableOfContents = { + pageMap: opts.pageMap ?? new Map([['h-1', 5]]), + pageMapDoc: doc, // Match the current doc so the freshness check passes + }; + } + + const editor = { + state: { doc, schema: { nodes: { paragraph: { create: vi.fn() }, tableOfContents: {} } } }, + commands, + schema: { marks: {} }, + options: {}, + storage, + on: () => {}, + } as unknown as Editor; + + return { editor, commands, doc }; + } + + const tocTarget = { kind: 'block', nodeType: 'tableOfContents', nodeId: 'toc-1' } as const; + + it('updates page numbers when page map is available and values changed', () => { + const { editor, commands } = makePageNumberEditor({ pageMap: new Map([['h-1', 5]]) }); + const result = tocUpdateWrapper(editor, { target: tocTarget, mode: 'pageNumbers' }, { changeMode: 'direct' }); + + expect(result.success).toBe(true); + expect(commands.replaceTableOfContentsContentById).toHaveBeenCalledTimes(1); + }); + + it('returns NO_OP when page numbers are already up to date', () => { + const { editor, commands } = makePageNumberEditor({ + pageMap: new Map([['h-1', 5]]), + entryPageText: '5', + }); + const result = tocUpdateWrapper(editor, { target: tocTarget, mode: 'pageNumbers' }, { changeMode: 'direct' }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.failure.code).toBe('NO_OP'); + } + expect(commands.replaceTableOfContentsContentById).not.toHaveBeenCalled(); + }); + + it('returns CAPABILITY_UNAVAILABLE when no page map exists', () => { + const { editor } = makePageNumberEditor({ pageMap: null }); + const result = tocUpdateWrapper(editor, { target: tocTarget, mode: 'pageNumbers' }, { changeMode: 'direct' }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.failure.code).toBe('CAPABILITY_UNAVAILABLE'); + } + }); + + it('returns NO_OP when config excludes page numbers', () => { + // \\n "1-9" omits page numbers for levels 1-9 — i.e. all levels + const { editor } = makePageNumberEditor({ instruction: 'TOC \\o "1-3" \\n "1-9"' }); + const result = tocUpdateWrapper(editor, { target: tocTarget, mode: 'pageNumbers' }, { changeMode: 'direct' }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.failure.code).toBe('NO_OP'); + } + }); + + it('supports dryRun', () => { + const { editor, commands } = makePageNumberEditor({ pageMap: new Map([['h-1', 5]]) }); + const result = tocUpdateWrapper(editor, { target: tocTarget, mode: 'pageNumbers' }, { dryRun: true }); + + expect(result.success).toBe(true); + expect(commands.replaceTableOfContentsContentById).not.toHaveBeenCalled(); + }); + + it('returns PAGE_NUMBERS_NOT_MATERIALIZED when TOC has no tocPageNumber marks', () => { + // Build a TOC without any tocPageNumber marks + const entryText = createNode('text', [], { text: 'Heading 1' }); + const entryParagraph = createNode('paragraph', [entryText], { + attrs: { + sdBlockId: 'toc-entry-p1', + paragraphProperties: { styleId: 'TOC1' }, + tocSourceId: 'h-1', + }, + isBlock: true, + inlineContent: true, + }); + const tocNode = createNode('tableOfContents', [entryParagraph], { + attrs: { sdBlockId: 'toc-1', instruction: 'TOC \\o "1-3" \\h \\z' }, + isBlock: true, + }); + const heading = createNode('paragraph', [createNode('text', [], { text: 'Heading 1' })], { + attrs: { sdBlockId: 'h-1', paragraphProperties: { styleId: 'Heading1' } }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [tocNode, heading], { isBlock: false }); + + const editor = { + state: { doc, schema: { nodes: { paragraph: { create: vi.fn() }, tableOfContents: {} } } }, + commands: { + insertTableOfContentsAt: vi.fn(() => true), + setTableOfContentsInstructionById: vi.fn(() => true), + replaceTableOfContentsContentById: vi.fn(() => true), + deleteTableOfContentsById: vi.fn(() => true), + }, + schema: { marks: {} }, + options: {}, + storage: { tableOfContents: { pageMap: new Map([['h-1', 5]]), pageMapDoc: doc } }, + on: () => {}, + } as unknown as Editor; + + const result = tocUpdateWrapper(editor, { target: tocTarget, mode: 'pageNumbers' }, { changeMode: 'direct' }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.failure.code).toBe('PAGE_NUMBERS_NOT_MATERIALIZED'); + } + }); + + it('returns CAPABILITY_UNAVAILABLE when page map is stale (doc changed since last layout)', () => { + const { editor, doc } = makePageNumberEditor({ pageMap: new Map([['h-1', 5]]) }); + + // Simulate a doc change after layout: create a new doc with the same + // TOC structure but a different object identity (as ProseMirror does on + // every document-changing transaction). + const entryTextNode = createNode('text', [], { text: 'Heading 1' }); + const tabNode = createNode('text', [], { text: '\t' }); + const pageNumNode = createNode('text', [], { text: '0', marks: [{ type: 'tocPageNumber' }] }); + const entryParagraph = createNode('paragraph', [entryTextNode, tabNode, pageNumNode], { + attrs: { sdBlockId: 'toc-entry-p1', paragraphProperties: { styleId: 'TOC1' }, tocSourceId: 'h-1' }, + isBlock: true, + inlineContent: true, + }); + const tocNode = createNode('tableOfContents', [entryParagraph], { + attrs: { sdBlockId: 'toc-1', instruction: 'TOC \\o "1-3" \\h \\z' }, + isBlock: true, + }); + const heading = createNode('paragraph', [createNode('text', [], { text: 'Heading 1' })], { + attrs: { sdBlockId: 'h-1', paragraphProperties: { styleId: 'Heading1' } }, + isBlock: true, + inlineContent: true, + }); + const newDoc = createNode('doc', [tocNode, heading], { isBlock: false }); + + // Replace the doc reference — pageMapDoc still points to the old doc + (editor.state as unknown as { doc: unknown }).doc = newDoc; + expect(newDoc).not.toBe(doc); // Sanity: different object identity + + const result = tocUpdateWrapper(editor, { target: tocTarget, mode: 'pageNumbers' }, { changeMode: 'direct' }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.failure.code).toBe('CAPABILITY_UNAVAILABLE'); + } + }); + }); }); diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/toc-wrappers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/toc-wrappers.ts index 280d424ecb..24df1be01b 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/toc-wrappers.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/toc-wrappers.ts @@ -21,12 +21,13 @@ import type { ReceiptFailureCode, TocSwitchConfig, } from '@superdoc/document-api'; -import { buildDiscoveryResult } from '@superdoc/document-api'; +import { buildDiscoveryResult, DocumentApiValidationError } from '@superdoc/document-api'; import { parseTocInstruction, serializeTocInstruction, applyTocPatch, areTocConfigsEqual, + deriveIncludePageNumbers, DEFAULT_TOC_CONFIG, } from '../../core/super-converter/field-references/shared/toc-switches.js'; import { @@ -36,11 +37,7 @@ import { extractTocInfo, buildTocDiscoveryItem, } from '../helpers/toc-resolver.js'; -import { - collectHeadingSources, - buildTocEntryParagraphs, - type EntryParagraphJson, -} from '../helpers/toc-entry-builder.js'; +import { collectTocSources, buildTocEntryParagraphs, type EntryParagraphJson } from '../helpers/toc-entry-builder.js'; import { paginate } from '../helpers/adapter-utils.js'; import { getRevision } from './revision-tracker.js'; import { executeDomainCommand } from './plan-wrappers.js'; @@ -48,6 +45,25 @@ import { requireEditorCommand, rejectTrackedMode } from '../helpers/mutation-hel import { clearIndexCache } from '../helpers/index-cache.js'; import { resolveBlockInsertionPos } from './create-insertion.js'; +// --------------------------------------------------------------------------- +// Typed patch helper +// --------------------------------------------------------------------------- + +/** + * Wraps `applyTocPatch` and re-throws raw `INVALID_INPUT:` errors as + * `DocumentApiValidationError` so callers get structured error codes. + */ +function applyTocPatchTyped(...args: Parameters): ReturnType { + try { + return applyTocPatch(...args); + } catch (err) { + if (err instanceof Error && err.message.startsWith('INVALID_INPUT:')) { + throw new DocumentApiValidationError('INVALID_INPUT', err.message.slice('INVALID_INPUT: '.length)); + } + throw err; + } +} + // --------------------------------------------------------------------------- // Reads // --------------------------------------------------------------------------- @@ -167,9 +183,18 @@ function isTocContentUnchanged(existingNode: ProseMirrorNode, newContent: unknow return JSON.stringify(existingEntries) === JSON.stringify(normalized); } +/** + * Merges rightAlignPageNumbers (a PM node attr, not a field switch) into the + * config's display so that entry materialization can branch on it. + */ +function withRightAlign(config: TocSwitchConfig, rightAlignPageNumbers: boolean | undefined): TocSwitchConfig { + if (rightAlignPageNumbers === undefined) return config; + return { ...config, display: { ...config.display, rightAlignPageNumbers } }; +} + function materializeTocContent(doc: ProseMirrorNode, config: TocSwitchConfig): EntryParagraphJson[] { - const headingSources = collectHeadingSources(doc, config); - const entryParagraphs = buildTocEntryParagraphs(headingSources, config); + const sources = collectTocSources(doc, config); + const entryParagraphs = buildTocEntryParagraphs(sources, config); return entryParagraphs.length > 0 ? entryParagraphs : NO_ENTRIES_PLACEHOLDER; } @@ -187,10 +212,20 @@ export function tocConfigureWrapper( const resolved = resolveTocTarget(editor.state.doc, input.target); const currentConfig = parseTocInstruction(resolved.node.attrs?.instruction ?? ''); - const patched = applyTocPatch(currentConfig, input.patch); - const nextContent = materializeTocContent(editor.state.doc, patched); + const patched = applyTocPatchTyped(currentConfig, input.patch); + + // rightAlignPageNumbers is a PM node attr, not an instruction switch + const rightAlignChanged = + input.patch.rightAlignPageNumbers !== undefined && + input.patch.rightAlignPageNumbers !== resolved.node.attrs?.rightAlignPageNumbers; + + // Merge rightAlignPageNumbers into config for entry materialization. + // Patch value takes priority; fall back to existing node attr. + const effectiveRightAlign = + input.patch.rightAlignPageNumbers ?? (resolved.node.attrs?.rightAlignPageNumbers as boolean | undefined); + const nextContent = materializeTocContent(editor.state.doc, withRightAlign(patched, effectiveRightAlign)); - if (areTocConfigsEqual(currentConfig, patched)) { + if (areTocConfigsEqual(currentConfig, patched) && !rightAlignChanged) { return tocFailure('NO_OP', 'Configuration patch produced no change.'); } @@ -207,6 +242,7 @@ export function tocConfigureWrapper( sdBlockId: commandNodeId, instruction: serializeTocInstruction(patched), ...(shouldRefreshContent ? { content: nextContent } : {}), + ...(rightAlignChanged ? { rightAlignPageNumbers: input.patch.rightAlignPageNumbers } : {}), }, options?.expectedRevision, ); @@ -227,11 +263,26 @@ export function tocConfigureWrapper( export function tocUpdateWrapper(editor: Editor, input: TocUpdateInput, options?: MutationOptions): TocMutationResult { rejectTrackedMode('toc.update', options); + const mode = input.mode ?? 'all'; + + if (mode === 'pageNumbers') { + return tocUpdatePageNumbers(editor, input, options); + } + + return tocUpdateAll(editor, input, options); +} + +/** + * Mode 'all' — full rebuild from configured sources (headings + TC fields). + * This is the original toc.update behavior. + */ +function tocUpdateAll(editor: Editor, input: TocUpdateInput, options?: MutationOptions): TocMutationResult { const command = requireEditorCommand(editor.commands?.replaceTableOfContentsContentById, 'toc.update'); const resolved = resolveTocTarget(editor.state.doc, input.target); const config = parseTocInstruction(resolved.node.attrs?.instruction ?? ''); - const content = materializeTocContent(editor.state.doc, config); + const rightAlign = resolved.node.attrs?.rightAlignPageNumbers as boolean | undefined; + const content = materializeTocContent(editor.state.doc, withRightAlign(config, rightAlign)); // NO_OP detection: compare new content against existing before executing. // The PM command returns "found" (not "content changed"), so receipt-based @@ -257,6 +308,158 @@ export function tocUpdateWrapper(editor: Editor, input: TocUpdateInput, options? return receiptApplied(receipt) ? tocSuccess(resolved.nodeId) : tocFailure('NO_OP', 'TOC update produced no change.'); } +// --------------------------------------------------------------------------- +// toc.update mode: 'pageNumbers' +// --------------------------------------------------------------------------- + +/** + * Extracts the page map from the editor if it is fresh. + * + * The page map is set by PresentationEditor after each render cycle. It maps + * sdBlockId → page number for every anchored block in the rendered layout. + * + * Returns null when: + * - No layout has been computed (headless mode, or before first render). + * - The stored map is stale (the document changed since the last layout cycle). + * Staleness is detected by comparing the doc snapshot stored alongside the map + * against the current editor.state.doc (ProseMirror creates a new doc object + * on every document-changing transaction). + */ +function getPageMap(editor: Editor): Map | null { + const storage = (editor as unknown as { storage?: Record }).storage; + if (!storage) return null; + + const tocStorage = storage.tableOfContents as { pageMap?: Map; pageMapDoc?: unknown } | undefined; + if (!tocStorage?.pageMap) return null; + + // Reject stale maps — the doc must match the snapshot from the last layout cycle + if (tocStorage.pageMapDoc !== undefined && tocStorage.pageMapDoc !== editor.state.doc) { + return null; + } + + return tocStorage.pageMap; +} + +/** + * Mode 'pageNumbers' — surgical page number update without rebuilding entries. + * + * Decision tree: + * 1. Config says no page numbers → NO_OP + * 2. No page map available → CAPABILITY_UNAVAILABLE + * 3. No tocPageNumber marks found → PAGE_NUMBERS_NOT_MATERIALIZED + * 4. Marks found, page map available → update each marked run, success + */ +function tocUpdatePageNumbers(editor: Editor, input: TocUpdateInput, options?: MutationOptions): TocMutationResult { + const command = requireEditorCommand(editor.commands?.replaceTableOfContentsContentById, 'toc.update'); + + const resolved = resolveTocTarget(editor.state.doc, input.target); + const config = parseTocInstruction(resolved.node.attrs?.instruction ?? ''); + + // 1. Config says no page numbers → NO_OP + if (deriveIncludePageNumbers(config.display.omitPageNumberLevels, config.source.outlineLevels) === false) { + return tocFailure('NO_OP', 'TOC configuration excludes page numbers. Nothing to update.'); + } + + // 2. Get page map + const pageMap = getPageMap(editor); + if (!pageMap) { + return tocFailure( + 'CAPABILITY_UNAVAILABLE', + 'Page number resolution requires a completed layout. Trigger a render cycle and retry, or use mode "all".', + ); + } + + // 3. Walk TOC children and build updated content with resolved page numbers + const { updatedContent, hasPageNumberMarks, anyChanged } = buildPageNumberUpdatedContent(resolved.node, pageMap); + + if (!hasPageNumberMarks) { + return tocFailure( + 'PAGE_NUMBERS_NOT_MATERIALIZED', + 'TOC entries do not contain tagged page number runs. Run toc.update with mode "all" first.', + ); + } + + if (!anyChanged) { + return tocFailure('NO_OP', 'Page numbers are already up to date.'); + } + + if (options?.dryRun) { + return tocSuccess(resolved.nodeId); + } + + const receipt = runTocCommand( + editor, + command, + { + sdBlockId: resolved.commandNodeId ?? resolved.nodeId, + content: updatedContent, + }, + options?.expectedRevision, + ); + + return receiptApplied(receipt) + ? tocSuccess(resolved.nodeId) + : tocFailure('NO_OP', 'Page number update produced no change.'); +} + +/** + * Walks the TOC node's children and produces updated paragraph JSON where + * tocPageNumber-marked text runs are replaced with resolved page numbers. + */ +function buildPageNumberUpdatedContent( + tocNode: ProseMirrorNode, + pageMap: Map, +): { updatedContent: EntryParagraphJson[]; hasPageNumberMarks: boolean; anyChanged: boolean } { + const updatedContent: EntryParagraphJson[] = []; + let hasPageNumberMarks = false; + let anyChanged = false; + + tocNode.forEach((child) => { + if (child.type.name !== 'paragraph') { + // Non-paragraph children: serialize as-is + updatedContent.push(child.toJSON() as EntryParagraphJson); + return; + } + + const tocSourceId = child.attrs?.tocSourceId as string | undefined; + const childJson = child.toJSON() as EntryParagraphJson; + const content = childJson.content ?? []; + + let paragraphChanged = false; + + const updatedContentArray = content.map((node: Record) => { + const marks = node.marks as Array<{ type: string }> | undefined; + const hasTocPageNumberMark = marks?.some((m) => m.type === 'tocPageNumber'); + + if (!hasTocPageNumberMark) return node; + + hasPageNumberMarks = true; + + // Skip entries without tocSourceId — no anchor for page map lookup + if (!tocSourceId) return node; + + const pageNumber = pageMap.get(tocSourceId); + const newText = pageNumber !== undefined ? String(pageNumber) : '??'; + + if (node.text !== newText) { + paragraphChanged = true; + return { ...node, text: newText }; + } + + return node; + }); + + if (paragraphChanged) { + anyChanged = true; + updatedContent.push({ ...childJson, content: updatedContentArray }); + } else { + updatedContent.push(childJson); + } + }); + + return { updatedContent, hasPageNumberMarks, anyChanged }; +} + // --------------------------------------------------------------------------- // toc.remove // --------------------------------------------------------------------------- @@ -307,9 +510,9 @@ export function createTableOfContentsWrapper( } // Build instruction from config patch or use defaults - const config = input.config ? applyTocPatch(DEFAULT_TOC_CONFIG, input.config) : DEFAULT_TOC_CONFIG; + const config = input.config ? applyTocPatchTyped(DEFAULT_TOC_CONFIG, input.config) : DEFAULT_TOC_CONFIG; const instruction = serializeTocInstruction(config); - const content = materializeTocContent(editor.state.doc, config); + const content = materializeTocContent(editor.state.doc, withRightAlign(config, input.config?.rightAlignPageNumbers)); const sdBlockId = uuidv4(); @@ -325,6 +528,9 @@ export function createTableOfContentsWrapper( instruction, sdBlockId, content, + ...(input.config?.rightAlignPageNumbers !== undefined + ? { rightAlignPageNumbers: input.config.rightAlignPageNumbers } + : {}), }, options?.expectedRevision, ); diff --git a/packages/super-editor/src/extensions/index.js b/packages/super-editor/src/extensions/index.js index 869d43dbc9..5296141cf7 100644 --- a/packages/super-editor/src/extensions/index.js +++ b/packages/super-editor/src/extensions/index.js @@ -43,12 +43,13 @@ import { ShapeContainer } from './shape-container/index.js'; import { ShapeTextbox } from './shape-textbox/index.js'; import { ContentBlock } from './content-block/index.js'; import { BlockNode } from './block-node/index.js'; -import { TableOfContents } from './table-of-contents/index.js'; +import { TableOfContents, TocPageNumber } from './table-of-contents/index.js'; import { DocumentIndex } from './document-index/index.js'; import { VectorShape } from './vector-shape/index.js'; import { ShapeGroup } from './shape-group/index.js'; import { PassthroughBlock, PassthroughInline } from '@extensions/passthrough/index.js'; import { IndexEntry } from './index-entry/index.js'; +import { TableOfContentsEntry } from './table-of-contents-entry/index.js'; // Marks extensions import { TextStyle } from './text-style/text-style.js'; @@ -145,6 +146,7 @@ const getStarterExtensions = () => { Strike, TabNode, TableOfContents, + TocPageNumber, DocumentIndex, Text, TextAlign, @@ -180,6 +182,7 @@ const getStarterExtensions = () => { TotalPageCount, PageReference, IndexEntry, + TableOfContentsEntry, ShapeContainer, ShapeTextbox, ContentBlock, @@ -238,6 +241,8 @@ export { TableHeader, DocumentIndex, IndexEntry, + TableOfContentsEntry, + TocPageNumber, Placeholder, DropCursor, BlockNode, diff --git a/packages/super-editor/src/extensions/paragraph/paragraph.js b/packages/super-editor/src/extensions/paragraph/paragraph.js index d47099fa53..e32a612a20 100644 --- a/packages/super-editor/src/extensions/paragraph/paragraph.js +++ b/packages/super-editor/src/extensions/paragraph/paragraph.js @@ -167,6 +167,7 @@ export const Paragraph = OxmlNode.create({ filename: { rendered: false }, paragraphProperties: { rendered: false }, pageBreakSource: { rendered: false }, + tocSourceId: { rendered: false }, sectionMargins: { rendered: false }, listRendering: { keepOnSplit: false, diff --git a/packages/super-editor/src/extensions/table-of-contents-entry/index.js b/packages/super-editor/src/extensions/table-of-contents-entry/index.js new file mode 100644 index 0000000000..4d549bdb1d --- /dev/null +++ b/packages/super-editor/src/extensions/table-of-contents-entry/index.js @@ -0,0 +1 @@ +export * from './table-of-contents-entry.js'; diff --git a/packages/super-editor/src/extensions/table-of-contents-entry/table-of-contents-entry.js b/packages/super-editor/src/extensions/table-of-contents-entry/table-of-contents-entry.js new file mode 100644 index 0000000000..70c92d87c9 --- /dev/null +++ b/packages/super-editor/src/extensions/table-of-contents-entry/table-of-contents-entry.js @@ -0,0 +1,103 @@ +import { Node, Attribute } from '@core/index.js'; + +export const TableOfContentsEntry = Node.create({ + name: 'tableOfContentsEntry', + + group: 'inline', + + inline: true, + + atom: true, + + draggable: false, + + selectable: false, + + content: 'inline*', + + addOptions() { + return { + htmlAttributes: { + contenteditable: false, + 'data-id': 'document-toc-entry', + 'aria-label': 'Table of contents entry', + style: 'display:none', + }, + }; + }, + + addAttributes() { + return { + instruction: { + default: '', + rendered: false, + }, + instructionTokens: { + default: null, + rendered: false, + }, + marksAsAttrs: { + default: null, + rendered: false, + }, + }; + }, + + parseDOM() { + return [{ tag: 'span[data-id="document-toc-entry"]' }]; + }, + + renderDOM({ htmlAttributes }) { + return ['span', Attribute.mergeAttributes(this.options.htmlAttributes, htmlAttributes), 0]; + }, + + addCommands() { + return { + insertTableOfContentsEntryAt: + ({ pos, instruction, instructionTokens = null }) => + ({ tr, dispatch }) => { + const nodeType = this.editor.schema.nodes.tableOfContentsEntry; + if (!nodeType) return false; + + const node = nodeType.create({ + instruction, + instructionTokens, + }); + + try { + if (dispatch) { + tr.insert(pos, node); + } + return true; + } catch (error) { + if (error instanceof RangeError) return false; + throw error; + } + }, + + updateTableOfContentsEntryAt: + ({ pos, instruction }) => + ({ tr, dispatch, state }) => { + const node = state.doc.nodeAt(pos); + if (!node || node.type.name !== 'tableOfContentsEntry') return false; + + if (dispatch) { + tr.setNodeMarkup(pos, undefined, { ...node.attrs, instruction, instructionTokens: null }); + } + return true; + }, + + deleteTableOfContentsEntryAt: + ({ pos }) => + ({ tr, dispatch, state }) => { + const node = state.doc.nodeAt(pos); + if (!node || node.type.name !== 'tableOfContentsEntry') return false; + + if (dispatch) { + tr.delete(pos, pos + node.nodeSize); + } + return true; + }, + }; + }, +}); diff --git a/packages/super-editor/src/extensions/table-of-contents-entry/table-of-contents-entry.test.js b/packages/super-editor/src/extensions/table-of-contents-entry/table-of-contents-entry.test.js new file mode 100644 index 0000000000..a39f7bc891 --- /dev/null +++ b/packages/super-editor/src/extensions/table-of-contents-entry/table-of-contents-entry.test.js @@ -0,0 +1,31 @@ +import { describe, it, expect, vi } from 'vitest'; +import { TableOfContentsEntry } from './table-of-contents-entry.js'; + +describe('TableOfContentsEntry commands', () => { + it('updateTableOfContentsEntryAt clears stale instructionTokens', () => { + const commands = TableOfContentsEntry.config.addCommands(); + const setNodeMarkup = vi.fn(); + const dispatch = vi.fn(); + const node = { + type: { name: 'tableOfContentsEntry' }, + attrs: { + instruction: 'TC "Old" \\l "1"', + instructionTokens: [{ type: 'text', text: 'TC "Old" \\l "1"' }], + marksAsAttrs: [{ type: 'bold', attrs: {} }], + }, + }; + + const result = commands.updateTableOfContentsEntryAt({ pos: 5, instruction: 'TC "New" \\l "2"' })({ + tr: { setNodeMarkup }, + dispatch, + state: { doc: { nodeAt: vi.fn().mockReturnValue(node) } }, + }); + + expect(result).toBe(true); + expect(setNodeMarkup).toHaveBeenCalledWith(5, undefined, { + ...node.attrs, + instruction: 'TC "New" \\l "2"', + instructionTokens: null, + }); + }); +}); diff --git a/packages/super-editor/src/extensions/table-of-contents/index.js b/packages/super-editor/src/extensions/table-of-contents/index.js index 3cac4444f5..2dcd1b4b7e 100644 --- a/packages/super-editor/src/extensions/table-of-contents/index.js +++ b/packages/super-editor/src/extensions/table-of-contents/index.js @@ -1 +1,2 @@ export * from './table-of-contents.js'; +export * from './toc-page-number.js'; diff --git a/packages/super-editor/src/extensions/table-of-contents/table-of-contents.js b/packages/super-editor/src/extensions/table-of-contents/table-of-contents.js index 763b31e493..7f10df5ffc 100644 --- a/packages/super-editor/src/extensions/table-of-contents/table-of-contents.js +++ b/packages/super-editor/src/extensions/table-of-contents/table-of-contents.js @@ -9,6 +9,17 @@ export const TableOfContents = Node.create({ inline: false, + addStorage() { + return { + /** + * Maps sdBlockId → page number. Set by PresentationEditor after each + * layout cycle. Read by toc.update({ mode: 'pageNumbers' }) wrapper. + * @type {Map | null} + */ + pageMap: null, + }; + }, + addOptions() { return { htmlAttributes: { @@ -41,12 +52,12 @@ export const TableOfContents = Node.create({ return { /** * Insert a tableOfContents node at the given document position. - * @param {{ pos: number, instruction?: string, sdBlockId?: string, content?: object[] }} options + * @param {{ pos: number, instruction?: string, sdBlockId?: string, content?: object[], rightAlignPageNumbers?: boolean }} options */ insertTableOfContentsAt: (options) => ({ tr, dispatch, state }) => { - const { pos, instruction = '', sdBlockId = null, content } = options; + const { pos, instruction = '', sdBlockId = null, content, rightAlignPageNumbers } = options; const tocType = this.editor.schema.nodes.tableOfContents; if (!tocType) return false; @@ -55,7 +66,9 @@ export const TableOfContents = Node.create({ paragraphType.create({}, this.editor.schema.text('Update table of contents to populate entries.')), ]; const materializedContent = normalizeTocContent(content, state.schema) ?? defaultContent; - const tocNode = tocType.create({ instruction, sdBlockId }, materializedContent); + const attrs = { instruction, sdBlockId }; + if (rightAlignPageNumbers !== undefined) attrs.rightAlignPageNumbers = rightAlignPageNumbers; + const tocNode = tocType.create(attrs, materializedContent); try { if (dispatch) { @@ -71,18 +84,20 @@ export const TableOfContents = Node.create({ /** * Update the instruction attribute of a tableOfContents node by sdBlockId. * Optionally replaces the materialized TOC content in the same transaction. - * @param {{ sdBlockId: string, instruction: string, content?: object[] }} options + * @param {{ sdBlockId: string, instruction: string, content?: object[], rightAlignPageNumbers?: boolean }} options */ setTableOfContentsInstructionById: (options) => ({ tr, dispatch, state }) => { - const { sdBlockId, instruction, content } = options; + const { sdBlockId, instruction, content, rightAlignPageNumbers } = options; let found = false; state.doc.descendants((node, pos) => { if (found) return false; if (node.type.name === 'tableOfContents' && node.attrs.sdBlockId === sdBlockId) { if (dispatch) { - tr.setNodeMarkup(pos, undefined, { ...node.attrs, instruction }); + const nextAttrs = { ...node.attrs, instruction }; + if (rightAlignPageNumbers !== undefined) nextAttrs.rightAlignPageNumbers = rightAlignPageNumbers; + tr.setNodeMarkup(pos, undefined, nextAttrs); const fragment = normalizeTocContent(content, state.schema); if (fragment) { const from = pos + 1; @@ -168,6 +183,15 @@ export const TableOfContents = Node.create({ return attrs.sdBlockId ? { 'data-sd-block-id': attrs.sdBlockId } : {}; }, }, + /** + * Whether TOC entry page numbers use right-aligned tab stops. + * Persisted as a PM node attribute (no OOXML switch equivalent). + * Derived on DOCX import from the first entry paragraph's tab stop properties. + */ + rightAlignPageNumbers: { + default: true, + rendered: false, + }, }; }, }); diff --git a/packages/super-editor/src/extensions/table-of-contents/toc-page-number.js b/packages/super-editor/src/extensions/table-of-contents/toc-page-number.js new file mode 100644 index 0000000000..0a1e1c2ad4 --- /dev/null +++ b/packages/super-editor/src/extensions/table-of-contents/toc-page-number.js @@ -0,0 +1,33 @@ +import { Mark } from '@core/index.js'; + +/** + * Inline mark that tags page number text runs within TOC entry paragraphs. + * + * This mark is a structural tag — it has no visual effect and exists solely + * so that `toc.update({ mode: 'pageNumbers' })` can surgically identify and + * replace page number text without rebuilding the entire TOC. + * + * Applied by `buildTocEntryParagraphs` during materialization and detected + * during DOCX import via a strict structural heuristic (see §2f in plan). + */ +export const TocPageNumber = Mark.create({ + name: 'tocPageNumber', + + inclusive: false, + + spanning: false, + + addOptions() { + return { + htmlAttributes: {}, + }; + }, + + parseHTML() { + return [{ tag: 'span[data-toc-page-number]' }]; + }, + + renderHTML() { + return ['span', { 'data-toc-page-number': '' }, 0]; + }, +});