diff --git a/apps/cli/src/__tests__/conformance/scenarios.ts b/apps/cli/src/__tests__/conformance/scenarios.ts index d49ba0bff6..5cf9a1a8de 100644 --- a/apps/cli/src/__tests__/conformance/scenarios.ts +++ b/apps/cli/src/__tests__/conformance/scenarios.ts @@ -36,7 +36,7 @@ function skippedSuccessScenario(operationId: CliOperationId) { }); } -type SuccessScenarioFactory = (harness: ConformanceHarness) => Promise; +type ScenarioFactory = (harness: ConformanceHarness) => Promise; function deferredRuntimeScenario( operationId: CliOperationId, @@ -3334,7 +3334,7 @@ export const SUCCESS_SCENARIOS = { await harness.openSessionFixture(stateDir, 'doc-history-redo', 'history-redo-session'); return { stateDir, args: ['history', 'redo', '--session', 'history-redo-session'] }; }, -} as const satisfies Partial>; +} as const satisfies Partial>; const EXPLICIT_RUNTIME_CONFORMANCE_SKIP = new Set([ 'doc.toc.markEntry', @@ -3358,8 +3358,9 @@ const EXPLICIT_RUNTIME_CONFORMANCE_SKIP = new Set([ ]); const CANONICAL_OPERATION_IDS = Object.keys(CLI_OPERATION_COMMAND_KEYS) as CliOperationId[]; +const SUCCESS_SCENARIOS_BY_OPERATION: Partial> = SUCCESS_SCENARIOS; const AUTO_SKIPPED_OPERATION_IDS = CANONICAL_OPERATION_IDS.filter( - (operationId) => SUCCESS_SCENARIOS[operationId] == null, + (operationId) => SUCCESS_SCENARIOS_BY_OPERATION[operationId] == null, ); const RUNTIME_CONFORMANCE_SKIP = new Set([ @@ -3367,13 +3368,37 @@ const RUNTIME_CONFORMANCE_SKIP = new Set([ ...AUTO_SKIPPED_OPERATION_IDS, ]); +const FAILURE_SCENARIOS: Partial> = { + 'doc.trackChanges.decide': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-trackChanges-decide-missing-id'); + const fixture = await harness.addTrackedChangeFixture(stateDir, 'doc-trackChanges-decide-missing-id'); + return { + stateDir, + args: [ + ...commandTokens('doc.trackChanges.decide'), + fixture.docPath, + '--decision', + 'accept', + '--target-json', + JSON.stringify({ id: 'missing-track-change-id' }), + '--out', + harness.createOutputPath('doc-trackChanges-decide-missing-id-output'), + ], + }; + }, +}; + +const EXPECTED_FAILURE_CODES: Partial> = { + 'doc.trackChanges.decide': ['TARGET_NOT_FOUND'], +}; + export const OPERATION_SCENARIOS = CANONICAL_OPERATION_IDS.map((operationId) => { - const success = SUCCESS_SCENARIOS[operationId] ?? skippedSuccessScenario(operationId); + const success = SUCCESS_SCENARIOS_BY_OPERATION[operationId] ?? skippedSuccessScenario(operationId); const scenario: OperationScenario = { operationId, success, - failure: genericInvalidArgumentFailure(operationId), - expectedFailureCodes: ['INVALID_ARGUMENT', 'MISSING_REQUIRED'], + failure: FAILURE_SCENARIOS[operationId] ?? genericInvalidArgumentFailure(operationId), + expectedFailureCodes: EXPECTED_FAILURE_CODES[operationId] ?? ['INVALID_ARGUMENT', 'MISSING_REQUIRED'], ...(RUNTIME_CONFORMANCE_SKIP.has(operationId) ? { skipRuntimeConformance: true } : {}), }; return scenario; diff --git a/apps/cli/src/__tests__/lib/_open-document-track-changes-worker.ts b/apps/cli/src/__tests__/lib/_open-document-track-changes-worker.ts new file mode 100644 index 0000000000..ad5d410c7c --- /dev/null +++ b/apps/cli/src/__tests__/lib/_open-document-track-changes-worker.ts @@ -0,0 +1,81 @@ +/** + * Subprocess worker for the openDocument track-changes forwarding test. + */ +import { mock } from 'bun:test'; + +let editorOpenCalled = false; +let capturedTrackChanges: unknown; + +const MockEditor = { + open: mock(async (_source: unknown, options: Record = {}) => { + editorOpenCalled = true; + capturedTrackChanges = (options.modules as { trackChanges?: unknown } | undefined)?.trackChanges; + return { + destroy: () => {}, + exportDocument: async () => new Uint8Array(), + }; + }), +}; + +mock.module('superdoc/super-editor', () => ({ + Editor: MockEditor, + BLANK_DOCX_BASE64: '', + DocxEncryptionError: class DocxEncryptionError extends Error { + code: string; + constructor(code: string, message: string) { + super(message); + this.code = code; + } + }, + getDocumentApiAdapters: () => ({}), + markdownToPmDoc: () => null, + initPartsRuntime: () => ({ dispose: () => {} }), + syncCommentEntitiesFromCollaboration: () => new Set(), +})); + +mock.module('@superdoc/document-api', () => ({ + createDocumentApi: () => ({}), +})); + +mock.module('happy-dom', () => ({ + Window: class { + document = { + createElement: () => ({}), + body: { appendChild: () => {}, innerHTML: '' }, + }; + happyDOM = { abort: () => {} }; + close() {} + }, +})); + +async function main() { + const { openDocument } = await import('../../lib/document'); + + const io = { + stdout: () => {}, + stderr: () => {}, + readStdinBytes: async () => new Uint8Array(), + }; + + let opened: { dispose(): void } | undefined; + try { + opened = await openDocument(undefined, io, { + editorOpenOptions: { + modules: { + trackChanges: { + replacements: 'independent', + }, + }, + }, + }); + } finally { + opened?.dispose(); + } + + console.log(JSON.stringify({ editorOpenCalled, capturedTrackChanges })); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/apps/cli/src/__tests__/lib/error-mapping.test.ts b/apps/cli/src/__tests__/lib/error-mapping.test.ts index cbb0f165da..88b3303fd9 100644 --- a/apps/cli/src/__tests__/lib/error-mapping.test.ts +++ b/apps/cli/src/__tests__/lib/error-mapping.test.ts @@ -14,6 +14,32 @@ describe('mapInvokeError', () => { expect(mapped.message).toBe('blocks.delete requires a target.'); expect(mapped.details).toEqual({ operationId: 'blocks.delete', details: { field: 'target' } }); }); + + test('preserves TARGET_NOT_FOUND for trackChanges.decide stale ids', () => { + const error = Object.assign(new Error('Tracked change "tc-1" was not found.'), { + code: 'TARGET_NOT_FOUND', + details: { id: 'tc-1' }, + }); + + const mapped = mapInvokeError('trackChanges.decide' as any, error); + expect(mapped.code).toBe('TARGET_NOT_FOUND'); + expect(mapped.details).toEqual({ operationId: 'trackChanges.decide', details: { id: 'tc-1' } }); + }); + + test('keeps track-changes accept/reject helper missing ids backward compatible', () => { + const error = Object.assign(new Error('Tracked change "tc-1" was not found.'), { + code: 'TARGET_NOT_FOUND', + details: { id: 'tc-1' }, + }); + + const accept = mapInvokeError('trackChanges.decide' as any, error, { commandName: 'track-changes accept' }); + const reject = mapInvokeError('trackChanges.decide' as any, error, { commandName: 'track-changes reject' }); + const canonical = mapInvokeError('trackChanges.decide' as any, error, { commandName: 'track-changes decide' }); + + expect(accept.code).toBe('TRACK_CHANGE_NOT_FOUND'); + expect(reject.code).toBe('TRACK_CHANGE_NOT_FOUND'); + expect(canonical.code).toBe('TARGET_NOT_FOUND'); + }); }); // --------------------------------------------------------------------------- @@ -205,6 +231,24 @@ describe('mapFailedReceipt: plan-engine code passthrough', () => { expect(result!.code).toBe('COMMAND_FAILED'); }); + test('maps helper trackChanges.decide TARGET_NOT_FOUND receipts to TRACK_CHANGE_NOT_FOUND', () => { + const receipt = { + success: false, + failure: { + code: 'TARGET_NOT_FOUND', + message: 'Tracked change "tc-1" was not found.', + }, + }; + + const helper = mapFailedReceipt('trackChanges.decide' as any, receipt, { commandName: 'track-changes accept' }); + const canonical = mapFailedReceipt('trackChanges.decide' as any, receipt, { + commandName: 'track-changes decide', + }); + + expect(helper?.code).toBe('TRACK_CHANGE_NOT_FOUND'); + expect(canonical?.code).toBe('TARGET_NOT_FOUND'); + }); + test('plan-engine code MATCH_NOT_FOUND passes through with structured details', () => { const receipt = { success: false, diff --git a/apps/cli/src/__tests__/lib/open-document-track-changes-forwarding.test.ts b/apps/cli/src/__tests__/lib/open-document-track-changes-forwarding.test.ts new file mode 100644 index 0000000000..6df793eb59 --- /dev/null +++ b/apps/cli/src/__tests__/lib/open-document-track-changes-forwarding.test.ts @@ -0,0 +1,32 @@ +/** + * Verifies that `openDocument` forwards `editorOpenOptions.modules.trackChanges` + * through to `Editor.open()`. + * + * This runs in a subprocess so `mock.module` cannot leak into other tests. + */ +import { describe, expect, test } from 'bun:test'; +import { join } from 'path'; + +const WORKER_SCRIPT = join(import.meta.dir, '_open-document-track-changes-worker.ts'); + +describe('openDocument track changes forwarding', () => { + test('trackChanges replacement mode reaches Editor.open()', async () => { + const proc = Bun.spawn(['bun', 'run', WORKER_SCRIPT], { + cwd: join(import.meta.dir, '../../..'), + stdout: 'pipe', + stderr: 'pipe', + }); + + const exitCode = await proc.exited; + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + + if (exitCode !== 0) { + throw new Error(`Worker failed (code ${exitCode}):\n${stderr || stdout}`); + } + + const result = JSON.parse(stdout.trim()); + expect(result.editorOpenCalled).toBe(true); + expect(result.capturedTrackChanges).toEqual({ replacements: 'independent' }); + }); +}); diff --git a/apps/cli/src/__tests__/lib/operation-runtime-metadata.test.ts b/apps/cli/src/__tests__/lib/operation-runtime-metadata.test.ts index c1a4cada5c..364883d0b6 100644 --- a/apps/cli/src/__tests__/lib/operation-runtime-metadata.test.ts +++ b/apps/cli/src/__tests__/lib/operation-runtime-metadata.test.ts @@ -75,4 +75,32 @@ describe('operation runtime metadata', () => { const optionNames = openOptions.map((o) => o.name); expect(optionNames).toContain('password'); }); + + test('doc.open metadata includes trackChanges JSON param', () => { + const openMeta = CLI_OPERATION_METADATA['doc.open']; + const trackChangesParam = openMeta.params.find((p) => p.name === 'trackChanges'); + + expect(trackChangesParam).toBeDefined(); + expect(trackChangesParam!.kind).toBe('jsonFlag'); + expect(trackChangesParam!.type).toBe('json'); + expect(trackChangesParam!.flag).toBe('track-changes-json'); + expect(trackChangesParam!.schema).toEqual({ + type: 'object', + properties: { + replacements: { + type: 'string', + enum: ['paired', 'independent'], + description: 'Tracked replacement grouping mode.', + }, + }, + }); + }); + + test('doc.open option specs include track-changes-json flag', () => { + const openOptions = CLI_OPERATION_OPTION_SPECS['doc.open']; + const trackChangesOption = openOptions.find((o) => o.name === 'track-changes-json'); + + expect(trackChangesOption).toBeDefined(); + expect(trackChangesOption!.type).toBe('string'); + }); }); diff --git a/apps/cli/src/__tests__/lib/special-handlers.test.ts b/apps/cli/src/__tests__/lib/special-handlers.test.ts new file mode 100644 index 0000000000..0f0bccfe86 --- /dev/null +++ b/apps/cli/src/__tests__/lib/special-handlers.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from 'bun:test'; +import { POST_INVOKE_HOOKS, PRE_INVOKE_HOOKS } from '../../lib/special-handlers'; + +const rawTrackChangesList = { + evaluatedRevision: '0', + total: 2, + items: [ + { + id: 'raw-parent', + handle: { ref: 'tc::body::raw-parent', refStability: 'stable', targetKind: 'trackedChange' }, + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'raw-parent' }, + type: 'insert', + author: 'Missy Fox', + date: '2026-05-20T14:08:00Z', + excerpt: 'ABCXYZ', + overlap: { + visualLayers: [ + { id: 'raw-parent', type: 'insert', relationship: 'parent' }, + { id: 'raw-child', type: 'delete', relationship: 'child' }, + ], + preferredContextTargetId: 'raw-child', + preferredContextTarget: { id: 'raw-child', type: 'delete', relationship: 'child' }, + }, + }, + { + id: 'raw-child', + handle: { ref: 'tc::body::raw-child', refStability: 'stable', targetKind: 'trackedChange' }, + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'raw-child' }, + type: 'delete', + author: 'Vivienne Salisbury', + date: '2026-05-20T14:08:00Z', + excerpt: 'HELLO', + }, + ], +}; + +type Overlap = { + visualLayers: Array<{ id: string }>; + preferredContextTargetId: string; + preferredContextTarget: { id: string }; +}; + +type TrackChangeItem = { + id: string; + overlap?: Overlap; +}; + +describe('special track-changes handlers', () => { + test('normalizes overlap IDs to the same stable IDs as trackChanges.list items', () => { + const hook = POST_INVOKE_HOOKS['trackChanges.list']; + if (!hook) throw new Error('trackChanges.list post hook must be registered'); + + const result = hook(rawTrackChangesList, { editor: {} as never }) as { items: TrackChangeItem[] }; + const parent = result.items[0] as TrackChangeItem & { overlap: Overlap }; + const child = result.items[1] as TrackChangeItem; + + expect(parent.id).not.toBe('raw-parent'); + expect(child.id).not.toBe('raw-child'); + expect(parent.overlap.visualLayers[0].id).toBe(parent.id); + expect(parent.overlap.visualLayers[1].id).toBe(child.id); + expect(parent.overlap.preferredContextTargetId).toBe(child.id); + expect(parent.overlap.preferredContextTarget.id).toBe(child.id); + }); + + test('normalizes overlap IDs on trackChanges.get output', () => { + const hook = POST_INVOKE_HOOKS['trackChanges.get']; + const listHook = POST_INVOKE_HOOKS['trackChanges.list']; + if (!hook) throw new Error('trackChanges.get post hook must be registered'); + if (!listHook) throw new Error('trackChanges.list post hook must be registered'); + + const context = { + editor: { + doc: { + invoke: () => rawTrackChangesList, + }, + }, + }; + const result = hook(rawTrackChangesList.items[0], context as never) as TrackChangeItem & { overlap: Overlap }; + const normalizedList = listHook(rawTrackChangesList, { editor: {} as never }) as { items: TrackChangeItem[] }; + const parentId = normalizedList.items[0]?.id; + const childId = normalizedList.items[1]?.id; + if (!parentId || !childId) throw new Error('expected normalized list to contain parent and child ids'); + + expect(result.id).toBe(parentId); + expect(result.overlap.visualLayers[1].id).toBe(childId); + expect(result.overlap.preferredContextTargetId).toBe(childId); + expect(result.overlap.preferredContextTarget.id).toBe(childId); + }); + + test('flattens formatRange receipts for CLI response validation', () => { + const hook = POST_INVOKE_HOOKS.formatRange; + if (!hook) throw new Error('formatRange post hook must be registered'); + + const result = hook( + { + resolution: { + target: { kind: 'text', blockId: 'p1', range: { start: 1, end: 4 } }, + range: { kind: 'text', segments: [{ blockId: 'p1', range: { start: 1, end: 4 } }] }, + }, + applied: true, + }, + { editor: {} as never }, + ) as { + target: unknown; + resolvedRange: unknown; + receipt: { applied: boolean }; + }; + + expect(result.target).toEqual({ kind: 'text', blockId: 'p1', range: { start: 1, end: 4 } }); + expect(result.resolvedRange).toEqual({ + kind: 'text', + segments: [{ blockId: 'p1', range: { start: 1, end: 4 } }], + }); + expect(result.receipt.applied).toBe(true); + }); + + test('translates stable trackedChangeId comment targets back to raw ids before invoke', () => { + const hook = PRE_INVOKE_HOOKS['comments.create']; + if (!hook) throw new Error('comments.create pre hook must be registered'); + + const normalizedList = POST_INVOKE_HOOKS['trackChanges.list']?.(rawTrackChangesList, { + editor: {} as never, + }) as { items: TrackChangeItem[] }; + const stableId = normalizedList.items[0]?.id; + if (!stableId) throw new Error('expected normalized list to contain a stable id'); + + const result = hook( + { + target: { trackedChangeId: stableId }, + text: 'comment', + }, + { + editor: { + doc: { + invoke: () => rawTrackChangesList, + }, + }, + } as never, + ) as { target: { trackedChangeId: string }; text: string }; + + expect(result.target.trackedChangeId).toBe('raw-parent'); + expect(result.text).toBe('comment'); + }); +}); diff --git a/apps/cli/src/__tests__/lib/validate-type-spec.test.ts b/apps/cli/src/__tests__/lib/validate-type-spec.test.ts index 415ecc8266..621b16cb7c 100644 --- a/apps/cli/src/__tests__/lib/validate-type-spec.test.ts +++ b/apps/cli/src/__tests__/lib/validate-type-spec.test.ts @@ -179,6 +179,39 @@ describe('doc.find select schema — accepts canonical and shorthand forms', () }); }); +describe('comments target schema — accepts selection and tracked-change targets', () => { + const createMetadata = CLI_OPERATION_METADATA['doc.comments.create']; + const patchMetadata = CLI_OPERATION_METADATA['doc.comments.patch']; + const createTargetSchema = createMetadata.params.find((p) => p.name === 'target')?.schema; + const patchTargetSchema = patchMetadata.params.find((p) => p.name === 'target')?.schema; + + if (!createTargetSchema || !patchTargetSchema) { + throw new Error('comments metadata missing target schema'); + } + + const selectionTarget = { + kind: 'selection', + start: { kind: 'text', blockId: '36D666B6', offset: 10 }, + end: { kind: 'text', blockId: '36D666B6', offset: 18 }, + }; + + test('accepts SelectionTarget for comments.create', () => { + expect(() => validateValueAgainstTypeSpec(selectionTarget, createTargetSchema, 'target')).not.toThrow(); + }); + + test('accepts SelectionTarget for comments.patch', () => { + expect(() => validateValueAgainstTypeSpec(selectionTarget, patchTargetSchema, 'target')).not.toThrow(); + }); + + test('accepts tracked-change target without explicit kind for comments.create', () => { + expect(() => validateValueAgainstTypeSpec({ trackedChangeId: 'tc-1' }, createTargetSchema, 'target')).not.toThrow(); + }); + + test('accepts tracked-change target without explicit kind for comments.patch', () => { + expect(() => validateValueAgainstTypeSpec({ trackedChangeId: 'tc-1' }, patchTargetSchema, 'target')).not.toThrow(); + }); +}); + describe('validateValueAgainstTypeSpec – object without explicit properties', () => { // type: 'object' schemas that use additionalProperties (or nothing at all) // must not crash the validator when `properties` is absent. diff --git a/apps/cli/src/cli/operation-hints.ts b/apps/cli/src/cli/operation-hints.ts index 59c07fbb73..fff7c99b31 100644 --- a/apps/cli/src/cli/operation-hints.ts +++ b/apps/cli/src/cli/operation-hints.ts @@ -88,6 +88,7 @@ export const SUCCESS_VERB: Record = { 'blocks.list': 'listed blocks', 'blocks.delete': 'deleted block', 'blocks.deleteRange': 'deleted block range', + formatRange: 'applied style', 'format.apply': 'applied style', ...buildFormatInlineAliasRecord('applied style'), ...buildParagraphRecord('updated paragraph formatting'), @@ -473,6 +474,7 @@ export const OUTPUT_FORMAT: Record = { 'blocks.list': 'plain', 'blocks.delete': 'plain', 'blocks.deleteRange': 'plain', + formatRange: 'mutationReceipt', 'format.apply': 'mutationReceipt', ...buildFormatInlineAliasRecord('mutationReceipt'), ...buildParagraphRecord('plain'), @@ -840,6 +842,7 @@ export const RESPONSE_ENVELOPE_KEY: Record 'blocks.list': 'result', 'blocks.delete': 'result', 'blocks.deleteRange': 'result', + formatRange: null, 'format.apply': null, ...buildFormatInlineAliasRecord(null), ...buildParagraphRecord('result'), @@ -1194,6 +1197,7 @@ export const RESPONSE_VALIDATION_KEY: Partial = 'blocks.list': 'blocks', 'blocks.delete': 'blocks', 'blocks.deleteRange': 'blocks', + formatRange: 'textMutation', 'format.apply': 'textMutation', ...buildFormatInlineAliasRecord('textMutation'), ...buildParagraphRecord('textMutation'), diff --git a/apps/cli/src/cli/operation-params.ts b/apps/cli/src/cli/operation-params.ts index 30e19c65a1..101d042a26 100644 --- a/apps/cli/src/cli/operation-params.ts +++ b/apps/cli/src/cli/operation-params.ts @@ -68,7 +68,7 @@ const CHANGE_MODE_PARAM: CliOperationParamSpec = { kind: 'flag', flag: 'change-mode', type: 'string', - schema: { enum: ['direct', 'tracked'] } as CliTypeSpec, + schema: { type: 'string', enum: ['direct', 'tracked'] } as CliTypeSpec, description: 'Edit mode: "direct" applies changes immediately, "tracked" records as suggestions.', }; const EXPECTED_REVISION_PARAM: CliOperationParamSpec = { @@ -1059,6 +1059,23 @@ const CLI_ONLY_METADATA: Record = { { name: 'overrideType', kind: 'flag', flag: 'override-type', type: 'string' }, { name: 'onMissing', kind: 'flag', flag: 'on-missing', type: 'string' }, { name: 'bootstrapSettlingMs', kind: 'flag', flag: 'bootstrap-settling-ms', type: 'number' }, + { + name: 'trackChanges', + kind: 'jsonFlag', + flag: 'track-changes-json', + type: 'json', + description: 'Track-changes open configuration.', + schema: { + type: 'object', + properties: { + replacements: { + type: 'string', + enum: ['paired', 'independent'], + description: 'Tracked replacement grouping mode.', + }, + }, + } as CliTypeSpec, + }, USER_NAME_PARAM, USER_EMAIL_PARAM, PASSWORD_PARAM, diff --git a/apps/cli/src/cli/types.ts b/apps/cli/src/cli/types.ts index 39384c68bb..93a485fc91 100644 --- a/apps/cli/src/cli/types.ts +++ b/apps/cli/src/cli/types.ts @@ -11,6 +11,7 @@ type TypeSpecBase = { description?: string; + enum?: readonly unknown[]; }; export type CliTypeSpec = @@ -25,6 +26,7 @@ export type CliTypeSpec = type: 'object'; properties: Record; required?: readonly string[]; + additionalProperties?: boolean | CliTypeSpec; } & TypeSpecBase); // --------------------------------------------------------------------------- diff --git a/apps/cli/src/commands/open.ts b/apps/cli/src/commands/open.ts index e7b5d50845..9519d76e66 100644 --- a/apps/cli/src/commands/open.ts +++ b/apps/cli/src/commands/open.ts @@ -27,7 +27,7 @@ const VALID_OVERRIDE_TYPES = new Set(['markdown', 'html', 'text']); const VALID_ON_MISSING = new Set(['seedFromDoc', 'blank', 'error']); export async function runOpen(tokens: string[], context: CommandContext): Promise { - const { parsed, help } = parseOperationArgs('doc.open', tokens, { + const { parsed, args, help } = parseOperationArgs('doc.open', tokens, { commandName: 'open', extraOptionSpecs: [{ name: 'collaboration-file', type: 'string' }], }); @@ -67,6 +67,7 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis const bootstrapSettlingMs = getNumberOption(parsed, 'bootstrap-settling-ms'); const userName = getStringOption(parsed, 'user-name'); const userEmail = getStringOption(parsed, 'user-email'); + const trackChanges = args.trackChanges as { replacements?: 'paired' | 'independent' } | undefined; const allowEnvFallback = context.executionMode !== 'host'; const password = resolvePassword(getStringOption(parsed, 'password'), allowEnvFallback); @@ -142,7 +143,7 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis const user = userName != null || userEmail != null ? { name: userName ?? 'CLI', email: userEmail ?? '' } : undefined; // Build editor open options from override params and password. - const editorOpenOptions: EditorPassThroughOptions & Record = {}; + const editorOpenOptions: EditorPassThroughOptions & { markdown?: string; html?: string; plainText?: string } = {}; if (contentOverride != null && overrideType) { if (overrideType === 'markdown') { editorOpenOptions.markdown = contentOverride; @@ -157,6 +158,13 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis if (password != null) { editorOpenOptions.password = password; } + if (trackChanges?.replacements != null) { + editorOpenOptions.modules = { + trackChanges: { + replacements: trackChanges.replacements, + }, + }; + } return withContextLock( context.io, diff --git a/apps/cli/src/lib/document.ts b/apps/cli/src/lib/document.ts index bd01d7347d..164078fe58 100644 --- a/apps/cli/src/lib/document.ts +++ b/apps/cli/src/lib/document.ts @@ -50,9 +50,16 @@ interface ContentOverrideOptions { plainText?: string; } +type TrackChangesReplacementMode = 'paired' | 'independent'; + /** Options passed through to Editor.open() alongside content overrides. */ export interface EditorPassThroughOptions { password?: string; + modules?: { + trackChanges?: { + replacements?: TrackChangesReplacementMode; + }; + }; } interface OpenDocumentOptions { diff --git a/apps/cli/src/lib/error-mapping.ts b/apps/cli/src/lib/error-mapping.ts index 4a24bc3a75..177f06ab11 100644 --- a/apps/cli/src/lib/error-mapping.ts +++ b/apps/cli/src/lib/error-mapping.ts @@ -13,6 +13,16 @@ import type { CliExposedOperationId } from '../cli/operation-set.js'; import { OPERATION_FAMILY, type OperationFamily } from '../cli/operation-hints.js'; import { CliError, type AdapterLikeError, type CliErrorCode } from './errors.js'; +type ErrorMappingContext = { + commandName?: string; +}; + +const TRACK_CHANGES_REVIEW_HELPER_COMMANDS = new Set(['track-changes accept', 'track-changes reject']); + +function isTrackChangesReviewHelper(operationId: CliExposedOperationId, context?: ErrorMappingContext): boolean { + return operationId === 'trackChanges.decide' && TRACK_CHANGES_REVIEW_HELPER_COMMANDS.has(context?.commandName ?? ''); +} + // --------------------------------------------------------------------------- // Error code extraction // --------------------------------------------------------------------------- @@ -37,10 +47,22 @@ function extractErrorDetails(error: unknown): unknown { // Per-family error mappers (thrown errors) // --------------------------------------------------------------------------- -function mapTrackChangesError(operationId: CliExposedOperationId, error: unknown, code: string | undefined): CliError { +function mapTrackChangesError( + operationId: CliExposedOperationId, + error: unknown, + code: string | undefined, + context?: ErrorMappingContext, +): CliError { const message = extractErrorMessage(error); const details = extractErrorDetails(error); + if (operationId === 'trackChanges.decide' && code === 'TARGET_NOT_FOUND') { + if (isTrackChangesReviewHelper(operationId, context)) { + return new CliError('TRACK_CHANGE_NOT_FOUND', message, { operationId, details }); + } + return new CliError('TARGET_NOT_FOUND', message, { operationId, details }); + } + if (code === 'TARGET_NOT_FOUND' || (typeof message === 'string' && message.includes('was not found'))) { return new CliError('TRACK_CHANGE_NOT_FOUND', message, { operationId, details }); } @@ -354,7 +376,12 @@ function mapDiffError(operationId: CliExposedOperationId, error: unknown, code: const FAMILY_MAPPERS: Record< OperationFamily, - (operationId: CliExposedOperationId, error: unknown, code: string | undefined) => CliError + ( + operationId: CliExposedOperationId, + error: unknown, + code: string | undefined, + context?: ErrorMappingContext, + ) => CliError > = { trackChanges: mapTrackChangesError, comments: mapCommentsError, @@ -385,11 +412,15 @@ function resolveOperationFamily(operationId: CliExposedOperationId): OperationFa * Maps an invoke() exception to a CLI error with the appropriate error code. * Called by the generic dispatch path after every invoke() failure. */ -export function mapInvokeError(operationId: CliExposedOperationId, error: unknown): CliError { +export function mapInvokeError( + operationId: CliExposedOperationId, + error: unknown, + context?: ErrorMappingContext, +): CliError { if (error instanceof CliError) return error; const code = extractErrorCode(error); const family = resolveOperationFamily(operationId); - return FAMILY_MAPPERS[family](operationId, error, code); + return FAMILY_MAPPERS[family](operationId, error, code, context); } // --------------------------------------------------------------------------- @@ -418,7 +449,11 @@ function isReceiptLike(value: unknown): value is ReceiptLike { * Returns null if the result is not a failed receipt (either successful or * not receipt-shaped at all). */ -export function mapFailedReceipt(operationId: CliExposedOperationId, result: unknown): CliError | null { +export function mapFailedReceipt( + operationId: CliExposedOperationId, + result: unknown, + context?: ErrorMappingContext, +): CliError | null { if (!isReceiptLike(result)) return null; if (result.success) return null; @@ -439,6 +474,12 @@ export function mapFailedReceipt(operationId: CliExposedOperationId, result: unk // Track-changes family if (family === 'trackChanges') { + if (operationId === 'trackChanges.decide' && failureCode === 'TARGET_NOT_FOUND') { + if (isTrackChangesReviewHelper(operationId, context)) { + return new CliError('TRACK_CHANGE_NOT_FOUND', failureMessage, { operationId, failure }); + } + return new CliError('TARGET_NOT_FOUND', failureMessage, { operationId, failure }); + } if (failureCode === 'TRACK_CHANGE_COMMAND_UNAVAILABLE') { return new CliError('TRACK_CHANGE_COMMAND_UNAVAILABLE', failureMessage, { operationId, failure }); } diff --git a/apps/cli/src/lib/generic-dispatch.ts b/apps/cli/src/lib/generic-dispatch.ts index 205f38d7dc..b39c1cf0b1 100644 --- a/apps/cli/src/lib/generic-dispatch.ts +++ b/apps/cli/src/lib/generic-dispatch.ts @@ -15,6 +15,7 @@ export type DocOperationRequest = { operationId: CliExposedOperationId; input: Record; context: CommandContext; + commandName?: string; }; /** diff --git a/apps/cli/src/lib/mutation-orchestrator.ts b/apps/cli/src/lib/mutation-orchestrator.ts index c140fa1af9..8db24c01f0 100644 --- a/apps/cli/src/lib/mutation-orchestrator.ts +++ b/apps/cli/src/lib/mutation-orchestrator.ts @@ -54,6 +54,7 @@ function invokeOperation( operationId: CliExposedOperationId, input: Record, options?: Record, + commandName?: string, ): unknown { const apiInput = extractInvokeInput(operationId, input); const preHook = PRE_INVOKE_HOOKS[operationId]; @@ -67,11 +68,11 @@ function invokeOperation( options, }); } catch (error) { - throw mapInvokeError(operationId, error); + throw mapInvokeError(operationId, error, { commandName }); } // Check for failed receipts (non-throwing failure path) - const failedReceiptError = mapFailedReceipt(operationId, result); + const failedReceiptError = mapFailedReceipt(operationId, result, { commandName }); if (failedReceiptError) throw failedReceiptError; const postHook = POST_INVOKE_HOOKS[operationId]; @@ -120,7 +121,7 @@ export async function executeMutationOperation(request: DocOperationRequest): Pr const changeMode = readChangeMode(input); const force = readBoolean(input, 'force'); const expectedRevision = readOptionalNumber(input, 'expectedRevision'); - const commandName = deriveCommandName(operationId); + const commandName = request.commandName ?? deriveCommandName(operationId); const catalog = COMMAND_CATALOG[operationId]; const invokeOptions: Record = {}; @@ -152,7 +153,7 @@ export async function executeMutationOperation(request: DocOperationRequest): Pr const source = doc === '-' ? 'stdin' : 'path'; const opened = await openDocument(doc, context.io); try { - const result = invokeOperation(opened.editor, operationId, input, invokeOptions); + const result = invokeOperation(opened.editor, operationId, input, invokeOptions, commandName); const document: DocumentPayload = { path: source === 'path' ? doc : undefined, source, @@ -204,7 +205,7 @@ export async function executeMutationOperation(request: DocOperationRequest): Pr }); try { - const result = invokeOperation(opened.editor, operationId, input, invokeOptions); + const result = invokeOperation(opened.editor, operationId, input, invokeOptions, commandName); if (dryRun) { const document: DocumentPayload = { diff --git a/apps/cli/src/lib/operation-executor.ts b/apps/cli/src/lib/operation-executor.ts index 0ea224ac6c..76bda7bcf6 100644 --- a/apps/cli/src/lib/operation-executor.ts +++ b/apps/cli/src/lib/operation-executor.ts @@ -238,6 +238,7 @@ export async function executeOperation(request: ExecuteOperationRequest): Promis operationId: docApiId as CliExposedOperationId, input, context: effectiveContext, + commandName, }); } diff --git a/apps/cli/src/lib/read-orchestrator.ts b/apps/cli/src/lib/read-orchestrator.ts index cdc3efc429..51cfa4355d 100644 --- a/apps/cli/src/lib/read-orchestrator.ts +++ b/apps/cli/src/lib/read-orchestrator.ts @@ -35,6 +35,7 @@ function invokeOperation( editor: EditorWithDoc, operationId: CliExposedOperationId, input: Record, + commandName?: string, ): unknown { const apiInput = extractInvokeInput(operationId, input); const preHook = PRE_INVOKE_HOOKS[operationId]; @@ -47,7 +48,7 @@ function invokeOperation( input: transformedInput, }); } catch (error) { - throw mapInvokeError(operationId, error); + throw mapInvokeError(operationId, error, { commandName }); } const postHook = POST_INVOKE_HOOKS[operationId]; @@ -102,13 +103,13 @@ export async function executeReadOperation(request: DocOperationRequest): Promis // snapshot sync) from running past a drift failure. const envelopeKey = resolveResponseEnvelopeKey(operationId); const doc = readOptionalString(input, 'doc'); - const commandName = deriveCommandName(operationId); + const commandName = request.commandName ?? deriveCommandName(operationId); if (doc) { const source = doc === '-' ? 'stdin' : 'path'; const opened = await openDocument(doc, context.io); try { - const result = invokeOperation(opened.editor, operationId, input); + const result = invokeOperation(opened.editor, operationId, input, commandName); const document: DocumentPayload = { path: source === 'path' ? doc : undefined, source, @@ -140,7 +141,7 @@ export async function executeReadOperation(request: DocOperationRequest): Promis }); try { - const result = invokeOperation(opened.editor, operationId, input); + const result = invokeOperation(opened.editor, operationId, input, commandName); // For oneshot collab reads, sync snapshot to keep working.docx current const isHostMode = context.executionMode === 'host' && context.sessionPool != null; diff --git a/apps/cli/src/lib/special-handlers.ts b/apps/cli/src/lib/special-handlers.ts index 24029e20f6..a26a8b235c 100644 --- a/apps/cli/src/lib/special-handlers.ts +++ b/apps/cli/src/lib/special-handlers.ts @@ -27,6 +27,7 @@ type PreInvokeHook = (input: unknown, context: HookContext) => unknown; type PostInvokeHook = (result: unknown, context: HookContext) => unknown; const FORMAT_RECEIPT_OPERATION_IDS: readonly CliExposedOperationId[] = [ + 'formatRange', 'format.apply', ...INLINE_PROPERTY_REGISTRY.map((entry) => `format.${entry.key}` as CliExposedOperationId), ]; @@ -67,6 +68,39 @@ function stableTrackChangeSignature(change: TrackChangeLike): string { return `${type}|${author}|${authorEmail}|${date}|${excerpt}`; } +function normalizeStableTrackChangeId(value: unknown, rawToStableId: ReadonlyMap): unknown { + if (typeof value !== 'string' || value.length === 0) return value; + return rawToStableId.get(value) ?? value; +} + +function normalizeOverlapLayer(value: unknown, rawToStableId: ReadonlyMap): unknown { + const record = asRecord(value); + if (!record) return value; + return { + ...record, + id: normalizeStableTrackChangeId(record.id, rawToStableId), + }; +} + +function normalizeTrackChangeOverlap(value: unknown, rawToStableId: ReadonlyMap): unknown { + const record = asRecord(value); + if (!record) return value; + + const visualLayers = Array.isArray(record.visualLayers) + ? record.visualLayers.map((layer) => normalizeOverlapLayer(layer, rawToStableId)) + : record.visualLayers; + const preferredContextTarget = record.preferredContextTarget + ? normalizeOverlapLayer(record.preferredContextTarget, rawToStableId) + : record.preferredContextTarget; + + return { + ...record, + ...(Array.isArray(record.visualLayers) ? { visualLayers } : {}), + preferredContextTargetId: normalizeStableTrackChangeId(record.preferredContextTargetId, rawToStableId), + ...(record.preferredContextTarget ? { preferredContextTarget } : {}), + }; +} + /** * Builds stable-ID ↔ raw-ID mappings from a track-changes list result. * The CLI uses SHA-1-based stable IDs instead of adapter raw IDs. @@ -85,14 +119,14 @@ function buildStableIdMappings(rawListResult: unknown): { const rawToStableId = new Map(); const signatureCounts = new Map(); - const normalizedItems = asArray(record.items) + const entries = asArray(record.items) .map((entry) => asRecord(entry)) .filter((entry): entry is Record => Boolean(entry)) .map((entry) => { const rawId = (typeof entry.id === 'string' && entry.id.length > 0 ? entry.id : undefined) ?? asTrackChangeAddress(entry.address)?.entityId; - if (!rawId) return entry; + if (!rawId) return { entry }; const signature = stableTrackChangeSignature(entry); const hash = createHash('sha1').update(signature).digest('hex').slice(0, 24); @@ -103,16 +137,23 @@ function buildStableIdMappings(rawListResult: unknown): { stableToRawId.set(stableId, rawId); rawToStableId.set(rawId, stableId); - const normalizedAddress = asTrackChangeAddress(entry.address); - const handleRecord = asRecord(entry.handle); - return { - ...entry, - id: stableId, - address: normalizedAddress ? { ...normalizedAddress, entityId: stableId } : entry.address, - handle: handleRecord ? { ...handleRecord, ref: `tc:${stableId}` } : entry.handle, - }; + return { entry, rawId, stableId }; }); + const normalizedItems = entries.map(({ entry, rawId, stableId }) => { + if (!rawId || !stableId) return entry; + + const normalizedAddress = asTrackChangeAddress(entry.address); + const handleRecord = asRecord(entry.handle); + return { + ...entry, + id: stableId, + address: normalizedAddress ? { ...normalizedAddress, entityId: stableId } : entry.address, + handle: handleRecord ? { ...handleRecord, ref: `tc:${stableId}` } : entry.handle, + ...(entry.overlap !== undefined ? { overlap: normalizeTrackChangeOverlap(entry.overlap, rawToStableId) } : {}), + }; + }); + return { normalizedResult: { ...record, @@ -172,6 +213,31 @@ const resolveReviewDecideId: PreInvokeHook = (input, context) => { return { ...record, target: { ...target, id: rawId } }; }; +/** + * Comment target shapes can carry trackedChangeId values copied from + * `trackChanges.list`, which the CLI normalizes to stable SHA-1 IDs. The + * adapter expects raw/runtime ids, so translate the target id before invoke. + */ +const resolveCommentTrackedChangeTargetId: PreInvokeHook = (input, context) => { + const record = asRecord(input); + if (!record) return input; + + const target = asRecord(record.target); + if (!target) return input; + + const stableId = typeof target.trackedChangeId === 'string' ? target.trackedChangeId : undefined; + if (!stableId) return input; + + const listResult = context.editor.doc.invoke({ + operationId: 'trackChanges.list' as const, + input: {}, + }); + const { stableToRawId } = buildStableIdMappings(listResult); + const rawId = stableToRawId.get(stableId) ?? stableId; + + return { ...record, target: { ...target, trackedChangeId: rawId } }; +}; + // --------------------------------------------------------------------------- // Post-invoke hooks // --------------------------------------------------------------------------- @@ -207,6 +273,7 @@ const normalizeTrackChangeGetId: PostInvokeHook = (result, context) => { ...record, id: stableId, address: normalizedAddress ? { ...normalizedAddress, entityId: stableId } : record.address, + ...(record.overlap !== undefined ? { overlap: normalizeTrackChangeOverlap(record.overlap, rawToStableId) } : {}), }; }; @@ -242,6 +309,9 @@ export const PRE_INVOKE_HOOKS: Partialeditor.doc.footnotes.update(...) | [`footnotes.update`](/document-api/reference/footnotes/update) | | editor.doc.footnotes.remove(...) | [`footnotes.remove`](/document-api/reference/footnotes/remove) | | editor.doc.footnotes.configure(...) | [`footnotes.configure`](/document-api/reference/footnotes/configure) | +| editor.doc.formatRange(...) | [`formatRange`](/document-api/reference/format/apply) | | editor.doc.format.apply(...) | [`format.apply`](/document-api/reference/format/apply) | | editor.doc.format.bold(...) | [`format.bold`](/document-api/reference/format/bold) | | editor.doc.format.italic(...) | [`format.italic`](/document-api/reference/format/italic) | diff --git a/apps/docs/document-api/migration.mdx b/apps/docs/document-api/migration.mdx index ee609c342f..daaae7b46d 100644 --- a/apps/docs/document-api/migration.mdx +++ b/apps/docs/document-api/migration.mdx @@ -1,7 +1,7 @@ --- title: Migrate to the Document API sidebarTitle: Migrate to Document API -keywords: "migrate commands, document api migration, editor commands deprecated, prosemirror deprecated, editor.doc" +keywords: 'migrate commands, document api migration, editor commands deprecated, prosemirror deprecated, editor.doc' --- `editor.commands`, `editor.state`, `editor.view`, and direct ProseMirror access are deprecated and will be removed in a future version. The [Document API](/document-api/overview) (`editor.doc`) is the replacement for all programmatic document operations. @@ -101,8 +101,8 @@ editor.commands.rejectTrackedChange(changeId); // After editor.doc.trackChanges.list(); -editor.doc.trackChanges.decide({ id: changeId, decision: 'accept' }); -editor.doc.trackChanges.decide({ id: changeId, decision: 'reject' }); +editor.doc.trackChanges.decide({ decision: 'accept', target: { id: changeId } }); +editor.doc.trackChanges.decide({ decision: 'reject', target: { id: changeId } }); ``` ### Tables diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 4efc168b52..28cbaa4786 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -152,6 +152,7 @@ "apps/docs/document-api/reference/footnotes/remove.mdx", "apps/docs/document-api/reference/footnotes/update.mdx", "apps/docs/document-api/reference/format/apply.mdx", + "apps/docs/document-api/reference/format/apply.mdx", "apps/docs/document-api/reference/format/b-cs.mdx", "apps/docs/document-api/reference/format/bold.mdx", "apps/docs/document-api/reference/format/border.mdx", @@ -524,6 +525,7 @@ "aliasMemberPaths": ["format.strikethrough"], "key": "format", "operationIds": [ + "formatRange", "format.apply", "format.bold", "format.italic", @@ -1077,5 +1079,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "266b6bb8fa042ebd94c3da6e11652471f49c40a061960f3df9055cf64c4bcbbe" + "sourceHash": "b154213fe6dbcb96de30f11c473aca2946148af73775fbb642b5c750c6a0bc46" } diff --git a/apps/docs/document-api/reference/capabilities/get.mdx b/apps/docs/document-api/reference/capabilities/get.mdx index 9746e87c3a..28b5000970 100644 --- a/apps/docs/document-api/reference/capabilities/get.mdx +++ b/apps/docs/document-api/reference/capabilities/get.mdx @@ -1260,6 +1260,11 @@ _No fields._ | `operations.format.webHidden.dryRun` | boolean | yes | | | `operations.format.webHidden.reasons` | enum[] | no | | | `operations.format.webHidden.tracked` | boolean | yes | | +| `operations.formatRange` | object | yes | | +| `operations.formatRange.available` | boolean | yes | | +| `operations.formatRange.dryRun` | boolean | yes | | +| `operations.formatRange.reasons` | enum[] | no | | +| `operations.formatRange.tracked` | boolean | yes | | | `operations.get` | object | yes | | | `operations.get.available` | boolean | yes | | | `operations.get.dryRun` | boolean | yes | | @@ -3566,6 +3571,11 @@ _No fields._ "dryRun": true, "tracked": false }, + "formatRange": { + "available": true, + "dryRun": true, + "tracked": true + }, "get": { "available": true, "dryRun": false, @@ -13194,6 +13204,41 @@ _No fields._ ], "type": "object" }, + "formatRange": { + "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" + }, "get": { "additionalProperties": false, "properties": { @@ -20385,6 +20430,7 @@ _No fields._ "insert", "replace", "delete", + "formatRange", "blocks.list", "blocks.delete", "blocks.deleteRange", diff --git a/apps/docs/document-api/reference/comments/create.mdx b/apps/docs/document-api/reference/comments/create.mdx index b805fbb0b1..2b0df61bad 100644 --- a/apps/docs/document-api/reference/comments/create.mdx +++ b/apps/docs/document-api/reference/comments/create.mdx @@ -20,14 +20,14 @@ Create a new comment thread (or reply when parentCommentId is given). ## Expected result -Returns a Receipt confirming the comment was created; reports NO_OP if the anchor target is invalid. +Returns a Receipt confirming the comment was created, including the new comment id; reports NO_OP if the anchor target is invalid. ## Input fields | Field | Type | Required | Description | | --- | --- | --- | --- | | `parentCommentId` | string | no | | -| `target` | TextAddress \\| TextTarget | no | One of: TextAddress, TextTarget | +| `target` | TextAddress \\| TextTarget \\| SelectionTarget \\| CommentTrackedChangeTarget | no | One of: TextAddress, TextTarget, SelectionTarget, CommentTrackedChangeTarget | | `text` | string | yes | | ### Example request @@ -53,6 +53,7 @@ Returns a Receipt confirming the comment was created; reports NO_OP if the ancho | Field | Type | Required | Description | | --- | --- | --- | --- | +| `id` | string | yes | | | `inserted` | EntityAddress[] | no | | | `removed` | EntityAddress[] | no | | | `success` | `true` | yes | Constant: `true` | @@ -72,6 +73,7 @@ Returns a Receipt confirming the comment was created; reports NO_OP if the ancho ```json { + "id": "id-001", "inserted": [ { "entityId": "entity-789", @@ -113,13 +115,19 @@ Returns a Receipt confirming the comment was created; reports NO_OP if the ancho "type": "string" }, "target": { - "description": "Text range to anchor the comment. Accepts either a single-block TextAddress {kind:'text', blockId, range} or a multi-segment TextTarget {kind:'text', segments:[{blockId, range}, ...]} for selections that span blocks.", + "description": "Comment target. Accepts a TextAddress, TextTarget, SelectionTarget, or {trackedChangeId, kind?:'trackedChange'} to anchor directly on tracked content.", "oneOf": [ { "$ref": "#/$defs/TextAddress" }, { "$ref": "#/$defs/TextTarget" + }, + { + "$ref": "#/$defs/SelectionTarget" + }, + { + "$ref": "#/$defs/CommentTrackedChangeTarget" } ] }, @@ -141,7 +149,7 @@ Returns a Receipt confirming the comment was created; reports NO_OP if the ancho { "oneOf": [ { - "$ref": "#/$defs/ReceiptSuccess" + "$ref": "#/$defs/CommentsCreateSuccess" }, { "additionalProperties": false, @@ -184,7 +192,7 @@ Returns a Receipt confirming the comment was created; reports NO_OP if the ancho ```json { - "$ref": "#/$defs/ReceiptSuccess" + "$ref": "#/$defs/CommentsCreateSuccess" } ``` diff --git a/apps/docs/document-api/reference/comments/get.mdx b/apps/docs/document-api/reference/comments/get.mdx index cdf60532ee..f2e4741461 100644 --- a/apps/docs/document-api/reference/comments/get.mdx +++ b/apps/docs/document-api/reference/comments/get.mdx @@ -49,6 +49,7 @@ Returns a CommentInfo object with the comment text, author, date, and thread met | `createdTime` | number | no | | | `creatorEmail` | string | no | | | `creatorName` | string | no | | +| `deletedText` | any | no | | | `importedId` | string | no | | | `isInternal` | boolean | no | | | `parentCommentId` | string | no | | @@ -58,6 +59,13 @@ Returns a CommentInfo object with the comment text, author, date, and thread met | `target.segments` | TextSegment[] | no | | | `target.story` | StoryLocator | no | StoryLocator | | `text` | string | no | | +| `trackedChange` | boolean | no | | +| `trackedChangeAnchorKey` | any | no | | +| `trackedChangeDisplayType` | any | no | | +| `trackedChangeLink` | CommentTrackedChangeLink \\| null | no | One of: CommentTrackedChangeLink, null | +| `trackedChangeStory` | StoryLocator \\| null | no | One of: StoryLocator, null | +| `trackedChangeText` | any | no | | +| `trackedChangeType` | enum | no | `"insert"`, `"delete"`, `"format"` | ### Example response @@ -125,6 +133,12 @@ Returns a CommentInfo object with the comment text, author, date, and thread met "creatorName": { "type": "string" }, + "deletedText": { + "type": [ + "string", + "null" + ] + }, "importedId": { "type": "string" }, @@ -145,6 +159,54 @@ Returns a CommentInfo object with the comment text, author, date, and thread met }, "text": { "type": "string" + }, + "trackedChange": { + "type": "boolean" + }, + "trackedChangeAnchorKey": { + "type": [ + "string", + "null" + ] + }, + "trackedChangeDisplayType": { + "type": [ + "string", + "null" + ] + }, + "trackedChangeLink": { + "oneOf": [ + { + "$ref": "#/$defs/CommentTrackedChangeLink" + }, + { + "type": "null" + } + ] + }, + "trackedChangeStory": { + "oneOf": [ + { + "$ref": "#/$defs/StoryLocator" + }, + { + "type": "null" + } + ] + }, + "trackedChangeText": { + "type": [ + "string", + "null" + ] + }, + "trackedChangeType": { + "enum": [ + "insert", + "delete", + "format" + ] } }, "required": [ diff --git a/apps/docs/document-api/reference/comments/list.mdx b/apps/docs/document-api/reference/comments/list.mdx index 96e21e03ed..d622a5bbd5 100644 --- a/apps/docs/document-api/reference/comments/list.mdx +++ b/apps/docs/document-api/reference/comments/list.mdx @@ -143,6 +143,12 @@ Returns a CommentsListResult with an array of comment threads and total count. "creatorName": { "type": "string" }, + "deletedText": { + "type": [ + "string", + "null" + ] + }, "handle": { "$ref": "#/$defs/ResolvedHandle" }, @@ -169,6 +175,54 @@ Returns a CommentsListResult with an array of comment threads and total count. }, "text": { "type": "string" + }, + "trackedChange": { + "type": "boolean" + }, + "trackedChangeAnchorKey": { + "type": [ + "string", + "null" + ] + }, + "trackedChangeDisplayType": { + "type": [ + "string", + "null" + ] + }, + "trackedChangeLink": { + "oneOf": [ + { + "$ref": "#/$defs/CommentTrackedChangeLink" + }, + { + "type": "null" + } + ] + }, + "trackedChangeStory": { + "oneOf": [ + { + "$ref": "#/$defs/StoryLocator" + }, + { + "type": "null" + } + ] + }, + "trackedChangeText": { + "type": [ + "string", + "null" + ] + }, + "trackedChangeType": { + "enum": [ + "insert", + "delete", + "format" + ] } }, "required": [ diff --git a/apps/docs/document-api/reference/comments/patch.mdx b/apps/docs/document-api/reference/comments/patch.mdx index 60204130ca..e689e20c1b 100644 --- a/apps/docs/document-api/reference/comments/patch.mdx +++ b/apps/docs/document-api/reference/comments/patch.mdx @@ -29,12 +29,7 @@ Returns a Receipt confirming the comment was updated; reports NO_OP if no fields | `commentId` | string | yes | | | `isInternal` | boolean | no | | | `status` | enum | no | `"resolved"`, `"active"` | -| `target` | TextAddress | no | TextAddress | -| `target.blockId` | string | no | | -| `target.kind` | `"text"` | no | Constant: `"text"` | -| `target.range` | Range | no | Range | -| `target.range.end` | integer | no | | -| `target.range.start` | integer | no | | +| `target` | TextAddress \\| TextTarget \\| SelectionTarget \\| CommentTrackedChangeTarget | no | One of: TextAddress, TextTarget, SelectionTarget, CommentTrackedChangeTarget | | `text` | string | no | | ### Example request @@ -131,7 +126,20 @@ Returns a Receipt confirming the comment was updated; reports NO_OP if no fields ] }, "target": { - "$ref": "#/$defs/TextAddress" + "oneOf": [ + { + "$ref": "#/$defs/TextAddress" + }, + { + "$ref": "#/$defs/TextTarget" + }, + { + "$ref": "#/$defs/SelectionTarget" + }, + { + "$ref": "#/$defs/CommentTrackedChangeTarget" + } + ] }, "text": { "description": "Updated comment text.", diff --git a/apps/docs/document-api/reference/extract.mdx b/apps/docs/document-api/reference/extract.mdx index 00057d1914..1f39be85d6 100644 --- a/apps/docs/document-api/reference/extract.mdx +++ b/apps/docs/document-api/reference/extract.mdx @@ -304,10 +304,11 @@ _No fields._ "type": "string" }, "type": { - "description": "Aggregate type at the entity level. In paired replacement mode, a delete+insert pair shares one entity and this collapses to 'insert'; per-half type lives on block.textSpans[].trackedChanges[].", + "description": "Entity-level type. In paired replacement mode, a delete+insert pair shares one entity with type 'replacement'; per-half type lives on block.textSpans[].trackedChanges[].", "enum": [ "insert", "delete", + "replacement", "format" ], "type": "string" diff --git a/apps/docs/document-api/reference/format/index.mdx b/apps/docs/document-api/reference/format/index.mdx index df4abd5fde..feefdc85a8 100644 --- a/apps/docs/document-api/reference/format/index.mdx +++ b/apps/docs/document-api/reference/format/index.mdx @@ -12,6 +12,7 @@ Canonical formatting mutation with directive semantics ('on', 'off', 'clear'). | Operation | Member path | Mutates | Idempotency | Tracked | Dry run | | --- | --- | --- | --- | --- | --- | +| formatRange | `formatRange` | Yes | `conditional` | Yes | Yes | | format.apply | `format.apply` | Yes | `conditional` | Yes | Yes | | format.bold | `format.bold` | Yes | `conditional` | Yes | Yes | | format.italic | `format.italic` | Yes | `conditional` | Yes | Yes | diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index 49a58b3833..e590891c7c 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -24,7 +24,7 @@ This reference is sourced from `packages/document-api/src/contract/*`. | Capabilities | 1 | 0 | 1 | [Open](/document-api/reference/capabilities/index) | | Create | 6 | 0 | 6 | [Open](/document-api/reference/create/index) | | Sections | 18 | 0 | 18 | [Open](/document-api/reference/sections/index) | -| Format | 44 | 1 | 45 | [Open](/document-api/reference/format/index) | +| Format | 45 | 1 | 46 | [Open](/document-api/reference/format/index) | | Styles | 1 | 0 | 1 | [Open](/document-api/reference/styles/index) | | Lists | 39 | 0 | 39 | [Open](/document-api/reference/lists/index) | | Comments | 5 | 0 | 5 | [Open](/document-api/reference/comments/index) | @@ -131,6 +131,7 @@ The tables below are grouped by namespace. | Operation | API member path | Description | | --- | --- | --- | +| formatRange | editor.doc.formatRange(...) | Legacy root-level alias for inline range formatting. Routes to `format.apply` for compatibility with older callers. | | format.apply | editor.doc.format.apply(...) | Apply inline run-property patch changes to the target range with explicit set/clear semantics. | | format.bold | editor.doc.format.bold(...) | Set or clear the `bold` inline run property on the target text range. | | format.italic | editor.doc.format.italic(...) | Set or clear the `italic` inline run property on the target text range. | @@ -243,7 +244,7 @@ The tables below are grouped by namespace. | --- | --- | --- | | trackChanges.list | editor.doc.trackChanges.list(...) | List all tracked changes in the document. | | trackChanges.get | editor.doc.trackChanges.get(...) | Retrieve a single tracked change by ID. | -| trackChanges.decide | editor.doc.trackChanges.decide(...) | Accept or reject a tracked change (by ID or scope: all). | +| trackChanges.decide | editor.doc.trackChanges.decide(...) | Accept or reject tracked changes by ID, range, or scope: all (optionally filtered by story). | #### Query diff --git a/apps/docs/document-api/reference/track-changes/decide.mdx b/apps/docs/document-api/reference/track-changes/decide.mdx index cfd98e37fb..e1f3723ca9 100644 --- a/apps/docs/document-api/reference/track-changes/decide.mdx +++ b/apps/docs/document-api/reference/track-changes/decide.mdx @@ -1,14 +1,14 @@ --- title: trackChanges.decide sidebarTitle: trackChanges.decide -description: "Accept or reject a tracked change (by ID or scope: all)." +description: "Accept or reject tracked changes by ID, range, or scope: all (optionally filtered by story)." --- {/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} ## Summary -Accept or reject a tracked change (by ID or scope: all). +Accept or reject tracked changes by ID, range, or scope: all (optionally filtered by story). - Operation ID: `trackChanges.decide` - API member path: `editor.doc.trackChanges.decide(...)` @@ -20,14 +20,14 @@ Accept or reject a tracked change (by ID or scope: all). ## Expected result -Returns a Receipt confirming the decision was applied; reports NO_OP if the change was already resolved. +Returns a Receipt confirming the decision was applied; reports NO_OP if the change was already resolved and typed failures for unsupported or denied tracked-change decisions. ## Input fields | Field | Type | Required | Description | | --- | --- | --- | --- | | `decision` | enum | yes | `"accept"`, `"reject"` | -| `target` | object \\| object | yes | One of: object, object | +| `target` | object \\| object(kind="range") \\| object | yes | One of: object, object(kind="range"), object | ### Example request @@ -60,7 +60,7 @@ Returns a Receipt confirming the decision was applied; reports NO_OP if the chan | Field | Type | Required | Description | | --- | --- | --- | --- | | `failure` | object | yes | | -| `failure.code` | enum | yes | `"NO_OP"` | +| `failure.code` | enum | yes | `"NO_OP"`, `"INVALID_TARGET"`, `"TARGET_NOT_FOUND"`, `"CAPABILITY_UNAVAILABLE"`, `"PERMISSION_DENIED"`, `"PRECONDITION_FAILED"`, `"COMMENT_CASCADE_PARTIAL"` | | `failure.details` | any | no | | | `failure.message` | string | yes | | | `success` | `false` | yes | Constant: `false` | @@ -97,6 +97,12 @@ Returns a Receipt confirming the decision was applied; reports NO_OP if the chan ## Non-applied failure codes - `NO_OP` +- `INVALID_TARGET` +- `TARGET_NOT_FOUND` +- `CAPABILITY_UNAVAILABLE` +- `PERMISSION_DENIED` +- `PRECONDITION_FAILED` +- `COMMENT_CASCADE_PARTIAL` ## Raw schemas @@ -128,6 +134,29 @@ Returns a Receipt confirming the decision was applied; reports NO_OP if the chan ], "type": "object" }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "range" + }, + "part": { + "description": "Optional part discriminator for the range target.", + "type": "string" + }, + "range": { + "$ref": "#/$defs/TextTarget" + }, + "story": { + "$ref": "#/$defs/StoryLocator" + } + }, + "required": [ + "kind", + "range" + ], + "type": "object" + }, { "additionalProperties": false, "properties": { @@ -135,6 +164,17 @@ Returns a Receipt confirming the decision was applied; reports NO_OP if the chan "enum": [ "all" ] + }, + "story": { + "description": "Optional explicit bulk filter. Omit or pass 'all' to target every revision-capable story, or pass a StoryLocator to scope the decision to one story.", + "oneOf": [ + { + "$ref": "#/$defs/StoryLocator" + }, + { + "const": "all" + } + ] } }, "required": [ @@ -169,7 +209,13 @@ Returns a Receipt confirming the decision was applied; reports NO_OP if the chan "properties": { "code": { "enum": [ - "NO_OP" + "NO_OP", + "INVALID_TARGET", + "TARGET_NOT_FOUND", + "CAPABILITY_UNAVAILABLE", + "PERMISSION_DENIED", + "PRECONDITION_FAILED", + "COMMENT_CASCADE_PARTIAL" ] }, "details": {}, @@ -216,7 +262,13 @@ Returns a Receipt confirming the decision was applied; reports NO_OP if the chan "properties": { "code": { "enum": [ - "NO_OP" + "NO_OP", + "INVALID_TARGET", + "TARGET_NOT_FOUND", + "CAPABILITY_UNAVAILABLE", + "PERMISSION_DENIED", + "PRECONDITION_FAILED", + "COMMENT_CASCADE_PARTIAL" ] }, "details": {}, diff --git a/apps/docs/document-api/reference/track-changes/get.mdx b/apps/docs/document-api/reference/track-changes/get.mdx index f3d9ab8a54..2dd2539378 100644 --- a/apps/docs/document-api/reference/track-changes/get.mdx +++ b/apps/docs/document-api/reference/track-changes/get.mdx @@ -20,7 +20,7 @@ Retrieve a single tracked change by ID. ## Expected result -Returns a TrackChangeInfo object with the change type, author, date, affected content, and raw imported Word OOXML revision IDs (`w:id`) when available. +Returns a TrackChangeInfo object with the change type (`insert`, `delete`, `replacement`, `format`), author, date, affected content, and raw imported Word OOXML revision IDs (`w:id`) when available. ## Input fields @@ -54,9 +54,13 @@ Returns a TrackChangeInfo object with the change type, author, date, affected co | `authorEmail` | string | no | | | `authorImage` | string | no | | | `date` | string | no | | +| `deletedText` | string | no | | | `excerpt` | string | no | | +| `grouping` | enum | no | `"standalone"`, `"replacement-pair"`, `"unknown"` | | `id` | string | yes | | -| `type` | enum | yes | `"insert"`, `"delete"`, `"format"` | +| `insertedText` | string | no | | +| `pairedWithChangeId` | any | no | | +| `type` | enum | yes | `"insert"`, `"delete"`, `"replacement"`, `"format"` | | `wordRevisionIds` | object | no | | | `wordRevisionIds.delete` | string | no | | | `wordRevisionIds.format` | string | no | | @@ -75,13 +79,10 @@ Returns a TrackChangeInfo object with the change type, author, date, affected co "storyType": "body" } }, - "author": "Jane Doe", + "grouping": "standalone", "id": "id-001", - "type": "insert", - "wordRevisionIds": { - "delete": "example", - "insert": "example" - } + "pairedWithChangeId": {}, + "type": "insert" } ``` @@ -135,16 +136,36 @@ Returns a TrackChangeInfo object with the change type, author, date, affected co "date": { "type": "string" }, + "deletedText": { + "type": "string" + }, "excerpt": { "type": "string" }, + "grouping": { + "enum": [ + "standalone", + "replacement-pair", + "unknown" + ] + }, "id": { "type": "string" }, + "insertedText": { + "type": "string" + }, + "pairedWithChangeId": { + "type": [ + "string", + "null" + ] + }, "type": { "enum": [ "insert", "delete", + "replacement", "format" ] }, diff --git a/apps/docs/document-api/reference/track-changes/list.mdx b/apps/docs/document-api/reference/track-changes/list.mdx index 6411a19b16..fc9e039d74 100644 --- a/apps/docs/document-api/reference/track-changes/list.mdx +++ b/apps/docs/document-api/reference/track-changes/list.mdx @@ -20,7 +20,7 @@ List all tracked changes in the document. ## Expected result -Returns a TrackChangesListResult with tracked change entries, total count, and raw imported Word OOXML revision IDs (`w:id`) when available. +Returns a TrackChangesListResult with tracked change entries (`insert`, `delete`, `replacement`, `format`), total count, and raw imported Word OOXML revision IDs (`w:id`) when available. ## Input fields @@ -29,7 +29,7 @@ Returns a TrackChangesListResult with tracked change entries, total count, and r | `in` | StoryLocator \\| `"all"` | no | One of: StoryLocator, `"all"` | | `limit` | integer | no | | | `offset` | integer | no | | -| `type` | enum | no | `"insert"`, `"delete"`, `"format"` | +| `type` | enum | no | `"insert"`, `"delete"`, `"replacement"`, `"format"` | ### Example request @@ -68,18 +68,15 @@ Returns a TrackChangesListResult with tracked change entries, total count, and r "storyType": "body" } }, - "author": "Jane Doe", + "grouping": "standalone", "handle": { "ref": "handle:abc123", "refStability": "stable", "targetKind": "text" }, "id": "id-001", - "type": "insert", - "wordRevisionIds": { - "delete": "example", - "insert": "example" - } + "pairedWithChangeId": {}, + "type": "insert" } ], "page": { @@ -126,10 +123,11 @@ Returns a TrackChangesListResult with tracked change entries, total count, and r "type": "integer" }, "type": { - "description": "Filter by change type: 'insert', 'delete', or 'format'.", + "description": "Filter by change type: 'insert', 'delete', 'replacement', or 'format'.", "enum": [ "insert", "delete", + "replacement", "format" ] } @@ -166,19 +164,39 @@ Returns a TrackChangesListResult with tracked change entries, total count, and r "date": { "type": "string" }, + "deletedText": { + "type": "string" + }, "excerpt": { "type": "string" }, + "grouping": { + "enum": [ + "standalone", + "replacement-pair", + "unknown" + ] + }, "handle": { "$ref": "#/$defs/ResolvedHandle" }, "id": { "type": "string" }, + "insertedText": { + "type": "string" + }, + "pairedWithChangeId": { + "type": [ + "string", + "null" + ] + }, "type": { "enum": [ "insert", "delete", + "replacement", "format" ] }, diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index c886ed7c82..1954e2b2e5 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -746,6 +746,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | Operation | CLI command | Description | | --- | --- | --- | +| `doc.formatRange` | `format-range` | Legacy root-level alias for inline range formatting. Routes to `format.apply` for compatibility with older callers. | | `doc.format.apply` | `format apply` | Apply inline run-property patch changes to the target range with explicit set/clear semantics. | | `doc.format.bold` | `format bold` | Set or clear the `bold` inline run property on the target text range. | | `doc.format.italic` | `format italic` | Set or clear the `italic` inline run property on the target text range. | @@ -1006,7 +1007,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | --- | --- | --- | | `doc.trackChanges.list` | `track-changes list` | List all tracked changes in the document. | | `doc.trackChanges.get` | `track-changes get` | Retrieve a single tracked change by ID. | -| `doc.trackChanges.decide` | `track-changes decide` | Accept or reject a tracked change (by ID or scope: all). | +| `doc.trackChanges.decide` | `track-changes decide` | Accept or reject tracked changes by ID, range, or scope: all (optionally filtered by story). | #### History @@ -1224,6 +1225,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | Operation | CLI command | Description | | --- | --- | --- | +| `doc.format_range` | `format-range` | Legacy root-level alias for inline range formatting. Routes to `format.apply` for compatibility with older callers. | | `doc.format.apply` | `format apply` | Apply inline run-property patch changes to the target range with explicit set/clear semantics. | | `doc.format.bold` | `format bold` | Set or clear the `bold` inline run property on the target text range. | | `doc.format.italic` | `format italic` | Set or clear the `italic` inline run property on the target text range. | @@ -1484,7 +1486,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | --- | --- | --- | | `doc.track_changes.list` | `track-changes list` | List all tracked changes in the document. | | `doc.track_changes.get` | `track-changes get` | Retrieve a single tracked change by ID. | -| `doc.track_changes.decide` | `track-changes decide` | Accept or reject a tracked change (by ID or scope: all). | +| `doc.track_changes.decide` | `track-changes decide` | Accept or reject tracked changes by ID, range, or scope: all (optionally filtered by story). | #### History diff --git a/apps/mcp/src/generated/catalog.ts b/apps/mcp/src/generated/catalog.ts index 4cd0a69ba6..b40215a3fe 100644 --- a/apps/mcp/src/generated/catalog.ts +++ b/apps/mcp/src/generated/catalog.ts @@ -1,5139 +1,6021 @@ // Auto-generated from packages/sdk/tools/catalog.json // Do not edit manually — re-run generate:all to update. export const MCP_TOOL_CATALOG = { - contractVersion: '0.1.0', - generatedAt: null, - toolCount: 10, - tools: [ + "contractVersion": "0.1.0", + "generatedAt": null, + "toolCount": 10, + "tools": [ { - toolName: 'superdoc_get_content', - description: - 'Read document content in various formats. Call this first in any workflow to understand document structure before making edits. Action "blocks" returns structured block data with nodeId, nodeType, textPreview, optional full text when includeText:true, formatting properties (fontFamily, fontSize, color, bold, underline, alignment), and ref handles for immediate use with superdoc_edit or superdoc_format. When you need to evaluate or rewrite existing paragraphs or clauses, prefer action "blocks" with includeText:true so you can identify the correct block and then target it by nodeId. Action "text" and "markdown" return the full document as plain text or Markdown. Action "html" returns HTML. Action "info" returns document metadata: word count, paragraph count, page count, outline, available styles, and capability flags. The "blocks" action supports pagination via "offset" and "limit", and filtering via "nodeTypes". Other actions ignore these parameters. This tool never modifies the document. Do NOT call superdoc_edit or superdoc_format without first reading blocks to get valid refs and formatting reference values.\n\nEXAMPLES:\n 1. {"action":"blocks"}\n 2. {"action":"blocks","includeText":true,"offset":0,"limit":20}\n 3. {"action":"blocks","offset":0,"limit":20,"nodeTypes":["heading","paragraph"]}\n 4. {"action":"text"}\n 5. {"action":"info"}', - inputSchema: { - type: 'object', - properties: { - action: { - type: 'string', - enum: ['blocks', 'extract', 'html', 'info', 'markdown', 'text'], - description: 'The action to perform. One of: blocks, extract, html, info, markdown, text.', - }, - unflattenLists: { - type: 'boolean', - description: - "When true, flattens nested list structures in output. Default: false. Only for action 'html'. Omit for other actions.", - }, - offset: { - type: 'number', - minimum: 0, - description: "Number of blocks to skip. Default: 0. Only for action 'blocks'. Omit for other actions.", - }, - limit: { - type: 'number', - minimum: 1, - description: - "Maximum blocks to return. Omit for all blocks. Only for action 'blocks'. Omit for other actions.", - }, - nodeTypes: { - type: 'array', - items: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - ], + "toolName": "superdoc_get_content", + "description": "Read document content in various formats. Call this first in any workflow to understand document structure before making edits. Action \"blocks\" returns structured block data with nodeId, nodeType, textPreview, optional full text when includeText:true, formatting properties (fontFamily, fontSize, color, bold, underline, alignment), and ref handles for immediate use with superdoc_edit or superdoc_format. When you need to evaluate or rewrite existing paragraphs or clauses, prefer action \"blocks\" with includeText:true so you can identify the correct block and then target it by nodeId. Action \"text\" and \"markdown\" return the full document as plain text or Markdown. Action \"html\" returns HTML. Action \"info\" returns document metadata: word count, paragraph count, page count, outline, available styles, and capability flags. The \"blocks\" action supports pagination via \"offset\" and \"limit\", and filtering via \"nodeTypes\". Other actions ignore these parameters. This tool never modifies the document. Do NOT call superdoc_edit or superdoc_format without first reading blocks to get valid refs and formatting reference values.\n\nEXAMPLES:\n 1. {\"action\":\"blocks\"}\n 2. {\"action\":\"blocks\",\"includeText\":true,\"offset\":0,\"limit\":20}\n 3. {\"action\":\"blocks\",\"offset\":0,\"limit\":20,\"nodeTypes\":[\"heading\",\"paragraph\"]}\n 4. {\"action\":\"text\"}\n 5. {\"action\":\"info\"}", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "blocks", + "extract", + "html", + "info", + "markdown", + "text" + ], + "description": "The action to perform. One of: blocks, extract, html, info, markdown, text." + }, + "unflattenLists": { + "type": "boolean", + "description": "When true, flattens nested list structures in output. Default: false. Only for action 'html'. Omit for other actions." + }, + "offset": { + "type": "number", + "minimum": 0, + "description": "Number of blocks to skip. Default: 0. Only for action 'blocks'. Omit for other actions." + }, + "limit": { + "type": "number", + "minimum": 1, + "description": "Maximum blocks to return. Omit for all blocks. Only for action 'blocks'. Omit for other actions." + }, + "nodeTypes": { + "type": "array", + "items": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt" + ] }, - description: - "Filter by block types (e.g. ['paragraph', 'heading']). Omit for all types. Only for action 'blocks'. Omit for other actions.", - }, - includeText: { - type: 'boolean', - description: - "When true, includes the full flattened block text in each block entry. Only for action 'blocks'. Omit for other actions.", + "description": "Filter by block types (e.g. ['paragraph', 'heading']). Omit for all types. Only for action 'blocks'. Omit for other actions." }, + "includeText": { + "type": "boolean", + "description": "When true, includes the full flattened block text in each block entry. Only for action 'blocks'. Omit for other actions." + } }, - required: ['action'], - additionalProperties: false, + "required": [ + "action" + ], + "additionalProperties": false }, - mutates: false, - operations: [ + "mutates": false, + "operations": [ { - operationId: 'doc.getText', - intentAction: 'text', + "operationId": "doc.getText", + "intentAction": "text" }, { - operationId: 'doc.getMarkdown', - intentAction: 'markdown', + "operationId": "doc.getMarkdown", + "intentAction": "markdown" }, { - operationId: 'doc.getHtml', - intentAction: 'html', + "operationId": "doc.getHtml", + "intentAction": "html" }, { - operationId: 'doc.info', - intentAction: 'info', + "operationId": "doc.info", + "intentAction": "info" }, { - operationId: 'doc.extract', - intentAction: 'extract', + "operationId": "doc.extract", + "intentAction": "extract" }, { - operationId: 'doc.blocks.list', - intentAction: 'blocks', - }, - ], + "operationId": "doc.blocks.list", + "intentAction": "blocks" + } + ] }, { - toolName: 'superdoc_edit', - description: - 'The primary tool for inserting content into documents. ALWAYS use action "insert" with type "markdown" to create headings, paragraphs, or any block content: this is faster and creates proper document structure in one call. Do NOT use superdoc_create for headings or paragraphs. The markdown parser creates headings from # markers (# = Heading1, ## = Heading2), bold from **text**, italic from *text*, and numbered/bullet lists. Position markdown inserts with "target" (a BlockNodeAddress like {kind:"block", nodeType, nodeId}) and "placement" (before, after, insideStart, insideEnd). Without a target, content appends at the end of the document. IMPORTANT: After a markdown insert, analyze the document context (what kind of document, how titles and body text are styled) and follow up with ONE superdoc_mutations call to format inserted blocks so they look like they belong. Each format.apply step accepts "inline" (fontFamily, fontSize, bold, underline, color), "alignment", and "scope" in the same step. Use scope: "block" so formatting covers the entire paragraph. Copy the exact property values from the existing get_content blocks (fontFamily, fontSize, color, alignment, bold, underline). Do NOT invent values: use what the blocks show. Also supports replace, delete, and undo/redo. For replace and delete, pass a "ref" from superdoc_search or superdoc_get_content blocks. A search ref covers only the matched substring; a block ref covers the entire block text, so use block refs when rewriting or shortening whole paragraphs. For multi-step redlines or whole-clause rewrites, prefer superdoc_mutations with where:{by:"block", nodeType, nodeId} from superdoc_get_content action "blocks" includeText:true rather than relying on text selectors. Refs expire after any mutation; always re-search before the next edit. For 2+ edits that must succeed or fail atomically, use superdoc_mutations instead. Supports "dryRun" to preview changes and "changeMode: tracked" to record edits as tracked changes (not supported for markdown/html inserts). Do NOT build "target" objects manually when a ref is available; prefer "ref" for simpler, more reliable targeting.\n\nEXAMPLES:\n 1. {"action":"insert","type":"markdown","target":{"kind":"block","nodeType":"paragraph","nodeId":""},"placement":"before","value":"# Executive Summary\\n\\nThis agreement sets forth the principal terms..."}\n 2. {"action":"insert","type":"markdown","value":"# Section Title\\n\\nParagraph content here.\\n\\n# Another Section\\n\\nMore content with **bold** and *italic*."}\n 3. {"action":"replace","ref":"","text":"new text here"}\n 4. {"action":"delete","ref":""}\n 5. {"action":"undo"}', - inputSchema: { - type: 'object', - properties: { - action: { - type: 'string', - enum: ['delete', 'insert', 'redo', 'replace', 'undo'], - description: 'The action to perform. One of: delete, insert, redo, replace, undo.', - }, - force: { - type: 'boolean', - description: 'Bypass confirmation checks.', - }, - changeMode: { - type: 'string', - enum: ['direct', 'tracked'], - description: 'Edit mode: "direct" applies changes immediately, "tracked" records as suggestions.', - }, - dryRun: { - type: 'boolean', - description: 'Preview the result without applying changes.', - }, - target: { - oneOf: [ + "toolName": "superdoc_edit", + "description": "The primary tool for inserting content into documents. ALWAYS use action \"insert\" with type \"markdown\" to create headings, paragraphs, or any block content: this is faster and creates proper document structure in one call. Do NOT use superdoc_create for headings or paragraphs. The markdown parser creates headings from # markers (# = Heading1, ## = Heading2), bold from **text**, italic from *text*, and numbered/bullet lists. Position markdown inserts with \"target\" (a BlockNodeAddress like {kind:\"block\", nodeType, nodeId}) and \"placement\" (before, after, insideStart, insideEnd). Without a target, content appends at the end of the document. IMPORTANT: After a markdown insert, analyze the document context (what kind of document, how titles and body text are styled) and follow up with ONE superdoc_mutations call to format inserted blocks so they look like they belong. Each format.apply step accepts \"inline\" (fontFamily, fontSize, bold, underline, color), \"alignment\", and \"scope\" in the same step. Use scope: \"block\" so formatting covers the entire paragraph. Copy the exact property values from the existing get_content blocks (fontFamily, fontSize, color, alignment, bold, underline). Do NOT invent values: use what the blocks show. Also supports replace, delete, and undo/redo. For replace and delete, pass a \"ref\" from superdoc_search or superdoc_get_content blocks. A search ref covers only the matched substring; a block ref covers the entire block text, so use block refs when rewriting or shortening whole paragraphs. For multi-step redlines or whole-clause rewrites, prefer superdoc_mutations with where:{by:\"block\", nodeType, nodeId} from superdoc_get_content action \"blocks\" includeText:true rather than relying on text selectors. Refs expire after any mutation; always re-search before the next edit. For 2+ edits that must succeed or fail atomically, use superdoc_mutations instead. Supports \"dryRun\" to preview changes and \"changeMode: tracked\" to record edits as tracked changes (not supported for markdown/html inserts). Do NOT build \"target\" objects manually when a ref is available; prefer \"ref\" for simpler, more reliable targeting.\n\nEXAMPLES:\n 1. {\"action\":\"insert\",\"type\":\"markdown\",\"target\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"},\"placement\":\"before\",\"value\":\"# Executive Summary\\n\\nThis agreement sets forth the principal terms...\"}\n 2. {\"action\":\"insert\",\"type\":\"markdown\",\"value\":\"# Section Title\\n\\nParagraph content here.\\n\\n# Another Section\\n\\nMore content with **bold** and *italic*.\"}\n 3. {\"action\":\"replace\",\"ref\":\"\",\"text\":\"new text here\"}\n 4. {\"action\":\"delete\",\"ref\":\"\"}\n 5. {\"action\":\"undo\"}", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "delete", + "insert", + "redo", + "replace", + "undo" + ], + "description": "The action to perform. One of: delete, insert, redo, replace, undo." + }, + "force": { + "type": "boolean", + "description": "Bypass confirmation checks." + }, + "changeMode": { + "type": "string", + "enum": [ + "direct", + "tracked" + ], + "description": "Edit mode: \"direct\" applies changes immediately, \"tracked\" records as suggestions." + }, + "dryRun": { + "type": "boolean", + "description": "Preview the result without applying changes." + }, + "target": { + "oneOf": [ { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/BlockNodeAddress', - description: - "Block address for structural insertion: {kind:'block', nodeType:'...', nodeId:'...'}.", + "$ref": "#/$defs/BlockNodeAddress", + "description": "Block address for structural insertion: {kind:'block', nodeType:'...', nodeId:'...'}." }, { - oneOf: [ + "oneOf": [ { - type: 'object', - properties: { - kind: { - const: 'selection', - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "selection", + "type": "string" }, - start: { - oneOf: [ + "start": { + "oneOf": [ { - type: 'object', - properties: { - kind: { - const: 'text', - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "text", + "type": "string" }, - blockId: { - type: 'string', - }, - offset: { - type: 'number', + "blockId": { + "type": "string" }, + "offset": { + "type": "number" + } }, - required: ['kind', 'blockId', 'offset'], + "required": [ + "kind", + "blockId", + "offset" + ] }, { - type: 'object', - properties: { - kind: { - const: 'nodeEdge', - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "nodeEdge", + "type": "string" }, - node: { - type: 'object', - properties: { - kind: { - const: 'block', - type: 'string', - }, - nodeType: { - enum: ['paragraph', 'heading', 'table', 'tableOfContents', 'sdt', 'image'], + "node": { + "type": "object", + "properties": { + "kind": { + "const": "block", + "type": "string" }, - nodeId: { - type: 'string', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "table", + "tableOfContents", + "sdt", + "image" + ] }, + "nodeId": { + "type": "string" + } }, - required: ['kind', 'nodeType', 'nodeId'], - }, - edge: { - enum: ['before', 'after'], + "required": [ + "kind", + "nodeType", + "nodeId" + ] }, + "edge": { + "enum": [ + "before", + "after" + ] + } }, - required: ['kind', 'node', 'edge'], - }, + "required": [ + "kind", + "node", + "edge" + ] + } ], - description: - "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries.", + "description": "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries." }, - end: { - oneOf: [ + "end": { + "oneOf": [ { - type: 'object', - properties: { - kind: { - const: 'text', - type: 'string', - }, - blockId: { - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "text", + "type": "string" }, - offset: { - type: 'number', + "blockId": { + "type": "string" }, + "offset": { + "type": "number" + } }, - required: ['kind', 'blockId', 'offset'], + "required": [ + "kind", + "blockId", + "offset" + ] }, { - type: 'object', - properties: { - kind: { - const: 'nodeEdge', - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "nodeEdge", + "type": "string" }, - node: { - type: 'object', - properties: { - kind: { - const: 'block', - type: 'string', + "node": { + "type": "object", + "properties": { + "kind": { + "const": "block", + "type": "string" }, - nodeType: { - enum: ['paragraph', 'heading', 'table', 'tableOfContents', 'sdt', 'image'], - }, - nodeId: { - type: 'string', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "table", + "tableOfContents", + "sdt", + "image" + ] }, + "nodeId": { + "type": "string" + } }, - required: ['kind', 'nodeType', 'nodeId'], - }, - edge: { - enum: ['before', 'after'], + "required": [ + "kind", + "nodeType", + "nodeId" + ] }, + "edge": { + "enum": [ + "before", + "after" + ] + } }, - required: ['kind', 'node', 'edge'], - }, + "required": [ + "kind", + "node", + "edge" + ] + } ], - description: - "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries.", - }, + "description": "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries." + } }, - required: ['kind', 'start', 'end'], + "required": [ + "kind", + "start", + "end" + ] }, { - type: 'object', - properties: { - kind: { - const: 'block', - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "block", + "type": "string" }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - ], - }, - nodeId: { - type: 'string', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt" + ] }, + "nodeId": { + "type": "string" + } }, - required: ['kind', 'nodeType', 'nodeId'], + "required": [ + "kind", + "nodeType", + "nodeId" + ] }, { - type: 'object', - properties: { - kind: { - const: 'selection', - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "selection", + "type": "string" }, - start: { - oneOf: [ + "start": { + "oneOf": [ { - type: 'object', - properties: { - kind: { - const: 'text', - type: 'string', - }, - blockId: { - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "text", + "type": "string" }, - offset: { - type: 'number', + "blockId": { + "type": "string" }, + "offset": { + "type": "number" + } }, - required: ['kind', 'blockId', 'offset'], + "required": [ + "kind", + "blockId", + "offset" + ] }, { - type: 'object', - properties: { - kind: { - const: 'nodeEdge', - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "nodeEdge", + "type": "string" }, - node: { - type: 'object', - properties: { - kind: { - const: 'block', - type: 'string', + "node": { + "type": "object", + "properties": { + "kind": { + "const": "block", + "type": "string" }, - nodeType: { - enum: ['paragraph', 'heading', 'table', 'tableOfContents', 'sdt', 'image'], - }, - nodeId: { - type: 'string', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "table", + "tableOfContents", + "sdt", + "image" + ] }, + "nodeId": { + "type": "string" + } }, - required: ['kind', 'nodeType', 'nodeId'], - }, - edge: { - enum: ['before', 'after'], + "required": [ + "kind", + "nodeType", + "nodeId" + ] }, + "edge": { + "enum": [ + "before", + "after" + ] + } }, - required: ['kind', 'node', 'edge'], - }, + "required": [ + "kind", + "node", + "edge" + ] + } ], - description: - "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries.", + "description": "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries." }, - end: { - oneOf: [ + "end": { + "oneOf": [ { - type: 'object', - properties: { - kind: { - const: 'text', - type: 'string', - }, - blockId: { - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "text", + "type": "string" }, - offset: { - type: 'number', + "blockId": { + "type": "string" }, + "offset": { + "type": "number" + } }, - required: ['kind', 'blockId', 'offset'], + "required": [ + "kind", + "blockId", + "offset" + ] }, { - type: 'object', - properties: { - kind: { - const: 'nodeEdge', - type: 'string', + "type": "object", + "properties": { + "kind": { + "const": "nodeEdge", + "type": "string" }, - node: { - type: 'object', - properties: { - kind: { - const: 'block', - type: 'string', + "node": { + "type": "object", + "properties": { + "kind": { + "const": "block", + "type": "string" }, - nodeType: { - enum: ['paragraph', 'heading', 'table', 'tableOfContents', 'sdt', 'image'], - }, - nodeId: { - type: 'string', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "table", + "tableOfContents", + "sdt", + "image" + ] }, + "nodeId": { + "type": "string" + } }, - required: ['kind', 'nodeType', 'nodeId'], - }, - edge: { - enum: ['before', 'after'], + "required": [ + "kind", + "nodeType", + "nodeId" + ] }, + "edge": { + "enum": [ + "before", + "after" + ] + } }, - required: ['kind', 'node', 'edge'], - }, + "required": [ + "kind", + "node", + "edge" + ] + } ], - description: - "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries.", - }, + "description": "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries." + } }, - required: ['kind', 'start', 'end'], - }, - ], - }, + "required": [ + "kind", + "start", + "end" + ] + } + ] + } ], - description: "Block address for structural insertion: {kind:'block', nodeType:'...', nodeId:'...'}.", + "description": "Block address for structural insertion: {kind:'block', nodeType:'...', nodeId:'...'}." }, { - $ref: '#/$defs/SelectionTarget', - description: - "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle.", - }, + "$ref": "#/$defs/SelectionTarget", + "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle." + } ], - description: "Block address for structural insertion: {kind:'block', nodeType:'...', nodeId:'...'}.", - }, - value: { - type: 'string', - description: "Text content to insert. Only for action 'insert'. Omit for other actions.", - }, - type: { - type: 'string', - description: - "Content format: 'text' (default), 'markdown', or 'html'. Only for action 'insert'. Omit for other actions.", - enum: ['text', 'markdown', 'html'], - }, - ref: { - oneOf: [ + "description": "Block address for structural insertion: {kind:'block', nodeType:'...', nodeId:'...'}." + }, + "value": { + "type": "string", + "description": "Text content to insert. Only for action 'insert'. Omit for other actions." + }, + "type": { + "type": "string", + "description": "Content format: 'text' (default), 'markdown', or 'html'. Only for action 'insert'. Omit for other actions.", + "enum": [ + "text", + "markdown", + "html" + ] + }, + "ref": { + "oneOf": [ { - oneOf: [ + "oneOf": [ { - type: 'string', - description: - 'Handle ref from superdoc_search result (pass handle.ref value directly). Preferred over building a target object.', + "type": "string", + "description": "Handle ref from superdoc_search result (pass handle.ref value directly). Preferred over building a target object." }, { - type: 'string', - description: - "Handle ref string from a superdoc_search result. Pass the handle.ref value directly (e.g. 'text:eyJ...'). Preferred over 'target' for inline formatting.", - }, + "type": "string", + "description": "Handle ref string from a superdoc_search result. Pass the handle.ref value directly (e.g. 'text:eyJ...'). Preferred over 'target' for inline formatting." + } ], - description: - 'Handle ref from superdoc_search result (pass handle.ref value directly). Preferred over building a target object.', + "description": "Handle ref from superdoc_search result (pass handle.ref value directly). Preferred over building a target object." }, { - type: 'string', - description: - "Handle ref string from a superdoc_search result. Pass the handle.ref value directly (e.g. 'text:eyJ...'). Preferred over 'target' for inline formatting.", - }, + "type": "string", + "description": "Handle ref string from a superdoc_search result. Pass the handle.ref value directly (e.g. 'text:eyJ...'). Preferred over 'target' for inline formatting." + } ], - description: - 'Handle ref from superdoc_search result (pass handle.ref value directly). Preferred over building a target object.', + "description": "Handle ref from superdoc_search result (pass handle.ref value directly). Preferred over building a target object." }, - content: { - oneOf: [ + "content": { + "oneOf": [ { - oneOf: [ + "oneOf": [ { - type: 'object', + "type": "object" }, { - type: 'array', - items: { - type: 'object', - }, - }, + "type": "array", + "items": { + "type": "object" + } + } ], - description: 'Document fragment to insert (structured content).', + "description": "Document fragment to insert (structured content)." }, { - oneOf: [ + "oneOf": [ { - type: 'object', - properties: {}, + "type": "object", + "properties": {} }, { - type: 'array', - items: { - type: 'object', - properties: {}, - }, - }, + "type": "array", + "items": { + "type": "object", + "properties": {} + } + } ], - description: 'Document fragment to replace with (structured content).', - }, + "description": "Document fragment to replace with (structured content)." + } ], - description: - "Document fragment to insert (structured content). Only for actions 'insert', 'replace'. Omit for other actions.", - }, - placement: { - enum: ['before', 'after', 'insideStart', 'insideEnd'], - description: - "Where to place content relative to target: 'before', 'after', 'insideStart', or 'insideEnd'. Only for action 'insert'. Omit for other actions.", + "description": "Document fragment to insert (structured content). Only for actions 'insert', 'replace'. Omit for other actions." + }, + "placement": { + "enum": [ + "before", + "after", + "insideStart", + "insideEnd" + ], + "description": "Where to place content relative to target: 'before', 'after', 'insideStart', or 'insideEnd'. Only for action 'insert'. Omit for other actions." }, - nestingPolicy: { - oneOf: [ + "nestingPolicy": { + "oneOf": [ { - type: 'object', - properties: { - tables: { - enum: ['forbid', 'allow'], - }, + "type": "object", + "properties": { + "tables": { + "enum": [ + "forbid", + "allow" + ] + } }, - additionalProperties: false, - description: "Controls nesting behavior. tables: 'allow' permits inserting tables inside other tables.", + "additionalProperties": false, + "description": "Controls nesting behavior. tables: 'allow' permits inserting tables inside other tables." }, { - type: 'object', - properties: { - tables: { - enum: ['forbid', 'allow'], - }, + "type": "object", + "properties": { + "tables": { + "enum": [ + "forbid", + "allow" + ] + } }, - description: "Controls nesting behavior. tables: 'allow' permits inserting tables inside other tables.", - }, + "description": "Controls nesting behavior. tables: 'allow' permits inserting tables inside other tables." + } ], - description: - "Controls nesting behavior. tables: 'allow' permits inserting tables inside other tables. Only for actions 'insert', 'replace'. Omit for other actions.", - }, - text: { - type: 'string', - description: "Replacement text content. Only for action 'replace'. Omit for other actions.", + "description": "Controls nesting behavior. tables: 'allow' permits inserting tables inside other tables. Only for actions 'insert', 'replace'. Omit for other actions." }, - behavior: { - $ref: '#/$defs/DeleteBehavior', - description: - "Delete behavior: 'selection' (default) or 'exact'. Only for action 'delete'. Omit for other actions.", + "text": { + "type": "string", + "description": "Replacement text content. Only for action 'replace'. Omit for other actions." }, + "behavior": { + "$ref": "#/$defs/DeleteBehavior", + "description": "Delete behavior: 'selection' (default) or 'exact'. Only for action 'delete'. Omit for other actions." + } }, - required: ['action'], - additionalProperties: false, + "required": [ + "action" + ], + "additionalProperties": false }, - mutates: true, - operations: [ + "mutates": true, + "operations": [ { - operationId: 'doc.insert', - intentAction: 'insert', - requiredOneOf: [['target', 'value'], ['ref', 'value'], ['value'], ['content']], + "operationId": "doc.insert", + "intentAction": "insert", + "requiredOneOf": [ + [ + "target", + "value" + ], + [ + "ref", + "value" + ], + [ + "value" + ], + [ + "content" + ] + ] }, { - operationId: 'doc.replace', - intentAction: 'replace', - requiredOneOf: [ - ['target', 'text'], - ['ref', 'text'], - ['target', 'content'], - ['ref', 'content'], - ], + "operationId": "doc.replace", + "intentAction": "replace", + "requiredOneOf": [ + [ + "target", + "text" + ], + [ + "ref", + "text" + ], + [ + "target", + "content" + ], + [ + "ref", + "content" + ] + ] }, { - operationId: 'doc.delete', - intentAction: 'delete', - requiredOneOf: [['target'], ['ref']], + "operationId": "doc.delete", + "intentAction": "delete", + "requiredOneOf": [ + [ + "target" + ], + [ + "ref" + ] + ] }, { - operationId: 'doc.history.undo', - intentAction: 'undo', + "operationId": "doc.history.undo", + "intentAction": "undo" }, { - operationId: 'doc.history.redo', - intentAction: 'redo', - }, - ], + "operationId": "doc.history.redo", + "intentAction": "redo" + } + ] }, { - toolName: 'superdoc_format', - description: - 'Change text and paragraph formatting. To format multiple items at once, use superdoc_mutations with format.apply steps instead of calling this tool repeatedly. Use require "all" with a node selector to format every heading or paragraph in one batch. Use this tool for single-item formatting when you have a valid ref or nodeId. Action "inline" applies character formatting (bold, italic, underline, color, fontSize, fontFamily, highlight, strike, vertAlign) to a text range via "ref". Action "set_style" applies a named paragraph style by styleId (get available styles from superdoc_get_content info). Actions "set_alignment", "set_indentation", "set_spacing", "set_direction", and "set_flow_options" change paragraph-level properties and require a block target: {kind:"block", nodeType:"paragraph", nodeId:""}, NOT a ref. Use "set_flow_options" with pageBreakBefore:true to start a paragraph on a new page. Supports "dryRun" and "changeMode: tracked" for inline formatting. Paragraph-level actions do NOT support tracked changes. Do NOT use a search ref for paragraph-level actions; they require a block target with nodeId. Do NOT use {kind:"block", start:{kind:"nodeEdge",...}} or selection-like structures for paragraph actions. ONLY {kind:"block", nodeType, nodeId} is accepted. Do NOT issue multiple superdoc_format calls in parallel; each call invalidates refs for subsequent calls.\n\nEXAMPLES:\n 1. {"action":"inline","ref":"","inline":{"bold":true}}\n 2. {"action":"inline","ref":"","inline":{"fontFamily":"Calibri","fontSize":11,"color":"#000000","bold":false}}\n 3. {"action":"set_alignment","target":{"kind":"block","nodeType":"paragraph","nodeId":""},"alignment":"center"}\n 4. {"action":"set_flow_options","target":{"kind":"block","nodeType":"paragraph","nodeId":""},"pageBreakBefore":true}\n 5. {"action":"set_spacing","target":{"kind":"block","nodeType":"paragraph","nodeId":""},"lineSpacing":{"rule":"auto","value":1.5}}', - inputSchema: { - type: 'object', - properties: { - action: { - type: 'string', - enum: [ - 'inline', - 'set_alignment', - 'set_direction', - 'set_flow_options', - 'set_indentation', - 'set_spacing', - 'set_style', + "toolName": "superdoc_format", + "description": "Change text and paragraph formatting. To format multiple items at once, use superdoc_mutations with format.apply steps instead of calling this tool repeatedly. Use require \"all\" with a node selector to format every heading or paragraph in one batch. Use this tool for single-item formatting when you have a valid ref or nodeId. Action \"inline\" applies character formatting (bold, italic, underline, color, fontSize, fontFamily, highlight, strike, vertAlign) to a text range via \"ref\". Action \"set_style\" applies a named paragraph style by styleId (get available styles from superdoc_get_content info). Actions \"set_alignment\", \"set_indentation\", \"set_spacing\", \"set_direction\", and \"set_flow_options\" change paragraph-level properties and require a block target: {kind:\"block\", nodeType:\"paragraph\", nodeId:\"\"}, NOT a ref. Use \"set_flow_options\" with pageBreakBefore:true to start a paragraph on a new page. Supports \"dryRun\" and \"changeMode: tracked\" for inline formatting. Paragraph-level actions do NOT support tracked changes. Do NOT use a search ref for paragraph-level actions; they require a block target with nodeId. Do NOT use {kind:\"block\", start:{kind:\"nodeEdge\",...}} or selection-like structures for paragraph actions. ONLY {kind:\"block\", nodeType, nodeId} is accepted. Do NOT issue multiple superdoc_format calls in parallel; each call invalidates refs for subsequent calls.\n\nEXAMPLES:\n 1. {\"action\":\"inline\",\"ref\":\"\",\"inline\":{\"bold\":true}}\n 2. {\"action\":\"inline\",\"ref\":\"\",\"inline\":{\"fontFamily\":\"Calibri\",\"fontSize\":11,\"color\":\"#000000\",\"bold\":false}}\n 3. {\"action\":\"set_alignment\",\"target\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"},\"alignment\":\"center\"}\n 4. {\"action\":\"set_flow_options\",\"target\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"},\"pageBreakBefore\":true}\n 5. {\"action\":\"set_spacing\",\"target\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"},\"lineSpacing\":{\"rule\":\"auto\",\"value\":1.5}}", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "inline", + "set_alignment", + "set_direction", + "set_flow_options", + "set_indentation", + "set_spacing", + "set_style" ], - description: - 'The action to perform. One of: inline, set_alignment, set_direction, set_flow_options, set_indentation, set_spacing, set_style.', + "description": "The action to perform. One of: inline, set_alignment, set_direction, set_flow_options, set_indentation, set_spacing, set_style." }, - force: { - type: 'boolean', - description: 'Bypass confirmation checks.', + "force": { + "type": "boolean", + "description": "Bypass confirmation checks." }, - changeMode: { - type: 'string', - enum: ['direct', 'tracked'], - description: 'Edit mode: "direct" applies changes immediately, "tracked" records as suggestions.', + "changeMode": { + "type": "string", + "enum": [ + "direct", + "tracked" + ], + "description": "Edit mode: \"direct\" applies changes immediately, \"tracked\" records as suggestions." }, - dryRun: { - type: 'boolean', - description: 'Preview the result without applying changes.', + "dryRun": { + "type": "boolean", + "description": "Preview the result without applying changes." }, - target: { - oneOf: [ + "target": { + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/SelectionTarget', - description: - "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle.", + "$ref": "#/$defs/SelectionTarget", + "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle." }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/ParagraphAddress', + "$ref": "#/$defs/ParagraphAddress" }, { - $ref: '#/$defs/HeadingAddress', + "$ref": "#/$defs/HeadingAddress" }, { - $ref: '#/$defs/ListItemAddress', - }, - ], - }, + "$ref": "#/$defs/ListItemAddress" + } + ] + } ], - description: - "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle.", + "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle." }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/ParagraphAddress', + "$ref": "#/$defs/ParagraphAddress" }, { - $ref: '#/$defs/HeadingAddress', + "$ref": "#/$defs/HeadingAddress" }, { - $ref: '#/$defs/ListItemAddress', - }, - ], - }, + "$ref": "#/$defs/ListItemAddress" + } + ] + } ], - description: - "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle.", + "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle." }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/ParagraphAddress', + "$ref": "#/$defs/ParagraphAddress" }, { - $ref: '#/$defs/HeadingAddress', + "$ref": "#/$defs/HeadingAddress" }, { - $ref: '#/$defs/ListItemAddress', - }, - ], - }, + "$ref": "#/$defs/ListItemAddress" + } + ] + } ], - description: - "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle.", + "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle." }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/ParagraphAddress', + "$ref": "#/$defs/ParagraphAddress" }, { - $ref: '#/$defs/HeadingAddress', + "$ref": "#/$defs/HeadingAddress" }, { - $ref: '#/$defs/ListItemAddress', - }, - ], - }, + "$ref": "#/$defs/ListItemAddress" + } + ] + } ], - description: - "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle.", + "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle." }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/ParagraphAddress', + "$ref": "#/$defs/ParagraphAddress" }, { - $ref: '#/$defs/HeadingAddress', + "$ref": "#/$defs/HeadingAddress" }, { - $ref: '#/$defs/ListItemAddress', - }, - ], - }, + "$ref": "#/$defs/ListItemAddress" + } + ] + } ], - description: - "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle.", + "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle." }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/ParagraphAddress', + "$ref": "#/$defs/ParagraphAddress" }, { - $ref: '#/$defs/HeadingAddress', + "$ref": "#/$defs/HeadingAddress" }, { - $ref: '#/$defs/ListItemAddress', - }, - ], - }, + "$ref": "#/$defs/ListItemAddress" + } + ] + } ], - description: - "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle. Required for actions 'set_style', 'set_alignment', 'set_indentation', 'set_spacing', 'set_flow_options', 'set_direction'.", + "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle. Required for actions 'set_style', 'set_alignment', 'set_indentation', 'set_spacing', 'set_flow_options', 'set_direction'." }, - inline: { - type: 'object', - properties: { - bold: { - oneOf: [ + "inline": { + "type": "object", + "properties": { + "bold": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - italic: { - oneOf: [ + "italic": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - strike: { - oneOf: [ + "strike": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - underline: { - oneOf: [ + "underline": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', + "type": "null" }, { - type: 'object', - properties: { - style: { - oneOf: [ + "type": "object", + "properties": { + "style": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - color: { - oneOf: [ + "color": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - themeColor: { - oneOf: [ + "themeColor": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, - }, - ], + "additionalProperties": false, + "minProperties": 1 + } + ] }, - highlight: { - oneOf: [ + "highlight": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - color: { - oneOf: [ + "color": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - fontSize: { - oneOf: [ + "fontSize": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - fontFamily: { - oneOf: [ + "fontFamily": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - letterSpacing: { - oneOf: [ + "letterSpacing": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - vertAlign: { - oneOf: [ + "vertAlign": { + "oneOf": [ { - enum: ['superscript', 'subscript', 'baseline'], + "enum": [ + "superscript", + "subscript", + "baseline" + ] }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - position: { - oneOf: [ + "position": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - dstrike: { - oneOf: [ + "dstrike": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - smallCaps: { - oneOf: [ + "smallCaps": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - caps: { - oneOf: [ + "caps": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - shading: { - oneOf: [ + "shading": { + "oneOf": [ { - type: 'object', - properties: { - fill: { - oneOf: [ + "type": "object", + "properties": { + "fill": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - color: { - oneOf: [ + "color": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - val: { - oneOf: [ + "val": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - border: { - oneOf: [ + "border": { + "oneOf": [ { - type: 'object', - properties: { - val: { - oneOf: [ + "type": "object", + "properties": { + "val": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - sz: { - oneOf: [ + "sz": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - color: { - oneOf: [ + "color": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - space: { - oneOf: [ + "space": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - outline: { - oneOf: [ + "outline": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - shadow: { - oneOf: [ + "shadow": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - emboss: { - oneOf: [ + "emboss": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - imprint: { - oneOf: [ + "imprint": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - charScale: { - oneOf: [ + "charScale": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - kerning: { - oneOf: [ + "kerning": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - vanish: { - oneOf: [ + "vanish": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - webHidden: { - oneOf: [ + "webHidden": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - specVanish: { - oneOf: [ + "specVanish": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - rtl: { - oneOf: [ + "rtl": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - cs: { - oneOf: [ + "cs": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - bCs: { - oneOf: [ + "bCs": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - iCs: { - oneOf: [ + "iCs": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - eastAsianLayout: { - oneOf: [ + "eastAsianLayout": { + "oneOf": [ { - type: 'object', - properties: { - id: { - oneOf: [ + "type": "object", + "properties": { + "id": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - combine: { - oneOf: [ + "combine": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - combineBrackets: { - oneOf: [ + "combineBrackets": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - vert: { - oneOf: [ + "vert": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - vertCompress: { - oneOf: [ + "vertCompress": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - em: { - oneOf: [ + "em": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - fitText: { - oneOf: [ + "fitText": { + "oneOf": [ { - type: 'object', - properties: { - val: { - oneOf: [ + "type": "object", + "properties": { + "val": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - id: { - oneOf: [ + "id": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - snapToGrid: { - oneOf: [ + "snapToGrid": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - lang: { - oneOf: [ + "lang": { + "oneOf": [ { - type: 'object', - properties: { - val: { - oneOf: [ + "type": "object", + "properties": { + "val": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - eastAsia: { - oneOf: [ + "eastAsia": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - bidi: { - oneOf: [ + "bidi": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - oMath: { - oneOf: [ + "oMath": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - rStyle: { - oneOf: [ + "rStyle": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - rFonts: { - oneOf: [ + "rFonts": { + "oneOf": [ { - type: 'object', - properties: { - ascii: { - oneOf: [ + "type": "object", + "properties": { + "ascii": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - hAnsi: { - oneOf: [ + "hAnsi": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - eastAsia: { - oneOf: [ + "eastAsia": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - cs: { - oneOf: [ + "cs": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - asciiTheme: { - oneOf: [ + "asciiTheme": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - hAnsiTheme: { - oneOf: [ + "hAnsiTheme": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - eastAsiaTheme: { - oneOf: [ + "eastAsiaTheme": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - csTheme: { - oneOf: [ + "csTheme": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - hint: { - oneOf: [ + "hint": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - fontSizeCs: { - oneOf: [ + "fontSizeCs": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - ligatures: { - oneOf: [ + "ligatures": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - numForm: { - oneOf: [ + "numForm": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - numSpacing: { - oneOf: [ + "numSpacing": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - stylisticSets: { - oneOf: [ - { - type: 'array', - items: { - type: 'object', - properties: { - id: { - type: 'number', - }, - val: { - type: 'boolean', + "stylisticSets": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number" }, + "val": { + "type": "boolean" + } }, - required: ['id'], - additionalProperties: false, + "required": [ + "id" + ], + "additionalProperties": false }, - minItems: 1, + "minItems": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - contextualAlternates: { - oneOf: [ + "contextualAlternates": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, - description: - "Inline formatting properties to apply. Set a property to apply it, use null to clear it. Example: {bold: true, italic: true} or {bold: null} to remove bold. Only for action 'inline'. Omit for other actions.", - }, - ref: { - type: 'string', - description: - "Handle ref string from a superdoc_search result. Pass the handle.ref value directly (e.g. 'text:eyJ...'). Preferred over 'target' for inline formatting. Only for action 'inline'. Omit for other actions.", - }, - styleId: { - type: 'string', - minLength: 1, - description: - "Named paragraph style ID (e.g. 'Normal', 'Heading1', 'BodyText'). Use superdoc_search to find a nearby paragraph, then inspect its style to determine the correct styleId. Required for action 'set_style'.", - }, - alignment: { - enum: ['left', 'center', 'right', 'justify'], - description: - "Visual paragraph alignment. In RTL paragraphs, 'left' stores w:jc='right' and 'right' stores w:jc='left' so Word displays the requested side. Required for action 'set_alignment'.", - }, - left: { - type: 'integer', - minimum: 0, - description: - "Left indentation in twips (1440 = 1 inch). Only for action 'set_indentation'. Omit for other actions.", - }, - right: { - type: 'integer', - minimum: 0, - description: - "Right indentation in twips (1440 = 1 inch). Only for action 'set_indentation'. Omit for other actions.", - }, - firstLine: { - type: 'integer', - minimum: 0, - description: - "First line indent in twips. Cannot be combined with hanging. Only for action 'set_indentation'. Omit for other actions.", - }, - hanging: { - type: 'integer', - minimum: 0, - description: - "Hanging indent in twips. Cannot be combined with firstLine. Only for action 'set_indentation'. Omit for other actions.", - }, - before: { - type: 'integer', - minimum: 0, - description: - "Space before paragraph in twips (20 twips = 1pt). Only for action 'set_spacing'. Omit for other actions.", - }, - after: { - type: 'integer', - minimum: 0, - description: - "Space after paragraph in twips (20 twips = 1pt). Only for action 'set_spacing'. Omit for other actions.", - }, - line: { - type: 'integer', - minimum: 1, - description: - "Line spacing value. Meaning depends on lineRule. Must be provided together with lineRule. Only for action 'set_spacing'. Omit for other actions.", - }, - lineRule: { - enum: ['auto', 'exact', 'atLeast'], - description: - "Line spacing rule. Required when 'line' is set. Only for action 'set_spacing'. Omit for other actions.", - }, - contextualSpacing: { - type: 'boolean', - description: "Only for action 'set_flow_options'. Omit for other actions.", - }, - pageBreakBefore: { - type: 'boolean', - description: "Only for action 'set_flow_options'. Omit for other actions.", - }, - suppressAutoHyphens: { - type: 'boolean', - description: "Only for action 'set_flow_options'. Omit for other actions.", - }, - direction: { - type: 'string', - enum: ['ltr', 'rtl'], - description: "Required for action 'set_direction'.", - }, - alignmentPolicy: { - type: 'string', - enum: ['preserve', 'matchDirection'], - description: "Only for action 'set_direction'. Omit for other actions.", + "additionalProperties": false, + "minProperties": 1, + "description": "Inline formatting properties to apply. Set a property to apply it, use null to clear it. Example: {bold: true, italic: true} or {bold: null} to remove bold. Only for action 'inline'. Omit for other actions." + }, + "ref": { + "type": "string", + "description": "Handle ref string from a superdoc_search result. Pass the handle.ref value directly (e.g. 'text:eyJ...'). Preferred over 'target' for inline formatting. Only for action 'inline'. Omit for other actions." + }, + "styleId": { + "type": "string", + "minLength": 1, + "description": "Named paragraph style ID (e.g. 'Normal', 'Heading1', 'BodyText'). Use superdoc_search to find a nearby paragraph, then inspect its style to determine the correct styleId. Required for action 'set_style'." + }, + "alignment": { + "enum": [ + "left", + "center", + "right", + "justify" + ], + "description": "Visual paragraph alignment. In RTL paragraphs, 'left' stores w:jc='right' and 'right' stores w:jc='left' so Word displays the requested side. Required for action 'set_alignment'." + }, + "left": { + "type": "integer", + "minimum": 0, + "description": "Left indentation in twips (1440 = 1 inch). Only for action 'set_indentation'. Omit for other actions." + }, + "right": { + "type": "integer", + "minimum": 0, + "description": "Right indentation in twips (1440 = 1 inch). Only for action 'set_indentation'. Omit for other actions." + }, + "firstLine": { + "type": "integer", + "minimum": 0, + "description": "First line indent in twips. Cannot be combined with hanging. Only for action 'set_indentation'. Omit for other actions." + }, + "hanging": { + "type": "integer", + "minimum": 0, + "description": "Hanging indent in twips. Cannot be combined with firstLine. Only for action 'set_indentation'. Omit for other actions." + }, + "before": { + "type": "integer", + "minimum": 0, + "description": "Space before paragraph in twips (20 twips = 1pt). Only for action 'set_spacing'. Omit for other actions." + }, + "after": { + "type": "integer", + "minimum": 0, + "description": "Space after paragraph in twips (20 twips = 1pt). Only for action 'set_spacing'. Omit for other actions." + }, + "line": { + "type": "integer", + "minimum": 1, + "description": "Line spacing value. Meaning depends on lineRule. Must be provided together with lineRule. Only for action 'set_spacing'. Omit for other actions." + }, + "lineRule": { + "enum": [ + "auto", + "exact", + "atLeast" + ], + "description": "Line spacing rule. Required when 'line' is set. Only for action 'set_spacing'. Omit for other actions." + }, + "contextualSpacing": { + "type": "boolean", + "description": "Only for action 'set_flow_options'. Omit for other actions." + }, + "pageBreakBefore": { + "type": "boolean", + "description": "Only for action 'set_flow_options'. Omit for other actions." + }, + "suppressAutoHyphens": { + "type": "boolean", + "description": "Only for action 'set_flow_options'. Omit for other actions." + }, + "direction": { + "type": "string", + "enum": [ + "ltr", + "rtl" + ], + "description": "Required for action 'set_direction'." }, + "alignmentPolicy": { + "type": "string", + "enum": [ + "preserve", + "matchDirection" + ], + "description": "Only for action 'set_direction'. Omit for other actions." + } }, - required: ['action'], - additionalProperties: false, + "required": [ + "action" + ], + "additionalProperties": false }, - mutates: true, - operations: [ + "mutates": true, + "operations": [ { - operationId: 'doc.format.apply', - intentAction: 'inline', - requiredOneOf: [ - ['target', 'inline'], - ['ref', 'inline'], - ], + "operationId": "doc.format.apply", + "intentAction": "inline", + "requiredOneOf": [ + [ + "target", + "inline" + ], + [ + "ref", + "inline" + ] + ] }, { - operationId: 'doc.styles.paragraph.setStyle', - intentAction: 'set_style', - required: ['target', 'styleId'], + "operationId": "doc.styles.paragraph.setStyle", + "intentAction": "set_style", + "required": [ + "target", + "styleId" + ] }, { - operationId: 'doc.format.paragraph.setAlignment', - intentAction: 'set_alignment', - required: ['target', 'alignment'], + "operationId": "doc.format.paragraph.setAlignment", + "intentAction": "set_alignment", + "required": [ + "target", + "alignment" + ] }, { - operationId: 'doc.format.paragraph.setIndentation', - intentAction: 'set_indentation', - required: ['target'], + "operationId": "doc.format.paragraph.setIndentation", + "intentAction": "set_indentation", + "required": [ + "target" + ] }, { - operationId: 'doc.format.paragraph.setSpacing', - intentAction: 'set_spacing', - required: ['target'], + "operationId": "doc.format.paragraph.setSpacing", + "intentAction": "set_spacing", + "required": [ + "target" + ] }, { - operationId: 'doc.format.paragraph.setFlowOptions', - intentAction: 'set_flow_options', - requiredOneOf: [ - ['target', 'contextualSpacing'], - ['target', 'pageBreakBefore'], - ['target', 'suppressAutoHyphens'], - ], + "operationId": "doc.format.paragraph.setFlowOptions", + "intentAction": "set_flow_options", + "requiredOneOf": [ + [ + "target", + "contextualSpacing" + ], + [ + "target", + "pageBreakBefore" + ], + [ + "target", + "suppressAutoHyphens" + ] + ] }, { - operationId: 'doc.format.paragraph.setDirection', - intentAction: 'set_direction', - required: ['target', 'direction'], - }, - ], + "operationId": "doc.format.paragraph.setDirection", + "intentAction": "set_direction", + "required": [ + "target", + "direction" + ] + } + ] }, { - toolName: 'superdoc_create', - description: - 'IMPORTANT: For headings and paragraphs, use superdoc_edit with type "markdown" instead: it is faster, creates proper styles, and handles positioning via target + placement. Only use superdoc_create for tables or when markdown cannot express the content. Creates a single paragraph, heading, or table. Returns nodeId and ref for the created block. After creating, the returned ref is valid for ONE immediate superdoc_format call. For subsequent operations, re-fetch blocks with superdoc_get_content to get fresh refs (refs expire after any mutation). When the user asks for a "heading", use action "heading" with a level (default 1). Use action "paragraph" for regular body text. Position with "at": {kind:"documentEnd"} (default), {kind:"documentStart"}, or {kind:"after"/"before", target:{kind:"block", nodeType, nodeId}} for relative placement. When creating multiple items in sequence, use the previous response nodeId as the next "at" target to maintain correct ordering. Do NOT use newlines in "text" to create multiple paragraphs; call this tool separately for each one.\n\nEXAMPLES:\n 1. {"action":"paragraph","text":"New paragraph content.","at":{"kind":"documentEnd"}}\n 2. {"action":"heading","text":"Section Title","level":2,"at":{"kind":"after","target":{"kind":"block","nodeType":"paragraph","nodeId":""}}}\n 3. {"action":"paragraph","text":"Chained item.","at":{"kind":"after","target":{"kind":"block","nodeType":"paragraph","nodeId":""}}}\n 4. {"action":"table","rows":3,"columns":4,"at":{"kind":"documentEnd"}}', - inputSchema: { - type: 'object', - properties: { - action: { - type: 'string', - enum: ['heading', 'paragraph', 'table'], - description: 'The action to perform. One of: heading, paragraph, table.', - }, - force: { - type: 'boolean', - description: 'Bypass confirmation checks.', - }, - changeMode: { - type: 'string', - enum: ['direct', 'tracked'], - description: 'Edit mode: "direct" applies changes immediately, "tracked" records as suggestions.', - }, - dryRun: { - type: 'boolean', - description: 'Preview the result without applying changes.', - }, - at: { - oneOf: [ + "toolName": "superdoc_create", + "description": "IMPORTANT: For headings and paragraphs, use superdoc_edit with type \"markdown\" instead: it is faster, creates proper styles, and handles positioning via target + placement. Only use superdoc_create for tables or when markdown cannot express the content. Creates a single paragraph, heading, or table. Returns nodeId and ref for the created block. After creating, the returned ref is valid for ONE immediate superdoc_format call. For subsequent operations, re-fetch blocks with superdoc_get_content to get fresh refs (refs expire after any mutation). When the user asks for a \"heading\", use action \"heading\" with a level (default 1). Use action \"paragraph\" for regular body text. Position with \"at\": {kind:\"documentEnd\"} (default), {kind:\"documentStart\"}, or {kind:\"after\"/\"before\", target:{kind:\"block\", nodeType, nodeId}} for relative placement. When creating multiple items in sequence, use the previous response nodeId as the next \"at\" target to maintain correct ordering. Do NOT use newlines in \"text\" to create multiple paragraphs; call this tool separately for each one.\n\nEXAMPLES:\n 1. {\"action\":\"paragraph\",\"text\":\"New paragraph content.\",\"at\":{\"kind\":\"documentEnd\"}}\n 2. {\"action\":\"heading\",\"text\":\"Section Title\",\"level\":2,\"at\":{\"kind\":\"after\",\"target\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"}}}\n 3. {\"action\":\"paragraph\",\"text\":\"Chained item.\",\"at\":{\"kind\":\"after\",\"target\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"}}}\n 4. {\"action\":\"table\",\"rows\":3,\"columns\":4,\"at\":{\"kind\":\"documentEnd\"}}", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "heading", + "paragraph", + "table" + ], + "description": "The action to perform. One of: heading, paragraph, table." + }, + "force": { + "type": "boolean", + "description": "Bypass confirmation checks." + }, + "changeMode": { + "type": "string", + "enum": [ + "direct", + "tracked" + ], + "description": "Edit mode: \"direct\" applies changes immediately, \"tracked\" records as suggestions." + }, + "dryRun": { + "type": "boolean", + "description": "Preview the result without applying changes." + }, + "at": { + "oneOf": [ { - description: - "Position: {kind:'documentEnd'} to append, {kind:'documentStart'} to prepend, or {kind:'before'|'after', target:{kind:'block', nodeType:'...', nodeId:'...'}} for relative placement.", - oneOf: [ - { - type: 'object', - properties: { - kind: { - const: 'documentStart', - type: 'string', - }, + "description": "Position: {kind:'documentEnd'} to append, {kind:'documentStart'} to prepend, or {kind:'before'|'after', target:{kind:'block', nodeType:'...', nodeId:'...'}} for relative placement.", + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "const": "documentStart", + "type": "string" + } }, - additionalProperties: false, - required: ['kind'], - }, - { - type: 'object', - properties: { - kind: { - const: 'documentEnd', - type: 'string', - }, + "additionalProperties": false, + "required": [ + "kind" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "documentEnd", + "type": "string" + } }, - additionalProperties: false, - required: ['kind'], + "additionalProperties": false, + "required": [ + "kind" + ] }, { - type: 'object', - properties: { - kind: { - const: 'before', - type: 'string', - }, - target: { - $ref: '#/$defs/BlockNodeAddress', + "type": "object", + "properties": { + "kind": { + "const": "before", + "type": "string" }, + "target": { + "$ref": "#/$defs/BlockNodeAddress" + } }, - additionalProperties: false, - required: ['kind', 'target'], - }, - { - type: 'object', - properties: { - kind: { - const: 'after', - type: 'string', - }, - target: { - $ref: '#/$defs/BlockNodeAddress', + "additionalProperties": false, + "required": [ + "kind", + "target" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "after", + "type": "string" }, + "target": { + "$ref": "#/$defs/BlockNodeAddress" + } }, - additionalProperties: false, - required: ['kind', 'target'], - }, - ], + "additionalProperties": false, + "required": [ + "kind", + "target" + ] + } + ] }, { - oneOf: [ - { - type: 'object', - properties: { - kind: { - const: 'documentStart', - type: 'string', - }, + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "const": "documentStart", + "type": "string" + } }, - additionalProperties: false, - required: ['kind'], - }, - { - type: 'object', - properties: { - kind: { - const: 'documentEnd', - type: 'string', - }, + "additionalProperties": false, + "required": [ + "kind" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "documentEnd", + "type": "string" + } }, - additionalProperties: false, - required: ['kind'], + "additionalProperties": false, + "required": [ + "kind" + ] }, { - type: 'object', - properties: { - kind: { - const: 'before', - type: 'string', - }, - target: { - $ref: '#/$defs/BlockNodeAddress', + "type": "object", + "properties": { + "kind": { + "const": "before", + "type": "string" }, + "target": { + "$ref": "#/$defs/BlockNodeAddress" + } }, - additionalProperties: false, - required: ['kind', 'target'], - }, - { - type: 'object', - properties: { - kind: { - const: 'after', - type: 'string', - }, - target: { - $ref: '#/$defs/BlockNodeAddress', + "additionalProperties": false, + "required": [ + "kind", + "target" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "after", + "type": "string" }, + "target": { + "$ref": "#/$defs/BlockNodeAddress" + } }, - additionalProperties: false, - required: ['kind', 'target'], - }, - { - type: 'object', - properties: { - kind: { - const: 'before', - type: 'string', - }, - nodeId: { - type: 'string', + "additionalProperties": false, + "required": [ + "kind", + "target" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "before", + "type": "string" }, + "nodeId": { + "type": "string" + } }, - additionalProperties: false, - required: ['kind', 'nodeId'], - }, - { - type: 'object', - properties: { - kind: { - const: 'after', - type: 'string', - }, - nodeId: { - type: 'string', + "additionalProperties": false, + "required": [ + "kind", + "nodeId" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "after", + "type": "string" }, + "nodeId": { + "type": "string" + } }, - additionalProperties: false, - required: ['kind', 'nodeId'], - }, - ], - }, + "additionalProperties": false, + "required": [ + "kind", + "nodeId" + ] + } + ] + } ], - description: - "Position: {kind:'documentEnd'} to append, {kind:'documentStart'} to prepend, or {kind:'before'|'after', target:{kind:'block', nodeType:'...', nodeId:'...'}} for relative placement.", + "description": "Position: {kind:'documentEnd'} to append, {kind:'documentStart'} to prepend, or {kind:'before'|'after', target:{kind:'block', nodeType:'...', nodeId:'...'}} for relative placement." }, - text: { - oneOf: [ + "text": { + "oneOf": [ { - type: 'string', - description: - 'Paragraph text content. Each call creates ONE paragraph. For multiple items (e.g. list items), call superdoc_create separately for each item: do NOT use newlines to put multiple items in one paragraph.', + "type": "string", + "description": "Paragraph text content. Each call creates ONE paragraph. For multiple items (e.g. list items), call superdoc_create separately for each item: do NOT use newlines to put multiple items in one paragraph." }, { - type: 'string', - description: 'Heading text content.', - }, + "type": "string", + "description": "Heading text content." + } ], - description: - 'Paragraph text content. Each call creates ONE paragraph. For multiple items (e.g. list items), call superdoc_create separately for each item: do NOT use newlines to put multiple items in one paragraph.', + "description": "Paragraph text content. Each call creates ONE paragraph. For multiple items (e.g. list items), call superdoc_create separately for each item: do NOT use newlines to put multiple items in one paragraph." }, - input: { - oneOf: [ + "input": { + "oneOf": [ { - type: 'object', - description: 'Full paragraph input as JSON (alternative to individual text/at params).', + "type": "object", + "description": "Full paragraph input as JSON (alternative to individual text/at params)." }, { - type: 'object', - description: 'Full heading input as JSON (alternative to individual text/level/at params).', - }, + "type": "object", + "description": "Full heading input as JSON (alternative to individual text/level/at params)." + } ], - description: 'Full paragraph input as JSON (alternative to individual text/at params).', - }, - level: { - type: 'integer', - minimum: 1, - maximum: 6, - description: "Heading level (1-6). Required for action 'heading'.", - }, - rows: { - type: 'integer', - minimum: 1, - description: "Required for action 'table'.", - }, - columns: { - type: 'integer', - minimum: 1, - description: "Required for action 'table'.", - }, + "description": "Full paragraph input as JSON (alternative to individual text/at params)." + }, + "level": { + "type": "integer", + "minimum": 1, + "maximum": 6, + "description": "Heading level (1-6). Required for action 'heading'." + }, + "rows": { + "type": "integer", + "minimum": 1, + "description": "Required for action 'table'." + }, + "columns": { + "type": "integer", + "minimum": 1, + "description": "Required for action 'table'." + } }, - required: ['action'], - additionalProperties: false, + "required": [ + "action" + ], + "additionalProperties": false }, - mutates: true, - operations: [ + "mutates": true, + "operations": [ { - operationId: 'doc.create.paragraph', - intentAction: 'paragraph', + "operationId": "doc.create.paragraph", + "intentAction": "paragraph" }, { - operationId: 'doc.create.heading', - intentAction: 'heading', - required: ['level'], + "operationId": "doc.create.heading", + "intentAction": "heading", + "required": [ + "level" + ] }, { - operationId: 'doc.create.table', - intentAction: 'table', - required: ['rows', 'columns'], - }, - ], + "operationId": "doc.create.table", + "intentAction": "table", + "required": [ + "rows", + "columns" + ] + } + ] }, { - toolName: 'superdoc_list', - description: - 'Create and manipulate bullet and numbered lists. Most actions require a list-item target: {kind:"block", nodeType:"listItem", nodeId:""}. Exceptions: "create" and "attach" operate on paragraph targets (they turn paragraphs into list items). Find nodeIds via superdoc_get_content({action:"blocks"}): pick listItem blocks for most actions, paragraph blocks for create/attach.\n\nCREATE & CONVERT:\n• "create": make a NEW list from paragraphs. Two modes: mode:"empty" with at:{kind:"block", nodeType:"paragraph", nodeId} converts a single paragraph; mode:"fromParagraphs" with target:{from:{...paragraph block address}, to:{...paragraph block address}} converts a range: ALL paragraphs between from and to become items, so make sure no other content sits between them. Pass a preset ("disc"|"circle"|"square"|"dash" for bullets; "decimal"|"decimalParenthesis"|"lowerLetter"|"upperLetter"|"lowerRoman"|"upperRoman" for ordered) or a custom style. Use "create" to start a fresh list: NOT to extend an existing one (use "attach" for that).\n• "attach": add paragraphs to an EXISTING list, inheriting its numbering definition. Pass target:{paragraph block address} (or {from, to} range of paragraphs) + attachTo:{kind:"block", nodeType:"listItem", nodeId:""} + optional level:0..8. Use this to extend a list or as the second half of a merge workflow (see "join" below).\n• "set_type": convert an existing list between ordered and bullet. Pass target:{listItem} + kind:"ordered" or "bullet". Adjacent compatible sequences are merged automatically to preserve continuous numbering.\n• "detach": convert a list item back to a plain paragraph. Pass target:{listItem}.\n\nITEMS & NESTING:\n• "insert": add a new list item adjacent to an existing item in the same list. Pass target:{listItem} + position:"before"|"after" + optional text. Use this (NOT superdoc_create) to add items to an existing list.\n• "indent" / "outdent": bump the target item\'s nesting level by one (0-8 range). Pass target:{listItem}.\n• "set_level": jump the target item to an explicit level. Pass target:{listItem} + level:0..8.\n\nNUMBERING (ordered lists):\n• "set_value": restart numbering at the target. Pass target:{listItem} + value: (e.g. value:1 to start over) or value:null to clear a previous override. Mid-sequence targets are atomically split off into their own sequence.\n• "continue_previous": make the target\'s sequence continue numbering from the nearest compatible previous sequence (same abstract definition). Pass target:{listItem of the sequence you want to renumber}. Fails with NO_COMPATIBLE_PREVIOUS or INCOMPATIBLE_DEFINITIONS if no matching prior sequence exists.\n\nSEQUENCE SHAPE (merge / split):\n• "merge": merge the target\'s sequence with an adjacent one into one continuous list. Pass target:{listItem} + direction:"withPrevious" or "withNext". Absorbed items adopt the absorbing sequence\'s numbering definition, and empty paragraphs between the two sequences are removed so numbering flows continuously.\n• "split": split the target\'s sequence at the target item into two independent lists. The target and everything after become a new sequence that restarts numbering at 1. Pass target:{listItem}; add restartNumbering:false to keep the count continuing instead of restarting.\n\nEXAMPLES:\n 1. {"action":"create","mode":"fromParagraphs","preset":"disc","target":{"from":{"kind":"block","nodeType":"paragraph","nodeId":""},"to":{"kind":"block","nodeType":"paragraph","nodeId":""}}}\n 2. {"action":"set_type","target":{"kind":"block","nodeType":"listItem","nodeId":""},"kind":"ordered"}\n 3. {"action":"insert","target":{"kind":"block","nodeType":"listItem","nodeId":""},"position":"after","text":"New list item"}\n 4. {"action":"indent","target":{"kind":"block","nodeType":"listItem","nodeId":""}}\n 5. {"action":"merge","target":{"kind":"block","nodeType":"listItem","nodeId":""},"direction":"withPrevious"}\n 6. {"action":"split","target":{"kind":"block","nodeType":"listItem","nodeId":""}}\n 7. {"action":"set_value","target":{"kind":"block","nodeType":"listItem","nodeId":""},"value":1}\n 8. {"action":"continue_previous","target":{"kind":"block","nodeType":"listItem","nodeId":""}}', - inputSchema: { - type: 'object', - properties: { - action: { - type: 'string', - enum: [ - 'attach', - 'continue_previous', - 'create', - 'delete', - 'detach', - 'indent', - 'insert', - 'merge', - 'outdent', - 'set_level', - 'set_type', - 'set_value', - 'split', + "toolName": "superdoc_list", + "description": "Create and manipulate bullet and numbered lists. Most actions require a list-item target: {kind:\"block\", nodeType:\"listItem\", nodeId:\"\"}. Exceptions: \"create\" and \"attach\" operate on paragraph targets (they turn paragraphs into list items). Find nodeIds via superdoc_get_content({action:\"blocks\"}): pick listItem blocks for most actions, paragraph blocks for create/attach.\n\nCREATE & CONVERT:\n• \"create\": make a NEW list from paragraphs. Two modes: mode:\"empty\" with at:{kind:\"block\", nodeType:\"paragraph\", nodeId} converts a single paragraph; mode:\"fromParagraphs\" with target:{from:{...paragraph block address}, to:{...paragraph block address}} converts a range: ALL paragraphs between from and to become items, so make sure no other content sits between them. Pass a preset (\"disc\"|\"circle\"|\"square\"|\"dash\" for bullets; \"decimal\"|\"decimalParenthesis\"|\"lowerLetter\"|\"upperLetter\"|\"lowerRoman\"|\"upperRoman\" for ordered) or a custom style. Use \"create\" to start a fresh list: NOT to extend an existing one (use \"attach\" for that).\n• \"attach\": add paragraphs to an EXISTING list, inheriting its numbering definition. Pass target:{paragraph block address} (or {from, to} range of paragraphs) + attachTo:{kind:\"block\", nodeType:\"listItem\", nodeId:\"\"} + optional level:0..8. Use this to extend a list or as the second half of a merge workflow (see \"join\" below).\n• \"set_type\": convert an existing list between ordered and bullet. Pass target:{listItem} + kind:\"ordered\" or \"bullet\". Adjacent compatible sequences are merged automatically to preserve continuous numbering.\n• \"detach\": convert a list item back to a plain paragraph. Pass target:{listItem}.\n\nITEMS & NESTING:\n• \"insert\": add a new list item adjacent to an existing item in the same list. Pass target:{listItem} + position:\"before\"|\"after\" + optional text. Use this (NOT superdoc_create) to add items to an existing list.\n• \"indent\" / \"outdent\": bump the target item's nesting level by one (0-8 range). Pass target:{listItem}.\n• \"set_level\": jump the target item to an explicit level. Pass target:{listItem} + level:0..8.\n\nNUMBERING (ordered lists):\n• \"set_value\": restart numbering at the target. Pass target:{listItem} + value: (e.g. value:1 to start over) or value:null to clear a previous override. Mid-sequence targets are atomically split off into their own sequence.\n• \"continue_previous\": make the target's sequence continue numbering from the nearest compatible previous sequence (same abstract definition). Pass target:{listItem of the sequence you want to renumber}. Fails with NO_COMPATIBLE_PREVIOUS or INCOMPATIBLE_DEFINITIONS if no matching prior sequence exists.\n\nSEQUENCE SHAPE (merge / split):\n• \"merge\": merge the target's sequence with an adjacent one into one continuous list. Pass target:{listItem} + direction:\"withPrevious\" or \"withNext\". Absorbed items adopt the absorbing sequence's numbering definition, and empty paragraphs between the two sequences are removed so numbering flows continuously.\n• \"split\": split the target's sequence at the target item into two independent lists. The target and everything after become a new sequence that restarts numbering at 1. Pass target:{listItem}; add restartNumbering:false to keep the count continuing instead of restarting.\n\nEXAMPLES:\n 1. {\"action\":\"create\",\"mode\":\"fromParagraphs\",\"preset\":\"disc\",\"target\":{\"from\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"},\"to\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"}}}\n 2. {\"action\":\"set_type\",\"target\":{\"kind\":\"block\",\"nodeType\":\"listItem\",\"nodeId\":\"\"},\"kind\":\"ordered\"}\n 3. {\"action\":\"insert\",\"target\":{\"kind\":\"block\",\"nodeType\":\"listItem\",\"nodeId\":\"\"},\"position\":\"after\",\"text\":\"New list item\"}\n 4. {\"action\":\"indent\",\"target\":{\"kind\":\"block\",\"nodeType\":\"listItem\",\"nodeId\":\"\"}}\n 5. {\"action\":\"merge\",\"target\":{\"kind\":\"block\",\"nodeType\":\"listItem\",\"nodeId\":\"\"},\"direction\":\"withPrevious\"}\n 6. {\"action\":\"split\",\"target\":{\"kind\":\"block\",\"nodeType\":\"listItem\",\"nodeId\":\"\"}}\n 7. {\"action\":\"set_value\",\"target\":{\"kind\":\"block\",\"nodeType\":\"listItem\",\"nodeId\":\"\"},\"value\":1}\n 8. {\"action\":\"continue_previous\",\"target\":{\"kind\":\"block\",\"nodeType\":\"listItem\",\"nodeId\":\"\"}}", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "attach", + "continue_previous", + "create", + "delete", + "detach", + "indent", + "insert", + "merge", + "outdent", + "set_level", + "set_type", + "set_value", + "split" ], - description: - 'The action to perform. One of: attach, continue_previous, create, delete, detach, indent, insert, merge, outdent, set_level, set_type, set_value, split.', + "description": "The action to perform. One of: attach, continue_previous, create, delete, detach, indent, insert, merge, outdent, set_level, set_type, set_value, split." }, - force: { - type: 'boolean', - description: 'Bypass confirmation checks.', + "force": { + "type": "boolean", + "description": "Bypass confirmation checks." }, - changeMode: { - type: 'string', - enum: ['direct', 'tracked'], - description: 'Edit mode: "direct" applies changes immediately, "tracked" records as suggestions.', + "changeMode": { + "type": "string", + "enum": [ + "direct", + "tracked" + ], + "description": "Edit mode: \"direct\" applies changes immediately, \"tracked\" records as suggestions." }, - dryRun: { - type: 'boolean', - description: 'Preview the result without applying changes.', + "dryRun": { + "type": "boolean", + "description": "Preview the result without applying changes." }, - target: { - oneOf: [ + "target": { + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/ListItemAddress', - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "$ref": "#/$defs/ListItemAddress", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/BlockAddressOrRange', - description: - "Required when mode is 'fromParagraphs'. Each call converts ONE paragraph into a list item. To make a list with N items, create N separate paragraphs first, then call superdoc_list create for EACH one. Format: {kind:'block', nodeType:'paragraph', nodeId:''}.", - }, + "$ref": "#/$defs/BlockAddressOrRange", + "description": "Required when mode is 'fromParagraphs'. Each call converts ONE paragraph into a list item. To make a list with N items, create N separate paragraphs first, then call superdoc_list create for EACH one. Format: {kind:'block', nodeType:'paragraph', nodeId:''}." + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/BlockAddressOrRange', - }, + "$ref": "#/$defs/BlockAddressOrRange" + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/ListItemAddress', - }, + "$ref": "#/$defs/ListItemAddress" + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/ListItemAddress', - }, + "$ref": "#/$defs/ListItemAddress" + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/ListItemAddress', - }, + "$ref": "#/$defs/ListItemAddress" + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/ListItemAddress', - }, + "$ref": "#/$defs/ListItemAddress" + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/ListItemAddress', - }, + "$ref": "#/$defs/ListItemAddress" + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/ListItemAddress', - }, + "$ref": "#/$defs/ListItemAddress" + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/ListItemAddress', - }, + "$ref": "#/$defs/ListItemAddress" + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/ListItemAddress', - }, + "$ref": "#/$defs/ListItemAddress" + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/ListItemAddress', - }, + "$ref": "#/$defs/ListItemAddress" + } ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." }, { - $ref: '#/$defs/ListItemAddress', - }, + "$ref": "#/$defs/ListItemAddress" + } + ], + "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}. Required for actions 'insert', 'attach', 'detach', 'delete', 'indent', 'outdent', 'merge', 'split', 'set_level', 'set_value', 'continue_previous', 'set_type'." + }, + "position": { + "enum": [ + "before", + "after" + ], + "description": "Required. Insert position relative to target: 'before' or 'after'. Required for action 'insert'." + }, + "text": { + "type": "string", + "description": "Text content for the new list item. Only for action 'insert'. Omit for other actions." + }, + "input": { + "type": "object", + "description": "Operation input as JSON object." + }, + "nodeId": { + "type": "string", + "description": "Node ID of the target list item." + }, + "mode": { + "enum": [ + "empty", + "fromParagraphs" ], - description: - "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}. Required for actions 'insert', 'attach', 'detach', 'delete', 'indent', 'outdent', 'merge', 'split', 'set_level', 'set_value', 'continue_previous', 'set_type'.", - }, - position: { - enum: ['before', 'after'], - description: - "Required. Insert position relative to target: 'before' or 'after'. Required for action 'insert'.", - }, - text: { - type: 'string', - description: "Text content for the new list item. Only for action 'insert'. Omit for other actions.", - }, - input: { - type: 'object', - description: 'Operation input as JSON object.', - }, - nodeId: { - type: 'string', - description: 'Node ID of the target list item.', - }, - mode: { - enum: ['empty', 'fromParagraphs'], - description: - "Required. 'fromParagraphs' converts existing paragraphs into list items: each paragraph becomes one item, so create one paragraph per item first. 'empty' creates a new empty list at 'at'. Required for action 'create'.", - }, - at: { - $ref: '#/$defs/BlockAddress', - description: - "Required when mode is 'empty'. The paragraph to create the list at. Format: {kind:'block', nodeType:'paragraph', nodeId:''}. Only for action 'create'. Omit for other actions.", - }, - kind: { - enum: ['ordered', 'bullet'], - description: - "List type: 'bullet' for bullet points, 'ordered' for numbered lists. Required for action 'set_type'.", - }, - level: { - oneOf: [ + "description": "Required. 'fromParagraphs' converts existing paragraphs into list items: each paragraph becomes one item, so create one paragraph per item first. 'empty' creates a new empty list at 'at'. Required for action 'create'." + }, + "at": { + "$ref": "#/$defs/BlockAddress", + "description": "Required when mode is 'empty'. The paragraph to create the list at. Format: {kind:'block', nodeType:'paragraph', nodeId:''}. Only for action 'create'. Omit for other actions." + }, + "kind": { + "enum": [ + "ordered", + "bullet" + ], + "description": "List type: 'bullet' for bullet points, 'ordered' for numbered lists. Required for action 'set_type'." + }, + "level": { + "oneOf": [ { - oneOf: [ + "oneOf": [ { - type: 'integer', - minimum: 0, - maximum: 8, - description: 'List nesting level (0-8). 0 is the top level.', + "type": "integer", + "minimum": 0, + "maximum": 8, + "description": "List nesting level (0-8). 0 is the top level." }, { - type: 'integer', - minimum: 0, - maximum: 8, - }, + "type": "integer", + "minimum": 0, + "maximum": 8 + } ], - description: 'List nesting level (0-8). 0 is the top level.', + "description": "List nesting level (0-8). 0 is the top level." }, { - type: 'integer', - minimum: 0, - maximum: 8, - }, + "type": "integer", + "minimum": 0, + "maximum": 8 + } ], - description: "List nesting level (0-8). 0 is the top level. Required for action 'set_level'.", - }, - preset: { - enum: [ - 'decimal', - 'decimalParenthesis', - 'lowerLetter', - 'upperLetter', - 'lowerRoman', - 'upperRoman', - 'disc', - 'circle', - 'square', - 'dash', + "description": "List nesting level (0-8). 0 is the top level. Required for action 'set_level'." + }, + "preset": { + "enum": [ + "decimal", + "decimalParenthesis", + "lowerLetter", + "upperLetter", + "lowerRoman", + "upperRoman", + "disc", + "circle", + "square", + "dash" ], - description: - "Predefined list style preset. Overrides 'kind' with a specific numbering or bullet format. Only for action 'create'. Omit for other actions.", - }, - style: { - type: 'object', - properties: { - version: { - const: 1, - type: 'number', + "description": "Predefined list style preset. Overrides 'kind' with a specific numbering or bullet format. Only for action 'create'. Omit for other actions." + }, + "style": { + "type": "object", + "properties": { + "version": { + "const": 1, + "type": "number" }, - levels: { - type: 'array', - items: { - type: 'object', - properties: { - level: { - type: 'integer', - minimum: 0, - maximum: 8, + "levels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "level": { + "type": "integer", + "minimum": 0, + "maximum": 8 }, - numFmt: { - type: 'string', + "numFmt": { + "type": "string" }, - lvlText: { - type: 'string', + "lvlText": { + "type": "string" }, - start: { - type: 'integer', + "start": { + "type": "integer" }, - alignment: { - enum: ['left', 'center', 'right'], + "alignment": { + "enum": [ + "left", + "center", + "right" + ] }, - indents: { - type: 'object', - properties: { - left: { - type: 'integer', + "indents": { + "type": "object", + "properties": { + "left": { + "type": "integer" }, - hanging: { - type: 'integer', - }, - firstLine: { - type: 'integer', + "hanging": { + "type": "integer" }, + "firstLine": { + "type": "integer" + } }, - additionalProperties: false, - }, - trailingCharacter: { - enum: ['tab', 'space', 'nothing'], + "additionalProperties": false }, - markerFont: { - type: 'string', + "trailingCharacter": { + "enum": [ + "tab", + "space", + "nothing" + ] }, - pictureBulletId: { - type: 'integer', + "markerFont": { + "type": "string" }, - tabStopAt: { - type: ['integer', 'null'], + "pictureBulletId": { + "type": "integer" }, - }, - additionalProperties: false, - required: ['level'], - }, - }, + "tabStopAt": { + "type": [ + "integer", + "null" + ] + } + }, + "additionalProperties": false, + "required": [ + "level" + ] + } + } }, - additionalProperties: false, - required: ['version', 'levels'], - description: "Only for action 'create'. Omit for other actions.", + "additionalProperties": false, + "required": [ + "version", + "levels" + ], + "description": "Only for action 'create'. Omit for other actions." }, - sequence: { - oneOf: [ + "sequence": { + "oneOf": [ { - type: 'object', - properties: { - mode: { - const: 'new', - type: 'string', - }, - startAt: { - type: 'integer', - minimum: 1, - }, + "type": "object", + "properties": { + "mode": { + "const": "new", + "type": "string" + }, + "startAt": { + "type": "integer", + "minimum": 1 + } }, - additionalProperties: false, - required: ['mode'], + "additionalProperties": false, + "required": [ + "mode" + ] }, { - type: 'object', - properties: { - mode: { - const: 'continuePrevious', - type: 'string', - }, + "type": "object", + "properties": { + "mode": { + "const": "continuePrevious", + "type": "string" + } }, - additionalProperties: false, - required: ['mode'], - }, + "additionalProperties": false, + "required": [ + "mode" + ] + } ], - description: "Only for action 'create'. Omit for other actions.", - }, - attachTo: { - $ref: '#/$defs/ListItemAddress', - description: "Required for action 'attach'.", + "description": "Only for action 'create'. Omit for other actions." }, - direction: { - enum: ['withPrevious', 'withNext'], - description: "Required for action 'merge'.", + "attachTo": { + "$ref": "#/$defs/ListItemAddress", + "description": "Required for action 'attach'." }, - restartNumbering: { - type: 'boolean', - description: "Only for action 'split'. Omit for other actions.", + "direction": { + "enum": [ + "withPrevious", + "withNext" + ], + "description": "Required for action 'merge'." }, - value: { - type: ['integer', 'null'], - description: "Required for action 'set_value'.", + "restartNumbering": { + "type": "boolean", + "description": "Only for action 'split'. Omit for other actions." }, - continuity: { - enum: ['preserve', 'none'], - description: - "Numbering continuity: 'preserve' keeps numbering; 'none' restarts. Only for action 'set_type'. Omit for other actions.", + "value": { + "type": [ + "integer", + "null" + ], + "description": "Required for action 'set_value'." }, + "continuity": { + "enum": [ + "preserve", + "none" + ], + "description": "Numbering continuity: 'preserve' keeps numbering; 'none' restarts. Only for action 'set_type'. Omit for other actions." + } }, - required: ['action'], - additionalProperties: false, + "required": [ + "action" + ], + "additionalProperties": false }, - mutates: true, - operations: [ + "mutates": true, + "operations": [ { - operationId: 'doc.lists.insert', - intentAction: 'insert', - required: ['target', 'position'], + "operationId": "doc.lists.insert", + "intentAction": "insert", + "required": [ + "target", + "position" + ] }, { - operationId: 'doc.lists.create', - intentAction: 'create', - required: ['mode'], + "operationId": "doc.lists.create", + "intentAction": "create", + "required": [ + "mode" + ] }, { - operationId: 'doc.lists.attach', - intentAction: 'attach', - required: ['target', 'attachTo'], + "operationId": "doc.lists.attach", + "intentAction": "attach", + "required": [ + "target", + "attachTo" + ] }, { - operationId: 'doc.lists.detach', - intentAction: 'detach', - required: ['target'], + "operationId": "doc.lists.detach", + "intentAction": "detach", + "required": [ + "target" + ] }, { - operationId: 'doc.lists.delete', - intentAction: 'delete', - required: ['target'], + "operationId": "doc.lists.delete", + "intentAction": "delete", + "required": [ + "target" + ] }, { - operationId: 'doc.lists.indent', - intentAction: 'indent', - required: ['target'], + "operationId": "doc.lists.indent", + "intentAction": "indent", + "required": [ + "target" + ] }, { - operationId: 'doc.lists.outdent', - intentAction: 'outdent', - required: ['target'], + "operationId": "doc.lists.outdent", + "intentAction": "outdent", + "required": [ + "target" + ] }, { - operationId: 'doc.lists.merge', - intentAction: 'merge', - required: ['target', 'direction'], + "operationId": "doc.lists.merge", + "intentAction": "merge", + "required": [ + "target", + "direction" + ] }, { - operationId: 'doc.lists.split', - intentAction: 'split', - required: ['target'], + "operationId": "doc.lists.split", + "intentAction": "split", + "required": [ + "target" + ] }, { - operationId: 'doc.lists.setLevel', - intentAction: 'set_level', - required: ['target', 'level'], + "operationId": "doc.lists.setLevel", + "intentAction": "set_level", + "required": [ + "target", + "level" + ] }, { - operationId: 'doc.lists.setValue', - intentAction: 'set_value', - required: ['target', 'value'], + "operationId": "doc.lists.setValue", + "intentAction": "set_value", + "required": [ + "target", + "value" + ] }, { - operationId: 'doc.lists.continuePrevious', - intentAction: 'continue_previous', - required: ['target'], + "operationId": "doc.lists.continuePrevious", + "intentAction": "continue_previous", + "required": [ + "target" + ] }, { - operationId: 'doc.lists.setType', - intentAction: 'set_type', - required: ['target', 'kind'], - }, - ], + "operationId": "doc.lists.setType", + "intentAction": "set_type", + "required": [ + "target", + "kind" + ] + } + ] }, { - toolName: 'superdoc_comment', - description: - 'Manage document comment threads: create, read, update, and delete. To create a comment, first use superdoc_search to find the target text, then pass action "create" with the comment text and a target built from items[0].blocks. For a single-block match use {kind:"text", blockId: items[0].blocks[0].blockId, range: items[0].blocks[0].range}. For a cross-block match use {kind:"text", segments: items[0].blocks.map(b => ({blockId: b.blockId, range: b.range}))}. Do NOT use items[0].highlightRange (snippet-relative, not block-relative) or items[0].target (a SelectionTarget, not accepted by comments.create). For threaded replies, pass "parentId" with the parent comment ID. Action "list" returns all comments with optional pagination (limit, offset) and filtering (includeResolved:true to include resolved). Action "get" retrieves a single comment by ID. Action "update" changes status to "resolved" or marks as internal. Action "delete" removes a comment or reply by ID. Do NOT pass "ref", "id", or "parentId" when creating a new top-level comment; only "action", "text", and "target" are needed.\n\nEXAMPLES:\n 1. {"action":"create","text":"Please review this section.","target":{"kind":"text","blockId":"","range":{"start":5,"end":25}}}\n 2. {"action":"list","limit":20,"offset":0}\n 3. {"action":"update","id":"","status":"resolved"}\n 4. {"action":"delete","id":""}', - inputSchema: { - type: 'object', - properties: { - action: { - type: 'string', - enum: ['create', 'delete', 'get', 'list', 'update'], - description: 'The action to perform. One of: create, delete, get, list, update.', - }, - force: { - type: 'boolean', - description: 'Bypass confirmation checks.', - }, - changeMode: { - type: 'string', - enum: ['direct', 'tracked'], - description: 'Edit mode: "direct" applies changes immediately, "tracked" records as suggestions.', - }, - text: { - oneOf: [ + "toolName": "superdoc_comment", + "description": "Manage document comment threads: create, read, update, and delete. To create a comment, first use superdoc_search to find the target text, then pass action \"create\" with the comment text and a target built from items[0].blocks. For a single-block match use {kind:\"text\", blockId: items[0].blocks[0].blockId, range: items[0].blocks[0].range}. For a cross-block match use {kind:\"text\", segments: items[0].blocks.map(b => ({blockId: b.blockId, range: b.range}))}. Do NOT use items[0].highlightRange (snippet-relative, not block-relative) or items[0].target (a SelectionTarget, not accepted by comments.create). For threaded replies, pass \"parentId\" with the parent comment ID. Action \"list\" returns all comments with optional pagination (limit, offset) and filtering (includeResolved:true to include resolved). Action \"get\" retrieves a single comment by ID. Action \"update\" changes status to \"resolved\" or marks as internal. Action \"delete\" removes a comment or reply by ID. Do NOT pass \"ref\", \"id\", or \"parentId\" when creating a new top-level comment; only \"action\", \"text\", and \"target\" are needed.\n\nEXAMPLES:\n 1. {\"action\":\"create\",\"text\":\"Please review this section.\",\"target\":{\"kind\":\"text\",\"blockId\":\"\",\"range\":{\"start\":5,\"end\":25}}}\n 2. {\"action\":\"list\",\"limit\":20,\"offset\":0}\n 3. {\"action\":\"update\",\"id\":\"\",\"status\":\"resolved\"}\n 4. {\"action\":\"delete\",\"id\":\"\"}", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "create", + "delete", + "get", + "list", + "update" + ], + "description": "The action to perform. One of: create, delete, get, list, update." + }, + "force": { + "type": "boolean", + "description": "Bypass confirmation checks." + }, + "changeMode": { + "type": "string", + "enum": [ + "direct", + "tracked" + ], + "description": "Edit mode: \"direct\" applies changes immediately, \"tracked\" records as suggestions." + }, + "text": { + "oneOf": [ { - type: 'string', - description: 'Comment text content.', + "type": "string", + "description": "Comment text content." }, { - type: 'string', - description: 'Updated comment text.', - }, + "type": "string", + "description": "Updated comment text." + } ], - description: "Comment text content. Required for action 'create'.", + "description": "Comment text content. Required for action 'create'." }, - target: { - oneOf: [ + "target": { + "oneOf": [ { - oneOf: [ + "oneOf": [ + { + "$ref": "#/$defs/TextAddress" + }, { - $ref: '#/$defs/TextAddress', + "$ref": "#/$defs/TextTarget" }, { - $ref: '#/$defs/TextTarget', + "$ref": "#/$defs/SelectionTarget" }, + { + "$ref": "#/$defs/CommentTrackedChangeTarget" + } ], - description: - "Text range to anchor the comment. Accepts either a single-block TextAddress {kind:'text', blockId, range} or a multi-segment TextTarget {kind:'text', segments:[{blockId, range}, ...]} for selections that span blocks.", + "description": "Comment target. Accepts a TextAddress, TextTarget, SelectionTarget, or {trackedChangeId, kind?:'trackedChange'} to anchor directly on tracked content." }, { - $ref: '#/$defs/TextAddress', - }, + "oneOf": [ + { + "$ref": "#/$defs/TextAddress" + }, + { + "$ref": "#/$defs/TextTarget" + }, + { + "$ref": "#/$defs/SelectionTarget" + }, + { + "$ref": "#/$defs/CommentTrackedChangeTarget" + } + ] + } ], - description: - "Text range to anchor the comment. Accepts either a single-block TextAddress {kind:'text', blockId, range} or a multi-segment TextTarget {kind:'text', segments:[{blockId, range}, ...]} for selections that span blocks. Only for actions 'create', 'update'. Omit for other actions.", + "description": "Comment target. Accepts a TextAddress, TextTarget, SelectionTarget, or {trackedChangeId, kind?:'trackedChange'} to anchor directly on tracked content. Only for actions 'create', 'update'. Omit for other actions." }, - parentId: { - type: 'string', - description: - "Parent comment ID for creating a threaded reply. Only for action 'create'. Omit for other actions.", + "parentId": { + "type": "string", + "description": "Parent comment ID for creating a threaded reply. Only for action 'create'. Omit for other actions." }, - id: { - type: 'string', - description: "Required for actions 'delete', 'get'.", + "id": { + "type": "string", + "description": "Required for actions 'delete', 'get'." }, - status: { - enum: ['resolved', 'active'], - description: - "Set comment status. Use 'resolved' to resolve a comment, or 'active' to reopen a previously resolved comment (lifecycle inverse). Only for action 'update'. Omit for other actions.", - }, - isInternal: { - type: 'boolean', - description: - "When true, marks the comment as internal (hidden from external collaborators). Only for action 'update'. Omit for other actions.", + "status": { + "enum": [ + "resolved", + "active" + ], + "description": "Set comment status. Use 'resolved' to resolve a comment, or 'active' to reopen a previously resolved comment (lifecycle inverse). Only for action 'update'. Omit for other actions." }, - includeResolved: { - type: 'boolean', - description: - "When true, includes resolved comments in results. Default: false. Only for action 'list'. Omit for other actions.", + "isInternal": { + "type": "boolean", + "description": "When true, marks the comment as internal (hidden from external collaborators). Only for action 'update'. Omit for other actions." }, - limit: { - type: 'integer', - description: "Maximum number of comments to return. Only for action 'list'. Omit for other actions.", + "includeResolved": { + "type": "boolean", + "description": "When true, includes resolved comments in results. Default: false. Only for action 'list'. Omit for other actions." }, - offset: { - type: 'integer', - description: "Number of comments to skip for pagination. Only for action 'list'. Omit for other actions.", + "limit": { + "type": "integer", + "description": "Maximum number of comments to return. Only for action 'list'. Omit for other actions." }, + "offset": { + "type": "integer", + "description": "Number of comments to skip for pagination. Only for action 'list'. Omit for other actions." + } }, - required: ['action'], - additionalProperties: false, + "required": [ + "action" + ], + "additionalProperties": false }, - mutates: true, - operations: [ + "mutates": true, + "operations": [ { - operationId: 'doc.comments.create', - intentAction: 'create', - required: ['text'], + "operationId": "doc.comments.create", + "intentAction": "create", + "required": [ + "text" + ] }, { - operationId: 'doc.comments.patch', - intentAction: 'update', + "operationId": "doc.comments.patch", + "intentAction": "update" }, { - operationId: 'doc.comments.delete', - intentAction: 'delete', - required: ['id'], + "operationId": "doc.comments.delete", + "intentAction": "delete", + "required": [ + "id" + ] }, { - operationId: 'doc.comments.get', - intentAction: 'get', - required: ['id'], + "operationId": "doc.comments.get", + "intentAction": "get", + "required": [ + "id" + ] }, { - operationId: 'doc.comments.list', - intentAction: 'list', - }, - ], + "operationId": "doc.comments.list", + "intentAction": "list" + } + ] }, { - toolName: 'superdoc_track_changes', - description: - 'Review and resolve tracked changes (insertions, deletions, format changes) in the document. Action "list" returns all tracked changes with optional filtering by type (insert, delete, format) and pagination (limit, offset). Each change includes an ID, type, author, timestamp, and content preview. Action "decide" accepts or rejects changes. Pass decision:"accept" to apply the change permanently, or decision:"reject" to discard it. Target a single change with {id:""} or all changes at once with {scope:"all"}. Do NOT use this tool unless the document has tracked changes. Use superdoc_get_content info to check the tracked change count first.\n\nEXAMPLES:\n 1. {"action":"list"}\n 2. {"action":"list","type":"insert","limit":10}\n 3. {"action":"decide","decision":"accept","target":{"id":""}}\n 4. {"action":"decide","decision":"reject","target":{"scope":"all"}}', - inputSchema: { - type: 'object', - properties: { - action: { - type: 'string', - enum: ['decide', 'list'], - description: 'The action to perform. One of: decide, list.', - }, - limit: { - type: 'integer', - description: "Maximum number of tracked changes to return. Only for action 'list'. Omit for other actions.", - }, - offset: { - type: 'integer', - description: - "Number of tracked changes to skip for pagination. Only for action 'list'. Omit for other actions.", - }, - type: { - enum: ['insert', 'delete', 'format'], - description: - "Filter by change type: 'insert', 'delete', or 'format'. Only for action 'list'. Omit for other actions.", - }, - force: { - type: 'boolean', - description: "Bypass confirmation checks. Only for action 'decide'. Omit for other actions.", - }, - changeMode: { - type: 'string', - enum: ['direct', 'tracked'], - description: - 'Edit mode: "direct" applies changes immediately, "tracked" records as suggestions. Only for action \'decide\'. Omit for other actions.', - }, - decision: { - enum: ['accept', 'reject'], - description: "Required for action 'decide'.", - }, - target: { - oneOf: [ + "toolName": "superdoc_track_changes", + "description": "Review and resolve tracked changes (insertions, deletions, replacements, format changes) in the document. Action \"list\" returns all tracked changes with optional filtering by type (insert, delete, replacement, format) and pagination (limit, offset). Each change includes an ID, type, author, timestamp, and content preview. Action \"decide\" accepts or rejects changes. Pass decision:\"accept\" to apply the change permanently, or decision:\"reject\" to discard it. Target a single change with {id:\"\"}, a partial selection with {kind:\"range\", range:{...}}, or all changes at once with {scope:\"all\"} (optionally plus story). Do NOT use this tool unless the document has tracked changes. Use superdoc_get_content info to check the tracked change count first.\n\nEXAMPLES:\n 1. {\"action\":\"list\"}\n 2. {\"action\":\"list\",\"type\":\"replacement\",\"limit\":10}\n 3. {\"action\":\"decide\",\"decision\":\"accept\",\"target\":{\"id\":\"\"}}\n 4. {\"action\":\"decide\",\"decision\":\"reject\",\"target\":{\"kind\":\"range\",\"range\":{\"kind\":\"text\",\"segments\":[{\"blockId\":\"\",\"range\":{\"start\":0,\"end\":5}}]}}}\n 5. {\"action\":\"decide\",\"decision\":\"reject\",\"target\":{\"scope\":\"all\"}}", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "decide", + "list" + ], + "description": "The action to perform. One of: decide, list." + }, + "limit": { + "type": "integer", + "description": "Maximum number of tracked changes to return. Only for action 'list'. Omit for other actions." + }, + "offset": { + "type": "integer", + "description": "Number of tracked changes to skip for pagination. Only for action 'list'. Omit for other actions." + }, + "type": { + "enum": [ + "insert", + "delete", + "replacement", + "format" + ], + "description": "Filter by change type: 'insert', 'delete', 'replacement', or 'format'. Only for action 'list'. Omit for other actions." + }, + "force": { + "type": "boolean", + "description": "Bypass confirmation checks. Only for action 'decide'. Omit for other actions." + }, + "changeMode": { + "type": "string", + "enum": [ + "direct", + "tracked" + ], + "description": "Edit mode: \"direct\" applies changes immediately, \"tracked\" records as suggestions. Only for action 'decide'. Omit for other actions." + }, + "decision": { + "enum": [ + "accept", + "reject" + ], + "description": "Required for action 'decide'." + }, + "target": { + "oneOf": [ { - type: 'object', - properties: { - id: { - type: 'string', - }, - story: { - $ref: '#/$defs/StoryLocator', - }, + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "story": { + "$ref": "#/$defs/StoryLocator" + } }, - additionalProperties: false, - required: ['id'], + "additionalProperties": false, + "required": [ + "id" + ] }, { - type: 'object', - properties: { - scope: { - enum: ['all'], - }, + "type": "object", + "properties": { + "kind": { + "const": "range", + "type": "string" + }, + "range": { + "$ref": "#/$defs/TextTarget" + }, + "story": { + "$ref": "#/$defs/StoryLocator" + }, + "part": { + "type": "string", + "description": "Optional part discriminator for the range target." + } }, - additionalProperties: false, - required: ['scope'], + "additionalProperties": false, + "required": [ + "kind", + "range" + ] }, + { + "type": "object", + "properties": { + "scope": { + "enum": [ + "all" + ] + }, + "story": { + "oneOf": [ + { + "$ref": "#/$defs/StoryLocator" + }, + { + "const": "all", + "type": "string" + } + ], + "description": "Optional explicit bulk filter. Omit or pass 'all' to target every revision-capable story, or pass a StoryLocator to scope the decision to one story." + } + }, + "additionalProperties": false, + "required": [ + "scope" + ] + } ], - description: "Required for action 'decide'.", - }, + "description": "Required for action 'decide'." + } }, - required: ['action'], - additionalProperties: false, + "required": [ + "action" + ], + "additionalProperties": false }, - mutates: true, - operations: [ + "mutates": true, + "operations": [ { - operationId: 'doc.trackChanges.list', - intentAction: 'list', + "operationId": "doc.trackChanges.list", + "intentAction": "list" }, { - operationId: 'doc.trackChanges.decide', - intentAction: 'decide', - required: ['decision', 'target'], - }, - ], + "operationId": "doc.trackChanges.decide", + "intentAction": "decide", + "required": [ + "decision", + "target" + ] + } + ] }, { - toolName: 'superdoc_search', - description: - 'Find text patterns or nodes in the document and get ref handles for targeting edits and formatting. Refs expire after any mutation that changes the document. Re-search before the next edit when using individual tools (superdoc_edit, superdoc_format). Within a superdoc_mutations batch, selectors in "where" clauses resolve automatically at compile time; no manual re-searching needed between steps. Text search returns handle.ref covering only the matched substring. Node search finds blocks by type (paragraph, heading, table, listItem, etc.). The "require" parameter controls match cardinality: "first" returns one match, "all" returns every match, "exactlyOne" fails if not exactly one match. Supports scoping via "within" to search inside a single block. Do NOT use regex or markdown formatting markers (#, **, etc.) in search patterns; patterns are plain text only. Do NOT use this tool when you already have a ref from superdoc_get_content blocks or superdoc_create; use that ref directly.\n\nEXAMPLES:\n 1. {"select":{"type":"text","pattern":"Introduction"},"require":"first"}\n 2. {"select":{"type":"text","pattern":"total amount"},"require":"all"}\n 3. {"select":{"type":"node","nodeType":"heading"},"require":"all"}\n 4. {"select":{"type":"text","pattern":"contract"},"within":{"kind":"block","nodeType":"paragraph","nodeId":"abc123"},"require":"first"}', - inputSchema: { - type: 'object', - properties: { - select: { - description: - "Search selector. Use {type:'text', pattern:'...'} for text search or {type:'node', nodeType:'paragraph'|'heading'|...} for node search.", - oneOf: [ + "toolName": "superdoc_search", + "description": "Find text patterns or nodes in the document and get ref handles for targeting edits and formatting. Refs expire after any mutation that changes the document. Re-search before the next edit when using individual tools (superdoc_edit, superdoc_format). Within a superdoc_mutations batch, selectors in \"where\" clauses resolve automatically at compile time; no manual re-searching needed between steps. Text search returns handle.ref covering only the matched substring. Node search finds blocks by type (paragraph, heading, table, listItem, etc.). The \"require\" parameter controls match cardinality: \"first\" returns one match, \"all\" returns every match, \"exactlyOne\" fails if not exactly one match. Supports scoping via \"within\" to search inside a single block. Do NOT use regex or markdown formatting markers (#, **, etc.) in search patterns; patterns are plain text only. Do NOT use this tool when you already have a ref from superdoc_get_content blocks or superdoc_create; use that ref directly.\n\nEXAMPLES:\n 1. {\"select\":{\"type\":\"text\",\"pattern\":\"Introduction\"},\"require\":\"first\"}\n 2. {\"select\":{\"type\":\"text\",\"pattern\":\"total amount\"},\"require\":\"all\"}\n 3. {\"select\":{\"type\":\"node\",\"nodeType\":\"heading\"},\"require\":\"all\"}\n 4. {\"select\":{\"type\":\"text\",\"pattern\":\"contract\"},\"within\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"abc123\"},\"require\":\"first\"}", + "inputSchema": { + "type": "object", + "properties": { + "select": { + "description": "Search selector. Use {type:'text', pattern:'...'} for text search or {type:'node', nodeType:'paragraph'|'heading'|...} for node search.", + "oneOf": [ { - type: 'object', - properties: { - type: { - const: 'text', - description: "Must be 'text' for text pattern search.", - type: 'string', - }, - pattern: { - type: 'string', - description: 'Text or regex pattern to match.', - }, - mode: { - enum: ['contains', 'regex'], - description: "Match mode: 'contains' (substring) or 'regex'.", - }, - caseSensitive: { - type: 'boolean', - description: 'Case-sensitive matching. Default: false.', + "type": "object", + "properties": { + "type": { + "const": "text", + "description": "Must be 'text' for text pattern search.", + "type": "string" + }, + "pattern": { + "type": "string", + "description": "Text or regex pattern to match." + }, + "mode": { + "enum": [ + "contains", + "regex" + ], + "description": "Match mode: 'contains' (substring) or 'regex'." }, + "caseSensitive": { + "type": "boolean", + "description": "Case-sensitive matching. Default: false." + } }, - additionalProperties: false, - required: ['type', 'pattern'], + "additionalProperties": false, + "required": [ + "type", + "pattern" + ] }, { - type: 'object', - properties: { - type: { - const: 'node', - description: "Must be 'node' for node type search.", - type: 'string', - }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - 'run', - 'bookmark', - 'comment', - 'hyperlink', - 'footnoteRef', - 'endnoteRef', - 'crossRef', - 'indexEntry', - 'citation', - 'authorityEntry', - 'sequenceField', - 'tab', - 'lineBreak', + "type": "object", + "properties": { + "type": { + "const": "node", + "description": "Must be 'node' for node type search.", + "type": "string" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "endnoteRef", + "crossRef", + "indexEntry", + "citation", + "authorityEntry", + "sequenceField", + "tab", + "lineBreak" ], - description: 'Block type to match (paragraph, heading, table, listItem, etc.).', - }, - kind: { - enum: ['block', 'inline'], - description: "Filter: 'block' or 'inline'.", + "description": "Block type to match (paragraph, heading, table, listItem, etc.)." }, + "kind": { + "enum": [ + "block", + "inline" + ], + "description": "Filter: 'block' or 'inline'." + } }, - additionalProperties: false, - required: ['type'], - }, + "additionalProperties": false, + "required": [ + "type" + ] + } + ] + }, + "within": { + "$ref": "#/$defs/BlockNodeAddress", + "description": "Limit search scope to within a specific block: {kind:'block', nodeType:'...', nodeId:'...'}." + }, + "require": { + "enum": [ + "any", + "first", + "exactlyOne", + "all" ], + "description": "Match cardinality: 'any' (all matches), 'first' (only first), 'exactlyOne' (fail if != 1), 'all' (fail if 0)." }, - within: { - $ref: '#/$defs/BlockNodeAddress', - description: "Limit search scope to within a specific block: {kind:'block', nodeType:'...', nodeId:'...'}.", - }, - require: { - enum: ['any', 'first', 'exactlyOne', 'all'], - description: - "Match cardinality: 'any' (all matches), 'first' (only first), 'exactlyOne' (fail if != 1), 'all' (fail if 0).", - }, - mode: { - enum: ['strict', 'candidates'], - description: - "Search mode: 'strict' (default, exact matching) or 'candidates' (returns scored potential matches).", - }, - includeNodes: { - type: 'boolean', - description: 'When true, includes full node data in results. Default: false.', - }, - limit: { - type: 'integer', - minimum: 1, - description: 'Maximum number of matches to return.', - }, - offset: { - type: 'integer', - minimum: 0, - description: 'Number of matches to skip for pagination.', - }, + "mode": { + "enum": [ + "strict", + "candidates" + ], + "description": "Search mode: 'strict' (default, exact matching) or 'candidates' (returns scored potential matches)." + }, + "includeNodes": { + "type": "boolean", + "description": "When true, includes full node data in results. Default: false." + }, + "limit": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of matches to return." + }, + "offset": { + "type": "integer", + "minimum": 0, + "description": "Number of matches to skip for pagination." + } }, - required: ['select'], - additionalProperties: false, + "required": [ + "select" + ], + "additionalProperties": false }, - mutates: false, - operations: [ + "mutates": false, + "operations": [ { - operationId: 'doc.query.match', - intentAction: 'match', - required: ['select'], - }, - ], + "operationId": "doc.query.match", + "intentAction": "match", + "required": [ + "select" + ] + } + ] }, { - toolName: 'superdoc_mutations', - description: - 'All steps succeed or all fail; no partial application. Execute multiple operations atomically in one batch. Use this for any workflow needing 2+ changes. Supported step types: text (text.rewrite, text.insert, text.delete), format (format.apply), create (create.heading, create.paragraph, create.table), assert. Each step has an id, an op, a "where" clause for targeting ({by:"select", select:{...}, require:"first"|"exactlyOne"|"all"} or {by:"ref", ref:"..."} or {by:"block", nodeType:"paragraph", nodeId:"..."}), and "args" with operation-specific parameters. Use {by:"block", nodeType, nodeId} when you want to rewrite, delete, format, or anchor against a whole known block from superdoc_get_content action "blocks" without relying on text matching. For full-paragraph or full-clause rewrites, first call superdoc_get_content with action:"blocks" and includeText:true, then rewrite the matching block by nodeId. Use {by:"select"} only for substring edits, discovery, or insertion relative to a sentence fragment; do NOT use a shortened text selector to replace an entire known block. For create steps, "where" targets an existing anchor block and args.position ("before" or "after") controls placement. Sequential creates targeting the same anchor maintain correct order via internal position mapping. For format.apply with require "all", use a node selector to format every heading or paragraph at once: {by:"select", select:{type:"node", nodeType:"heading"}, require:"all"}. Selectors resolve at compile time (before execution). This means format.apply steps CANNOT target content created by earlier create steps in the same batch. Split creates and formatting into separate batches: first a mutations call with creates, then a mutations call with format.apply. Action "preview" dry-runs the plan. Action "apply" executes it. If a selector matches nothing, the failure reports the step id plus selector details so you can retry with a shorter or more distinctive anchor. Do NOT create two steps that target overlapping text in the same block; combine them into a single text.rewrite step.\n\nEXAMPLES:\n 1. {"action":"apply","atomic":true,"changeMode":"direct","steps":[{"id":"s1","op":"text.rewrite","where":{"by":"select","select":{"type":"text","pattern":"old term"},"require":"all"},"args":{"replacement":{"text":"new term"}}},{"id":"s2","op":"text.delete","where":{"by":"select","select":{"type":"text","pattern":" (deprecated)"},"require":"all"},"args":{}}]}\n 2. {"action":"apply","steps":[{"id":"r1","op":"text.rewrite","where":{"by":"block","nodeType":"paragraph","nodeId":""},"args":{"replacement":{"text":"Updated clause text."}}},{"id":"f1","op":"format.apply","where":{"by":"select","select":{"type":"node","nodeType":"heading"},"require":"all"},"args":{"inline":{"color":"#FF0000"}}},{"id":"f2","op":"format.apply","where":{"by":"select","select":{"type":"text","pattern":"Confidential Information"},"require":"all"},"args":{"inline":{"bold":true}}}]}', - inputSchema: { - type: 'object', - properties: { - action: { - type: 'string', - enum: ['apply', 'preview'], - description: 'The action to perform. One of: apply, preview.', - }, - expectedRevision: { - type: 'string', - description: - "Document revision for optimistic concurrency. Mutation fails if document was modified since this revision. Only for action 'preview'. Omit for other actions.", - }, - atomic: { - const: true, - type: 'boolean', - description: 'Must be true. All steps execute as one atomic transaction.', - }, - changeMode: { - enum: ['direct', 'tracked'], - description: - "Required. Use 'direct' for immediate edits or 'tracked' for suggestions. Must always be provided.", - }, - steps: { - type: 'array', - items: { - oneOf: [ + "toolName": "superdoc_mutations", + "description": "All steps succeed or all fail; no partial application. Execute multiple operations atomically in one batch. Use this for any workflow needing 2+ changes. Supported step types: text (text.rewrite, text.insert, text.delete), format (format.apply), create (create.heading, create.paragraph, create.table), assert. Each step has an id, an op, a \"where\" clause for targeting ({by:\"select\", select:{...}, require:\"first\"|\"exactlyOne\"|\"all\"} or {by:\"ref\", ref:\"...\"} or {by:\"block\", nodeType:\"paragraph\", nodeId:\"...\"}), and \"args\" with operation-specific parameters. Use {by:\"block\", nodeType, nodeId} when you want to rewrite, delete, format, or anchor against a whole known block from superdoc_get_content action \"blocks\" without relying on text matching. For full-paragraph or full-clause rewrites, first call superdoc_get_content with action:\"blocks\" and includeText:true, then rewrite the matching block by nodeId. Use {by:\"select\"} only for substring edits, discovery, or insertion relative to a sentence fragment; do NOT use a shortened text selector to replace an entire known block. For create steps, \"where\" targets an existing anchor block and args.position (\"before\" or \"after\") controls placement. Sequential creates targeting the same anchor maintain correct order via internal position mapping. For format.apply with require \"all\", use a node selector to format every heading or paragraph at once: {by:\"select\", select:{type:\"node\", nodeType:\"heading\"}, require:\"all\"}. Selectors resolve at compile time (before execution). This means format.apply steps CANNOT target content created by earlier create steps in the same batch. Split creates and formatting into separate batches: first a mutations call with creates, then a mutations call with format.apply. Action \"preview\" dry-runs the plan. Action \"apply\" executes it. If a selector matches nothing, the failure reports the step id plus selector details so you can retry with a shorter or more distinctive anchor. Do NOT create two steps that target overlapping text in the same block; combine them into a single text.rewrite step.\n\nEXAMPLES:\n 1. {\"action\":\"apply\",\"atomic\":true,\"changeMode\":\"direct\",\"steps\":[{\"id\":\"s1\",\"op\":\"text.rewrite\",\"where\":{\"by\":\"select\",\"select\":{\"type\":\"text\",\"pattern\":\"old term\"},\"require\":\"all\"},\"args\":{\"replacement\":{\"text\":\"new term\"}}},{\"id\":\"s2\",\"op\":\"text.delete\",\"where\":{\"by\":\"select\",\"select\":{\"type\":\"text\",\"pattern\":\" (deprecated)\"},\"require\":\"all\"},\"args\":{}}]}\n 2. {\"action\":\"apply\",\"steps\":[{\"id\":\"r1\",\"op\":\"text.rewrite\",\"where\":{\"by\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"},\"args\":{\"replacement\":{\"text\":\"Updated clause text.\"}}},{\"id\":\"f1\",\"op\":\"format.apply\",\"where\":{\"by\":\"select\",\"select\":{\"type\":\"node\",\"nodeType\":\"heading\"},\"require\":\"all\"},\"args\":{\"inline\":{\"color\":\"#FF0000\"}}},{\"id\":\"f2\",\"op\":\"format.apply\",\"where\":{\"by\":\"select\",\"select\":{\"type\":\"text\",\"pattern\":\"Confidential Information\"},\"require\":\"all\"},\"args\":{\"inline\":{\"bold\":true}}}]}", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "apply", + "preview" + ], + "description": "The action to perform. One of: apply, preview." + }, + "expectedRevision": { + "type": "string", + "description": "Document revision for optimistic concurrency. Mutation fails if document was modified since this revision. Only for action 'preview'. Omit for other actions." + }, + "atomic": { + "const": true, + "type": "boolean", + "description": "Must be true. All steps execute as one atomic transaction." + }, + "changeMode": { + "enum": [ + "direct", + "tracked" + ], + "description": "Required. Use 'direct' for immediate edits or 'tracked' for suggestions. Must always be provided." + }, + "steps": { + "type": "array", + "items": { + "oneOf": [ { - type: 'object', - properties: { - id: { - type: 'string', + "type": "object", + "properties": { + "id": { + "type": "string" }, - op: { - const: 'text.rewrite', - type: 'string', + "op": { + "const": "text.rewrite", + "type": "string" }, - where: { - oneOf: [ + "where": { + "oneOf": [ { - type: 'object', - properties: { - by: { - const: 'select', - type: 'string', + "type": "object", + "properties": { + "by": { + "const": "select", + "type": "string" }, - select: { - oneOf: [ + "select": { + "oneOf": [ { - type: 'object', - properties: { - type: { - const: 'text', - description: "Must be 'text' for text pattern search.", - type: 'string', + "type": "object", + "properties": { + "type": { + "const": "text", + "description": "Must be 'text' for text pattern search.", + "type": "string" }, - pattern: { - type: 'string', - description: 'Text or regex pattern to match.', + "pattern": { + "type": "string", + "description": "Text or regex pattern to match." }, - mode: { - enum: ['contains', 'regex'], - description: "Match mode: 'contains' (substring) or 'regex'.", - }, - caseSensitive: { - type: 'boolean', - description: 'Case-sensitive matching. Default: false.', + "mode": { + "enum": [ + "contains", + "regex" + ], + "description": "Match mode: 'contains' (substring) or 'regex'." }, + "caseSensitive": { + "type": "boolean", + "description": "Case-sensitive matching. Default: false." + } }, - additionalProperties: false, - required: ['type', 'pattern'], + "additionalProperties": false, + "required": [ + "type", + "pattern" + ] }, { - type: 'object', - properties: { - type: { - const: 'node', - description: "Must be 'node' for node type search.", - type: 'string', + "type": "object", + "properties": { + "type": { + "const": "node", + "description": "Must be 'node' for node type search.", + "type": "string" }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - 'run', - 'bookmark', - 'comment', - 'hyperlink', - 'footnoteRef', - 'endnoteRef', - 'crossRef', - 'indexEntry', - 'citation', - 'authorityEntry', - 'sequenceField', - 'tab', - 'lineBreak', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "endnoteRef", + "crossRef", + "indexEntry", + "citation", + "authorityEntry", + "sequenceField", + "tab", + "lineBreak" ], - description: 'Block type to match (paragraph, heading, table, listItem, etc.).', - }, - kind: { - enum: ['block', 'inline'], - description: "Filter: 'block' or 'inline'.", + "description": "Block type to match (paragraph, heading, table, listItem, etc.)." }, + "kind": { + "enum": [ + "block", + "inline" + ], + "description": "Filter: 'block' or 'inline'." + } }, - additionalProperties: false, - required: ['type'], - }, - ], + "additionalProperties": false, + "required": [ + "type" + ] + } + ] }, - within: { - $ref: '#/$defs/BlockNodeAddress', - }, - require: { - enum: ['first', 'exactlyOne', 'all'], + "within": { + "$ref": "#/$defs/BlockNodeAddress" }, + "require": { + "enum": [ + "first", + "exactlyOne", + "all" + ] + } }, - additionalProperties: false, - required: ['by', 'select', 'require'], + "additionalProperties": false, + "required": [ + "by", + "select", + "require" + ] }, { - type: 'object', - properties: { - by: { - const: 'ref', - type: 'string', - }, - ref: { - type: 'string', + "type": "object", + "properties": { + "by": { + "const": "ref", + "type": "string" }, - within: { - $ref: '#/$defs/BlockNodeAddress', + "ref": { + "type": "string" }, + "within": { + "$ref": "#/$defs/BlockNodeAddress" + } }, - additionalProperties: false, - required: ['by', 'ref'], + "additionalProperties": false, + "required": [ + "by", + "ref" + ] }, { - type: 'object', - properties: { - by: { - const: 'target', - type: 'string', - }, - target: { - $ref: '#/$defs/SelectionTarget', + "type": "object", + "properties": { + "by": { + "const": "target", + "type": "string" }, + "target": { + "$ref": "#/$defs/SelectionTarget" + } }, - additionalProperties: false, - required: ['by', 'target'], + "additionalProperties": false, + "required": [ + "by", + "target" + ] }, { - type: 'object', - properties: { - by: { - const: 'block', - type: 'string', - }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - ], + "type": "object", + "properties": { + "by": { + "const": "block", + "type": "string" }, - nodeId: { - type: 'string', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt" + ] }, + "nodeId": { + "type": "string" + } }, - additionalProperties: false, - required: ['by', 'nodeType', 'nodeId'], - }, - ], + "additionalProperties": false, + "required": [ + "by", + "nodeType", + "nodeId" + ] + } + ] }, - args: { - type: 'object', - properties: { - replacement: { - oneOf: [ + "args": { + "type": "object", + "properties": { + "replacement": { + "oneOf": [ { - type: 'object', - properties: { - text: { - type: 'string', - }, + "type": "object", + "properties": { + "text": { + "type": "string" + } }, - additionalProperties: false, - required: ['text'], + "additionalProperties": false, + "required": [ + "text" + ] }, { - type: 'object', - properties: { - blocks: { - type: 'array', - items: { - type: 'object', - properties: { - text: { - type: 'string', - }, + "type": "object", + "properties": { + "blocks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "text": { + "type": "string" + } }, - additionalProperties: false, - required: ['text'], - }, - }, + "additionalProperties": false, + "required": [ + "text" + ] + } + } }, - additionalProperties: false, - required: ['blocks'], - }, - ], + "additionalProperties": false, + "required": [ + "blocks" + ] + } + ] }, - style: { - type: 'object', - properties: { - inline: { - type: 'object', - properties: { - mode: { - enum: ['preserve', 'set', 'clear', 'merge'], - type: 'string', + "style": { + "type": "object", + "properties": { + "inline": { + "type": "object", + "properties": { + "mode": { + "enum": [ + "preserve", + "set", + "clear", + "merge" + ], + "type": "string" }, - requireUniform: { - type: 'boolean', + "requireUniform": { + "type": "boolean" }, - onNonUniform: { - enum: ['error', 'useLeadingRun', 'majority', 'union'], + "onNonUniform": { + "enum": [ + "error", + "useLeadingRun", + "majority", + "union" + ] }, - setMarks: { - type: 'object', - properties: { - bold: { - enum: ['on', 'off', 'clear'], - }, - italic: { - enum: ['on', 'off', 'clear'], + "setMarks": { + "type": "object", + "properties": { + "bold": { + "enum": [ + "on", + "off", + "clear" + ] }, - underline: { - enum: ['on', 'off', 'clear'], + "italic": { + "enum": [ + "on", + "off", + "clear" + ] }, - strike: { - enum: ['on', 'off', 'clear'], + "underline": { + "enum": [ + "on", + "off", + "clear" + ] }, + "strike": { + "enum": [ + "on", + "off", + "clear" + ] + } }, - additionalProperties: false, - }, + "additionalProperties": false + } }, - additionalProperties: false, - required: ['mode'], + "additionalProperties": false, + "required": [ + "mode" + ] }, - paragraph: { - type: 'object', - properties: { - mode: { - enum: ['preserve', 'set', 'clear'], - type: 'string', - }, + "paragraph": { + "type": "object", + "properties": { + "mode": { + "enum": [ + "preserve", + "set", + "clear" + ], + "type": "string" + } }, - additionalProperties: false, - required: ['mode'], - }, + "additionalProperties": false, + "required": [ + "mode" + ] + } }, - additionalProperties: false, - required: ['inline'], - }, + "additionalProperties": false, + "required": [ + "inline" + ] + } }, - additionalProperties: false, - required: ['replacement'], - }, - }, - additionalProperties: false, - required: ['id', 'op', 'where', 'args'], + "additionalProperties": false, + "required": [ + "replacement" + ] + } + }, + "additionalProperties": false, + "required": [ + "id", + "op", + "where", + "args" + ] }, { - type: 'object', - properties: { - id: { - type: 'string', + "type": "object", + "properties": { + "id": { + "type": "string" }, - op: { - const: 'text.insert', - type: 'string', + "op": { + "const": "text.insert", + "type": "string" }, - where: { - oneOf: [ + "where": { + "oneOf": [ { - type: 'object', - properties: { - by: { - const: 'select', - type: 'string', + "type": "object", + "properties": { + "by": { + "const": "select", + "type": "string" }, - select: { - oneOf: [ + "select": { + "oneOf": [ { - type: 'object', - properties: { - type: { - const: 'text', - description: "Must be 'text' for text pattern search.", - type: 'string', + "type": "object", + "properties": { + "type": { + "const": "text", + "description": "Must be 'text' for text pattern search.", + "type": "string" }, - pattern: { - type: 'string', - description: 'Text or regex pattern to match.', + "pattern": { + "type": "string", + "description": "Text or regex pattern to match." }, - mode: { - enum: ['contains', 'regex'], - description: "Match mode: 'contains' (substring) or 'regex'.", - }, - caseSensitive: { - type: 'boolean', - description: 'Case-sensitive matching. Default: false.', + "mode": { + "enum": [ + "contains", + "regex" + ], + "description": "Match mode: 'contains' (substring) or 'regex'." }, + "caseSensitive": { + "type": "boolean", + "description": "Case-sensitive matching. Default: false." + } }, - additionalProperties: false, - required: ['type', 'pattern'], + "additionalProperties": false, + "required": [ + "type", + "pattern" + ] }, { - type: 'object', - properties: { - type: { - const: 'node', - description: "Must be 'node' for node type search.", - type: 'string', + "type": "object", + "properties": { + "type": { + "const": "node", + "description": "Must be 'node' for node type search.", + "type": "string" }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - 'run', - 'bookmark', - 'comment', - 'hyperlink', - 'footnoteRef', - 'endnoteRef', - 'crossRef', - 'indexEntry', - 'citation', - 'authorityEntry', - 'sequenceField', - 'tab', - 'lineBreak', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "endnoteRef", + "crossRef", + "indexEntry", + "citation", + "authorityEntry", + "sequenceField", + "tab", + "lineBreak" ], - description: 'Block type to match (paragraph, heading, table, listItem, etc.).', - }, - kind: { - enum: ['block', 'inline'], - description: "Filter: 'block' or 'inline'.", + "description": "Block type to match (paragraph, heading, table, listItem, etc.)." }, + "kind": { + "enum": [ + "block", + "inline" + ], + "description": "Filter: 'block' or 'inline'." + } }, - additionalProperties: false, - required: ['type'], - }, - ], - }, - within: { - $ref: '#/$defs/BlockNodeAddress', + "additionalProperties": false, + "required": [ + "type" + ] + } + ] }, - require: { - enum: ['first', 'exactlyOne'], + "within": { + "$ref": "#/$defs/BlockNodeAddress" }, + "require": { + "enum": [ + "first", + "exactlyOne" + ] + } }, - additionalProperties: false, - required: ['by', 'select', 'require'], + "additionalProperties": false, + "required": [ + "by", + "select", + "require" + ] }, { - type: 'object', - properties: { - by: { - const: 'ref', - type: 'string', - }, - ref: { - type: 'string', + "type": "object", + "properties": { + "by": { + "const": "ref", + "type": "string" }, - within: { - $ref: '#/$defs/BlockNodeAddress', + "ref": { + "type": "string" }, + "within": { + "$ref": "#/$defs/BlockNodeAddress" + } }, - additionalProperties: false, - required: ['by', 'ref'], + "additionalProperties": false, + "required": [ + "by", + "ref" + ] }, { - type: 'object', - properties: { - by: { - const: 'target', - type: 'string', - }, - target: { - $ref: '#/$defs/SelectionTarget', + "type": "object", + "properties": { + "by": { + "const": "target", + "type": "string" }, + "target": { + "$ref": "#/$defs/SelectionTarget" + } }, - additionalProperties: false, - required: ['by', 'target'], + "additionalProperties": false, + "required": [ + "by", + "target" + ] }, { - type: 'object', - properties: { - by: { - const: 'block', - type: 'string', - }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - ], + "type": "object", + "properties": { + "by": { + "const": "block", + "type": "string" }, - nodeId: { - type: 'string', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt" + ] }, + "nodeId": { + "type": "string" + } }, - additionalProperties: false, - required: ['by', 'nodeType', 'nodeId'], - }, - ], + "additionalProperties": false, + "required": [ + "by", + "nodeType", + "nodeId" + ] + } + ] }, - args: { - type: 'object', - properties: { - position: { - enum: ['before', 'after'], + "args": { + "type": "object", + "properties": { + "position": { + "enum": [ + "before", + "after" + ] }, - content: { - type: 'object', - properties: { - text: { - type: 'string', - }, + "content": { + "type": "object", + "properties": { + "text": { + "type": "string" + } }, - additionalProperties: false, - required: ['text'], + "additionalProperties": false, + "required": [ + "text" + ] }, - style: { - type: 'object', - properties: { - inline: { - type: 'object', - properties: { - mode: { - enum: ['inherit', 'set', 'clear'], - type: 'string', + "style": { + "type": "object", + "properties": { + "inline": { + "type": "object", + "properties": { + "mode": { + "enum": [ + "inherit", + "set", + "clear" + ], + "type": "string" }, - setMarks: { - type: 'object', - properties: { - bold: { - enum: ['on', 'off', 'clear'], - }, - italic: { - enum: ['on', 'off', 'clear'], + "setMarks": { + "type": "object", + "properties": { + "bold": { + "enum": [ + "on", + "off", + "clear" + ] }, - underline: { - enum: ['on', 'off', 'clear'], + "italic": { + "enum": [ + "on", + "off", + "clear" + ] }, - strike: { - enum: ['on', 'off', 'clear'], + "underline": { + "enum": [ + "on", + "off", + "clear" + ] }, + "strike": { + "enum": [ + "on", + "off", + "clear" + ] + } }, - additionalProperties: false, - }, + "additionalProperties": false + } }, - additionalProperties: false, - required: ['mode'], - }, + "additionalProperties": false, + "required": [ + "mode" + ] + } }, - additionalProperties: false, - required: ['inline'], - }, + "additionalProperties": false, + "required": [ + "inline" + ] + } }, - additionalProperties: false, - required: ['position', 'content'], - }, - }, - additionalProperties: false, - required: ['id', 'op', 'where', 'args'], + "additionalProperties": false, + "required": [ + "position", + "content" + ] + } + }, + "additionalProperties": false, + "required": [ + "id", + "op", + "where", + "args" + ] }, { - type: 'object', - properties: { - id: { - type: 'string', + "type": "object", + "properties": { + "id": { + "type": "string" }, - op: { - const: 'text.delete', - type: 'string', + "op": { + "const": "text.delete", + "type": "string" }, - where: { - oneOf: [ + "where": { + "oneOf": [ { - type: 'object', - properties: { - by: { - const: 'select', - type: 'string', + "type": "object", + "properties": { + "by": { + "const": "select", + "type": "string" }, - select: { - oneOf: [ + "select": { + "oneOf": [ { - type: 'object', - properties: { - type: { - const: 'text', - description: "Must be 'text' for text pattern search.", - type: 'string', - }, - pattern: { - type: 'string', - description: 'Text or regex pattern to match.', + "type": "object", + "properties": { + "type": { + "const": "text", + "description": "Must be 'text' for text pattern search.", + "type": "string" }, - mode: { - enum: ['contains', 'regex'], - description: "Match mode: 'contains' (substring) or 'regex'.", + "pattern": { + "type": "string", + "description": "Text or regex pattern to match." }, - caseSensitive: { - type: 'boolean', - description: 'Case-sensitive matching. Default: false.', + "mode": { + "enum": [ + "contains", + "regex" + ], + "description": "Match mode: 'contains' (substring) or 'regex'." }, + "caseSensitive": { + "type": "boolean", + "description": "Case-sensitive matching. Default: false." + } }, - additionalProperties: false, - required: ['type', 'pattern'], + "additionalProperties": false, + "required": [ + "type", + "pattern" + ] }, { - type: 'object', - properties: { - type: { - const: 'node', - description: "Must be 'node' for node type search.", - type: 'string', + "type": "object", + "properties": { + "type": { + "const": "node", + "description": "Must be 'node' for node type search.", + "type": "string" }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - 'run', - 'bookmark', - 'comment', - 'hyperlink', - 'footnoteRef', - 'endnoteRef', - 'crossRef', - 'indexEntry', - 'citation', - 'authorityEntry', - 'sequenceField', - 'tab', - 'lineBreak', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "endnoteRef", + "crossRef", + "indexEntry", + "citation", + "authorityEntry", + "sequenceField", + "tab", + "lineBreak" ], - description: 'Block type to match (paragraph, heading, table, listItem, etc.).', - }, - kind: { - enum: ['block', 'inline'], - description: "Filter: 'block' or 'inline'.", + "description": "Block type to match (paragraph, heading, table, listItem, etc.)." }, + "kind": { + "enum": [ + "block", + "inline" + ], + "description": "Filter: 'block' or 'inline'." + } }, - additionalProperties: false, - required: ['type'], - }, - ], - }, - within: { - $ref: '#/$defs/BlockNodeAddress', + "additionalProperties": false, + "required": [ + "type" + ] + } + ] }, - require: { - enum: ['first', 'exactlyOne', 'all'], + "within": { + "$ref": "#/$defs/BlockNodeAddress" }, + "require": { + "enum": [ + "first", + "exactlyOne", + "all" + ] + } }, - additionalProperties: false, - required: ['by', 'select', 'require'], + "additionalProperties": false, + "required": [ + "by", + "select", + "require" + ] }, { - type: 'object', - properties: { - by: { - const: 'ref', - type: 'string', + "type": "object", + "properties": { + "by": { + "const": "ref", + "type": "string" }, - ref: { - type: 'string', - }, - within: { - $ref: '#/$defs/BlockNodeAddress', + "ref": { + "type": "string" }, + "within": { + "$ref": "#/$defs/BlockNodeAddress" + } }, - additionalProperties: false, - required: ['by', 'ref'], + "additionalProperties": false, + "required": [ + "by", + "ref" + ] }, { - type: 'object', - properties: { - by: { - const: 'target', - type: 'string', - }, - target: { - $ref: '#/$defs/SelectionTarget', + "type": "object", + "properties": { + "by": { + "const": "target", + "type": "string" }, + "target": { + "$ref": "#/$defs/SelectionTarget" + } }, - additionalProperties: false, - required: ['by', 'target'], + "additionalProperties": false, + "required": [ + "by", + "target" + ] }, { - type: 'object', - properties: { - by: { - const: 'block', - type: 'string', - }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - ], + "type": "object", + "properties": { + "by": { + "const": "block", + "type": "string" }, - nodeId: { - type: 'string', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt" + ] }, + "nodeId": { + "type": "string" + } }, - additionalProperties: false, - required: ['by', 'nodeType', 'nodeId'], - }, - ], + "additionalProperties": false, + "required": [ + "by", + "nodeType", + "nodeId" + ] + } + ] }, - args: { - type: 'object', - properties: { - behavior: { - $ref: '#/$defs/DeleteBehavior', - }, + "args": { + "type": "object", + "properties": { + "behavior": { + "$ref": "#/$defs/DeleteBehavior" + } }, - additionalProperties: false, - }, - }, - additionalProperties: false, - required: ['id', 'op', 'where', 'args'], + "additionalProperties": false + } + }, + "additionalProperties": false, + "required": [ + "id", + "op", + "where", + "args" + ] }, { - type: 'object', - properties: { - id: { - type: 'string', + "type": "object", + "properties": { + "id": { + "type": "string" }, - op: { - const: 'format.apply', - type: 'string', + "op": { + "const": "format.apply", + "type": "string" }, - where: { - oneOf: [ + "where": { + "oneOf": [ { - type: 'object', - properties: { - by: { - const: 'select', - type: 'string', + "type": "object", + "properties": { + "by": { + "const": "select", + "type": "string" }, - select: { - oneOf: [ + "select": { + "oneOf": [ { - type: 'object', - properties: { - type: { - const: 'text', - description: "Must be 'text' for text pattern search.", - type: 'string', - }, - pattern: { - type: 'string', - description: 'Text or regex pattern to match.', + "type": "object", + "properties": { + "type": { + "const": "text", + "description": "Must be 'text' for text pattern search.", + "type": "string" }, - mode: { - enum: ['contains', 'regex'], - description: "Match mode: 'contains' (substring) or 'regex'.", + "pattern": { + "type": "string", + "description": "Text or regex pattern to match." }, - caseSensitive: { - type: 'boolean', - description: 'Case-sensitive matching. Default: false.', + "mode": { + "enum": [ + "contains", + "regex" + ], + "description": "Match mode: 'contains' (substring) or 'regex'." }, + "caseSensitive": { + "type": "boolean", + "description": "Case-sensitive matching. Default: false." + } }, - additionalProperties: false, - required: ['type', 'pattern'], + "additionalProperties": false, + "required": [ + "type", + "pattern" + ] }, { - type: 'object', - properties: { - type: { - const: 'node', - description: "Must be 'node' for node type search.", - type: 'string', + "type": "object", + "properties": { + "type": { + "const": "node", + "description": "Must be 'node' for node type search.", + "type": "string" }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - 'run', - 'bookmark', - 'comment', - 'hyperlink', - 'footnoteRef', - 'endnoteRef', - 'crossRef', - 'indexEntry', - 'citation', - 'authorityEntry', - 'sequenceField', - 'tab', - 'lineBreak', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "endnoteRef", + "crossRef", + "indexEntry", + "citation", + "authorityEntry", + "sequenceField", + "tab", + "lineBreak" ], - description: 'Block type to match (paragraph, heading, table, listItem, etc.).', - }, - kind: { - enum: ['block', 'inline'], - description: "Filter: 'block' or 'inline'.", + "description": "Block type to match (paragraph, heading, table, listItem, etc.)." }, + "kind": { + "enum": [ + "block", + "inline" + ], + "description": "Filter: 'block' or 'inline'." + } }, - additionalProperties: false, - required: ['type'], - }, - ], + "additionalProperties": false, + "required": [ + "type" + ] + } + ] }, - within: { - $ref: '#/$defs/BlockNodeAddress', - }, - require: { - enum: ['first', 'exactlyOne', 'all'], + "within": { + "$ref": "#/$defs/BlockNodeAddress" }, + "require": { + "enum": [ + "first", + "exactlyOne", + "all" + ] + } }, - additionalProperties: false, - required: ['by', 'select', 'require'], + "additionalProperties": false, + "required": [ + "by", + "select", + "require" + ] }, { - type: 'object', - properties: { - by: { - const: 'ref', - type: 'string', - }, - ref: { - type: 'string', + "type": "object", + "properties": { + "by": { + "const": "ref", + "type": "string" }, - within: { - $ref: '#/$defs/BlockNodeAddress', + "ref": { + "type": "string" }, + "within": { + "$ref": "#/$defs/BlockNodeAddress" + } }, - additionalProperties: false, - required: ['by', 'ref'], + "additionalProperties": false, + "required": [ + "by", + "ref" + ] }, { - type: 'object', - properties: { - by: { - const: 'target', - type: 'string', - }, - target: { - $ref: '#/$defs/SelectionTarget', + "type": "object", + "properties": { + "by": { + "const": "target", + "type": "string" }, + "target": { + "$ref": "#/$defs/SelectionTarget" + } }, - additionalProperties: false, - required: ['by', 'target'], + "additionalProperties": false, + "required": [ + "by", + "target" + ] }, { - type: 'object', - properties: { - by: { - const: 'block', - type: 'string', - }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - ], + "type": "object", + "properties": { + "by": { + "const": "block", + "type": "string" }, - nodeId: { - type: 'string', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt" + ] }, + "nodeId": { + "type": "string" + } }, - additionalProperties: false, - required: ['by', 'nodeType', 'nodeId'], - }, - ], + "additionalProperties": false, + "required": [ + "by", + "nodeType", + "nodeId" + ] + } + ] }, - args: { - type: 'object', - properties: { - inline: { - type: 'object', - properties: { - bold: { - oneOf: [ + "args": { + "type": "object", + "properties": { + "inline": { + "type": "object", + "properties": { + "bold": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - italic: { - oneOf: [ + "italic": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - strike: { - oneOf: [ + "strike": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - underline: { - oneOf: [ + "underline": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', + "type": "null" }, { - type: 'object', - properties: { - style: { - oneOf: [ + "type": "object", + "properties": { + "style": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - color: { - oneOf: [ + "color": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - themeColor: { - oneOf: [ + "themeColor": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, - }, - ], + "additionalProperties": false, + "minProperties": 1 + } + ] }, - highlight: { - oneOf: [ + "highlight": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - color: { - oneOf: [ + "color": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - fontSize: { - oneOf: [ + "fontSize": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - fontFamily: { - oneOf: [ + "fontFamily": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - letterSpacing: { - oneOf: [ + "letterSpacing": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - vertAlign: { - oneOf: [ + "vertAlign": { + "oneOf": [ { - enum: ['superscript', 'subscript', 'baseline'], + "enum": [ + "superscript", + "subscript", + "baseline" + ] }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - position: { - oneOf: [ + "position": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - dstrike: { - oneOf: [ + "dstrike": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - smallCaps: { - oneOf: [ + "smallCaps": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - caps: { - oneOf: [ + "caps": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - shading: { - oneOf: [ + "shading": { + "oneOf": [ { - type: 'object', - properties: { - fill: { - oneOf: [ + "type": "object", + "properties": { + "fill": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - color: { - oneOf: [ + "color": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - val: { - oneOf: [ + "val": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - border: { - oneOf: [ + "border": { + "oneOf": [ { - type: 'object', - properties: { - val: { - oneOf: [ + "type": "object", + "properties": { + "val": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - sz: { - oneOf: [ + "sz": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - color: { - oneOf: [ + "color": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - space: { - oneOf: [ + "space": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - outline: { - oneOf: [ + "outline": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - shadow: { - oneOf: [ + "shadow": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - emboss: { - oneOf: [ + "emboss": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - imprint: { - oneOf: [ + "imprint": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - charScale: { - oneOf: [ + "charScale": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - kerning: { - oneOf: [ + "kerning": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - vanish: { - oneOf: [ + "vanish": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - webHidden: { - oneOf: [ + "webHidden": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - specVanish: { - oneOf: [ + "specVanish": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - rtl: { - oneOf: [ + "rtl": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - cs: { - oneOf: [ + "cs": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - bCs: { - oneOf: [ + "bCs": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - iCs: { - oneOf: [ + "iCs": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - eastAsianLayout: { - oneOf: [ + "eastAsianLayout": { + "oneOf": [ { - type: 'object', - properties: { - id: { - oneOf: [ + "type": "object", + "properties": { + "id": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - combine: { - oneOf: [ + "combine": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - combineBrackets: { - oneOf: [ + "combineBrackets": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - vert: { - oneOf: [ + "vert": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - vertCompress: { - oneOf: [ + "vertCompress": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - em: { - oneOf: [ + "em": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - fitText: { - oneOf: [ + "fitText": { + "oneOf": [ { - type: 'object', - properties: { - val: { - oneOf: [ + "type": "object", + "properties": { + "val": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - id: { - oneOf: [ + "id": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - snapToGrid: { - oneOf: [ + "snapToGrid": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - lang: { - oneOf: [ + "lang": { + "oneOf": [ { - type: 'object', - properties: { - val: { - oneOf: [ + "type": "object", + "properties": { + "val": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - eastAsia: { - oneOf: [ + "eastAsia": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - bidi: { - oneOf: [ + "bidi": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - oMath: { - oneOf: [ + "oMath": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - rStyle: { - oneOf: [ + "rStyle": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - rFonts: { - oneOf: [ + "rFonts": { + "oneOf": [ { - type: 'object', - properties: { - ascii: { - oneOf: [ + "type": "object", + "properties": { + "ascii": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - hAnsi: { - oneOf: [ + "hAnsi": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - eastAsia: { - oneOf: [ + "eastAsia": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - cs: { - oneOf: [ + "cs": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - asciiTheme: { - oneOf: [ + "asciiTheme": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - hAnsiTheme: { - oneOf: [ + "hAnsiTheme": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - eastAsiaTheme: { - oneOf: [ + "eastAsiaTheme": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - csTheme: { - oneOf: [ + "csTheme": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - hint: { - oneOf: [ + "hint": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, + "additionalProperties": false, + "minProperties": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - fontSizeCs: { - oneOf: [ + "fontSizeCs": { + "oneOf": [ { - type: 'number', + "type": "number" }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - ligatures: { - oneOf: [ + "ligatures": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - numForm: { - oneOf: [ + "numForm": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - numSpacing: { - oneOf: [ + "numSpacing": { + "oneOf": [ { - type: 'string', - minLength: 1, + "type": "string", + "minLength": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - stylisticSets: { - oneOf: [ + "stylisticSets": { + "oneOf": [ { - type: 'array', - items: { - type: 'object', - properties: { - id: { - type: 'number', - }, - val: { - type: 'boolean', + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number" }, + "val": { + "type": "boolean" + } }, - required: ['id'], - additionalProperties: false, + "required": [ + "id" + ], + "additionalProperties": false }, - minItems: 1, + "minItems": 1 }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - contextualAlternates: { - oneOf: [ + "contextualAlternates": { + "oneOf": [ { - type: 'boolean', + "type": "boolean" }, { - type: 'null', - }, - ], - }, + "type": "null" + } + ] + } }, - additionalProperties: false, - minProperties: 1, - }, - alignment: { - type: 'string', - enum: ['left', 'center', 'right', 'justify'], - description: - 'Set paragraph alignment on the target block(s). Can be combined with inline formatting in the same step.', + "additionalProperties": false, + "minProperties": 1 }, - scope: { - type: 'string', - enum: ['match', 'block'], - description: - 'When "block", inline formatting expands to cover the entire parent paragraph(s), not just the matched text. Use "block" after markdown inserts to format whole paragraphs with a short identifying pattern. Default: "match".', + "alignment": { + "type": "string", + "enum": [ + "left", + "center", + "right", + "justify" + ], + "description": "Set paragraph alignment on the target block(s). Can be combined with inline formatting in the same step." }, + "scope": { + "type": "string", + "enum": [ + "match", + "block" + ], + "description": "When \"block\", inline formatting expands to cover the entire parent paragraph(s), not just the matched text. Use \"block\" after markdown inserts to format whole paragraphs with a short identifying pattern. Default: \"match\"." + } }, - additionalProperties: false, - minProperties: 1, - }, - }, - additionalProperties: false, - required: ['id', 'op', 'where', 'args'], + "additionalProperties": false, + "minProperties": 1 + } + }, + "additionalProperties": false, + "required": [ + "id", + "op", + "where", + "args" + ] }, { - type: 'object', - properties: { - id: { - type: 'string', + "type": "object", + "properties": { + "id": { + "type": "string" }, - op: { - const: 'assert', - type: 'string', + "op": { + "const": "assert", + "type": "string" }, - where: { - type: 'object', - properties: { - by: { - const: 'select', - type: 'string', + "where": { + "type": "object", + "properties": { + "by": { + "const": "select", + "type": "string" }, - select: { - oneOf: [ + "select": { + "oneOf": [ { - type: 'object', - properties: { - type: { - const: 'text', - description: "Must be 'text' for text pattern search.", - type: 'string', + "type": "object", + "properties": { + "type": { + "const": "text", + "description": "Must be 'text' for text pattern search.", + "type": "string" }, - pattern: { - type: 'string', - description: 'Text or regex pattern to match.', + "pattern": { + "type": "string", + "description": "Text or regex pattern to match." }, - mode: { - enum: ['contains', 'regex'], - description: "Match mode: 'contains' (substring) or 'regex'.", - }, - caseSensitive: { - type: 'boolean', - description: 'Case-sensitive matching. Default: false.', + "mode": { + "enum": [ + "contains", + "regex" + ], + "description": "Match mode: 'contains' (substring) or 'regex'." }, + "caseSensitive": { + "type": "boolean", + "description": "Case-sensitive matching. Default: false." + } }, - additionalProperties: false, - required: ['type', 'pattern'], + "additionalProperties": false, + "required": [ + "type", + "pattern" + ] }, { - type: 'object', - properties: { - type: { - const: 'node', - description: "Must be 'node' for node type search.", - type: 'string', + "type": "object", + "properties": { + "type": { + "const": "node", + "description": "Must be 'node' for node type search.", + "type": "string" }, - nodeType: { - enum: [ - 'paragraph', - 'heading', - 'listItem', - 'table', - 'tableRow', - 'tableCell', - 'tableOfContents', - 'image', - 'sdt', - 'run', - 'bookmark', - 'comment', - 'hyperlink', - 'footnoteRef', - 'endnoteRef', - 'crossRef', - 'indexEntry', - 'citation', - 'authorityEntry', - 'sequenceField', - 'tab', - 'lineBreak', + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "tableOfContents", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "endnoteRef", + "crossRef", + "indexEntry", + "citation", + "authorityEntry", + "sequenceField", + "tab", + "lineBreak" ], - description: 'Block type to match (paragraph, heading, table, listItem, etc.).', - }, - kind: { - enum: ['block', 'inline'], - description: "Filter: 'block' or 'inline'.", + "description": "Block type to match (paragraph, heading, table, listItem, etc.)." }, + "kind": { + "enum": [ + "block", + "inline" + ], + "description": "Filter: 'block' or 'inline'." + } }, - additionalProperties: false, - required: ['type'], - }, - ], - }, - within: { - $ref: '#/$defs/BlockNodeAddress', + "additionalProperties": false, + "required": [ + "type" + ] + } + ] }, + "within": { + "$ref": "#/$defs/BlockNodeAddress" + } }, - additionalProperties: false, - required: ['by', 'select'], + "additionalProperties": false, + "required": [ + "by", + "select" + ] }, - args: { - type: 'object', - properties: { - expectCount: { - type: 'number', - }, + "args": { + "type": "object", + "properties": { + "expectCount": { + "type": "number" + } }, - additionalProperties: false, - required: ['expectCount'], - }, - }, - additionalProperties: false, - required: ['id', 'op', 'where', 'args'], - }, - ], + "additionalProperties": false, + "required": [ + "expectCount" + ] + } + }, + "additionalProperties": false, + "required": [ + "id", + "op", + "where", + "args" + ] + } + ] }, - description: - "Ordered array of mutation steps. Each step needs 'op' (text.rewrite, text.insert, text.delete, format.apply, or assert) and a 'where' targeting clause.", - }, - force: { - type: 'boolean', - description: "Bypass confirmation checks. Only for action 'apply'. Omit for other actions.", + "description": "Ordered array of mutation steps. Each step needs 'op' (text.rewrite, text.insert, text.delete, format.apply, or assert) and a 'where' targeting clause." }, + "force": { + "type": "boolean", + "description": "Bypass confirmation checks. Only for action 'apply'. Omit for other actions." + } }, - required: ['action', 'atomic', 'changeMode', 'steps'], - additionalProperties: false, + "required": [ + "action", + "atomic", + "changeMode", + "steps" + ], + "additionalProperties": false }, - mutates: true, - operations: [ + "mutates": true, + "operations": [ { - operationId: 'doc.mutations.preview', - intentAction: 'preview', - required: ['atomic', 'changeMode', 'steps'], + "operationId": "doc.mutations.preview", + "intentAction": "preview", + "required": [ + "atomic", + "changeMode", + "steps" + ] }, { - operationId: 'doc.mutations.apply', - intentAction: 'apply', - required: ['atomic', 'steps', 'changeMode'], - }, - ], + "operationId": "doc.mutations.apply", + "intentAction": "apply", + "required": [ + "atomic", + "steps", + "changeMode" + ] + } + ] }, { - toolName: 'superdoc_table', - description: - 'Create and modify table structure, content, and styling. Find table/row/cell nodeIds via superdoc_get_content({action:"blocks"}) or superdoc_search.\n\nACTIONS:\n• Structure: delete, insert_row, delete_row, insert_column, delete_column, merge_cells, unmerge_cells.\n• Cell content: set_cell_text (text). set_cell (vAlign / wrap / fit / preferred width).\n• Row / column: set_row (height + rule), set_row_options (repeat-header, allow-break), set_column (widthPt).\n• Table styling: set_borders, set_shading, set_style_options (headerRow / bandedRows / firstColumn / lastColumn / lastRow / bandedColumns), set_layout (autofit / alignment / direction / preferredWidth), set_options (default cell margins + cell spacing).\n\nLOCATORS (the shapes ops accept):\n• insert_row append shorthand: { nodeId: "" } with no rowIndex/position appends at the end. Three other forms: target a row + position, table + rowIndex + position, or any of the above with count:N for multiple.\n• insert_column shorthand: position:"first"|"last" with no columnIndex. Otherwise columnIndex + position:"left"|"right".\n• merge_cells: table target + start:{rowIndex, columnIndex} + end:{rowIndex, columnIndex}.\n• set_cell_text: table target + rowIndex + columnIndex (preferred) OR cell target.\n• set_cell: cell target only. Does NOT accept table+rowIndex+columnIndex.\n• set_borders / set_shading: table OR cell target. NOT a row target.\n\nCOLOR FORMAT:\nHex strings accept #RRGGBB, RRGGBB, #RGB, or 3-digit RGB; also "auto"; also null to clear (where supported). Stored canonically as uppercase RRGGBB. Always pass a concrete color when one is implied. Never call set_borders with `auto` for a "make it look [X]" ask.\n\nSTYLING (TWO MODES):\n\nA. STRUCTURAL CHANGE → re-apply the existing styling.\n Triggers: insert_row / insert_column / delete_row / delete_column / merge_cells / unmerge_cells. (NOT set_cell_text or set_cell: those don\'t disturb borders/shading.)\n Recipe: read the current borders/shading/cnf flags via superdoc_get_content({action:"blocks"}) before the change, then re-apply the SAME values after with set_borders + set_shading + set_style_options. The goal is consistency, not a redesign.\n Skip on a freshly created table. A new table starts un-styled.\n\nB. STYLE-CHANGE REQUEST ("make it look [X]" / "style the whole table") → apply the FULL set with concrete colors.\n Touch every axis: borders, shading, text alignment, font color/weight, cnf flags, spacing. A single set_borders call without shading and font tweaks always looks half-finished. That\'s the #1 cause of "no visual change" complaints.\n Color palette: discover the document\'s palette by reading superdoc_get_content({action:"blocks"}) and reusing the colors on existing tables/headings. When no palette is obvious, default to corporate blue "1F3864" or dark grey "444444" for accents and "F2F2F2" / "E7E6E6" for banding.\n Recipe (call ALL of these):\n 1. set_borders applyTo:"all" with an explicit color and weight.\n 2. set_shading on the header row cells with the accent color. Add banding on alternate body rows if appropriate.\n 3. set_style_options { headerRow: true, bandedRows?: true } so cnf regions are recognized.\n 4. Cell-text alignment via superdoc_format action:"set_alignment". Center the header, left-align body, right-align numeric columns. Paragraph-level: target the paragraph inside each cell.\n 5. Font color + weight via superdoc_format action:"inline". Header gets a contrasting color (white on dark fill, accent on light fill) plus bold:true.\n 6. set_options if the user asks for tighter or looser spacing.\n Steps 4–5 cross to superdoc_format. Use superdoc_mutations to batch many format.apply steps in one call.\n\nAFTER set_cell_text, match the new cell to its siblings:\nset_cell_text writes plain text with the document\'s default font/size/color and no weight. Always follow up with one superdoc_format inline call copying fontFamily/fontSize/color/bold from a sibling cell (or any non-empty body paragraph if the table is fresh and has no sibling content). If sibling cells show a bold-prefix pattern like "Label: value", replicate it on the new cell via superdoc_search + superdoc_format inline (or one superdoc_mutations batch with format.apply steps).\n\nLIST-TO-TABLE:\n(1) superdoc_create action:"table" with the desired rows/columns. (2) Populate cells with set_cell_text using rowIndex/columnIndex (one call per cell). (3) DELETE THE WHOLE LIST in one call: superdoc_list({action:"delete", target:{kind:"block", nodeType:"listItem", nodeId:""}}). The op walks the contiguous list and removes all items.\nWrong paths for list deletion (all leave bullets/empty paragraphs behind): text.delete, superdoc_edit action:"delete" on text refs, lists.detach, lists.convertToText.\n\nEXAMPLES:\n 1. {"action":"insert_row","nodeId":""}\n 2. {"action":"insert_column","nodeId":"","position":"last"}\n 3. {"action":"merge_cells","nodeId":"","start":{"rowIndex":0,"columnIndex":0},"end":{"rowIndex":1,"columnIndex":1}}\n 4. {"action":"set_cell_text","nodeId":"","rowIndex":0,"columnIndex":0,"text":"Q1 Revenue"}\n 5. {"action":"set_row","nodeId":"","rowIndex":0,"heightPt":24,"rule":"atLeast"}\n 6. {"action":"set_borders","nodeId":"","mode":"applyTo","applyTo":"all","border":{"lineStyle":"single","lineWeightPt":1,"color":"#000000"}}\n 7. {"action":"set_shading","target":{"kind":"block","nodeType":"tableCell","nodeId":""},"color":"#E3F2FD"}\n 8. {"action":"set_style_options","nodeId":"","styleOptions":{"headerRow":true,"bandedRows":true}}', - inputSchema: { - type: 'object', - properties: { - action: { - type: 'string', - enum: [ - 'delete', - 'delete_column', - 'delete_row', - 'insert_column', - 'insert_row', - 'merge_cells', - 'set_borders', - 'set_cell', - 'set_cell_text', - 'set_column', - 'set_layout', - 'set_options', - 'set_row', - 'set_row_options', - 'set_shading', - 'set_style_options', - 'unmerge_cells', + "toolName": "superdoc_table", + "description": "Create and modify table structure, content, and styling. Find table/row/cell nodeIds via superdoc_get_content({action:\"blocks\"}) or superdoc_search.\n\nACTIONS:\n• Structure: delete, insert_row, delete_row, insert_column, delete_column, merge_cells, unmerge_cells.\n• Cell content: set_cell_text (text). set_cell (vAlign / wrap / fit / preferred width).\n• Row / column: set_row (height + rule), set_row_options (repeat-header, allow-break), set_column (widthPt).\n• Table styling: set_borders, set_shading, set_style_options (headerRow / bandedRows / firstColumn / lastColumn / lastRow / bandedColumns), set_layout (autofit / alignment / direction / preferredWidth), set_options (default cell margins + cell spacing).\n\nLOCATORS (the shapes ops accept):\n• insert_row append shorthand: { nodeId: \"\" } with no rowIndex/position appends at the end. Three other forms: target a row + position, table + rowIndex + position, or any of the above with count:N for multiple.\n• insert_column shorthand: position:\"first\"|\"last\" with no columnIndex. Otherwise columnIndex + position:\"left\"|\"right\".\n• merge_cells: table target + start:{rowIndex, columnIndex} + end:{rowIndex, columnIndex}.\n• set_cell_text: table target + rowIndex + columnIndex (preferred) OR cell target.\n• set_cell: cell target only. Does NOT accept table+rowIndex+columnIndex.\n• set_borders / set_shading: table OR cell target. NOT a row target.\n\nCOLOR FORMAT:\nHex strings accept #RRGGBB, RRGGBB, #RGB, or 3-digit RGB; also \"auto\"; also null to clear (where supported). Stored canonically as uppercase RRGGBB. Always pass a concrete color when one is implied. Never call set_borders with `auto` for a \"make it look [X]\" ask.\n\nSTYLING (TWO MODES):\n\nA. STRUCTURAL CHANGE → re-apply the existing styling.\n Triggers: insert_row / insert_column / delete_row / delete_column / merge_cells / unmerge_cells. (NOT set_cell_text or set_cell: those don't disturb borders/shading.)\n Recipe: read the current borders/shading/cnf flags via superdoc_get_content({action:\"blocks\"}) before the change, then re-apply the SAME values after with set_borders + set_shading + set_style_options. The goal is consistency, not a redesign.\n Skip on a freshly created table. A new table starts un-styled.\n\nB. STYLE-CHANGE REQUEST (\"make it look [X]\" / \"style the whole table\") → apply the FULL set with concrete colors.\n Touch every axis: borders, shading, text alignment, font color/weight, cnf flags, spacing. A single set_borders call without shading and font tweaks always looks half-finished. That's the #1 cause of \"no visual change\" complaints.\n Color palette: discover the document's palette by reading superdoc_get_content({action:\"blocks\"}) and reusing the colors on existing tables/headings. When no palette is obvious, default to corporate blue \"1F3864\" or dark grey \"444444\" for accents and \"F2F2F2\" / \"E7E6E6\" for banding.\n Recipe (call ALL of these):\n 1. set_borders applyTo:\"all\" with an explicit color and weight.\n 2. set_shading on the header row cells with the accent color. Add banding on alternate body rows if appropriate.\n 3. set_style_options { headerRow: true, bandedRows?: true } so cnf regions are recognized.\n 4. Cell-text alignment via superdoc_format action:\"set_alignment\". Center the header, left-align body, right-align numeric columns. Paragraph-level: target the paragraph inside each cell.\n 5. Font color + weight via superdoc_format action:\"inline\". Header gets a contrasting color (white on dark fill, accent on light fill) plus bold:true.\n 6. set_options if the user asks for tighter or looser spacing.\n Steps 4–5 cross to superdoc_format. Use superdoc_mutations to batch many format.apply steps in one call.\n\nAFTER set_cell_text, match the new cell to its siblings:\nset_cell_text writes plain text with the document's default font/size/color and no weight. Always follow up with one superdoc_format inline call copying fontFamily/fontSize/color/bold from a sibling cell (or any non-empty body paragraph if the table is fresh and has no sibling content). If sibling cells show a bold-prefix pattern like \"Label: value\", replicate it on the new cell via superdoc_search + superdoc_format inline (or one superdoc_mutations batch with format.apply steps).\n\nLIST-TO-TABLE:\n(1) superdoc_create action:\"table\" with the desired rows/columns. (2) Populate cells with set_cell_text using rowIndex/columnIndex (one call per cell). (3) DELETE THE WHOLE LIST in one call: superdoc_list({action:\"delete\", target:{kind:\"block\", nodeType:\"listItem\", nodeId:\"\"}}). The op walks the contiguous list and removes all items.\nWrong paths for list deletion (all leave bullets/empty paragraphs behind): text.delete, superdoc_edit action:\"delete\" on text refs, lists.detach, lists.convertToText.\n\nEXAMPLES:\n 1. {\"action\":\"insert_row\",\"nodeId\":\"\"}\n 2. {\"action\":\"insert_column\",\"nodeId\":\"\",\"position\":\"last\"}\n 3. {\"action\":\"merge_cells\",\"nodeId\":\"\",\"start\":{\"rowIndex\":0,\"columnIndex\":0},\"end\":{\"rowIndex\":1,\"columnIndex\":1}}\n 4. {\"action\":\"set_cell_text\",\"nodeId\":\"\",\"rowIndex\":0,\"columnIndex\":0,\"text\":\"Q1 Revenue\"}\n 5. {\"action\":\"set_row\",\"nodeId\":\"\",\"rowIndex\":0,\"heightPt\":24,\"rule\":\"atLeast\"}\n 6. {\"action\":\"set_borders\",\"nodeId\":\"\",\"mode\":\"applyTo\",\"applyTo\":\"all\",\"border\":{\"lineStyle\":\"single\",\"lineWeightPt\":1,\"color\":\"#000000\"}}\n 7. {\"action\":\"set_shading\",\"target\":{\"kind\":\"block\",\"nodeType\":\"tableCell\",\"nodeId\":\"\"},\"color\":\"#E3F2FD\"}\n 8. {\"action\":\"set_style_options\",\"nodeId\":\"\",\"styleOptions\":{\"headerRow\":true,\"bandedRows\":true}}", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "delete", + "delete_column", + "delete_row", + "insert_column", + "insert_row", + "merge_cells", + "set_borders", + "set_cell", + "set_cell_text", + "set_column", + "set_layout", + "set_options", + "set_row", + "set_row_options", + "set_shading", + "set_style_options", + "unmerge_cells" ], - description: - 'The action to perform. One of: delete, delete_column, delete_row, insert_column, insert_row, merge_cells, set_borders, set_cell, set_cell_text, set_column, set_layout, set_options, set_row, set_row_options, set_shading, set_style_options, unmerge_cells.', + "description": "The action to perform. One of: delete, delete_column, delete_row, insert_column, insert_row, merge_cells, set_borders, set_cell, set_cell_text, set_column, set_layout, set_options, set_row, set_row_options, set_shading, set_style_options, unmerge_cells." }, - force: { - type: 'boolean', - description: 'Bypass confirmation checks.', + "force": { + "type": "boolean", + "description": "Bypass confirmation checks." }, - changeMode: { - type: 'string', - enum: ['direct', 'tracked'], - description: 'Edit mode: "direct" applies changes immediately, "tracked" records as suggestions.', + "changeMode": { + "type": "string", + "enum": [ + "direct", + "tracked" + ], + "description": "Edit mode: \"direct\" applies changes immediately, \"tracked\" records as suggestions." }, - dryRun: { - type: 'boolean', - description: 'Preview the result without applying changes.', + "dryRun": { + "type": "boolean", + "description": "Preview the result without applying changes." }, - target: { - oneOf: [ + "target": { + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/TableAddress', + "$ref": "#/$defs/TableAddress" }, { - oneOf: [ + "oneOf": [ { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/TableRowAddress', + "$ref": "#/$defs/TableRowAddress" }, { - $ref: '#/$defs/TableAddress', - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] }, { - $ref: '#/$defs/TableAddress', - }, - ], - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] + } + ] }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/TableRowAddress', + "$ref": "#/$defs/TableRowAddress" }, { - $ref: '#/$defs/TableAddress', - }, - ], - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] + } + ] }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/TableRowAddress', + "$ref": "#/$defs/TableRowAddress" }, { - $ref: '#/$defs/TableAddress', - }, - ], - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] + } + ] }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/TableRowAddress', + "$ref": "#/$defs/TableRowAddress" }, { - $ref: '#/$defs/TableAddress', - }, - ], - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] + } + ] }, { - $ref: '#/$defs/TableAddress', - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] }, { - $ref: '#/$defs/TableAddress', - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] }, { - $ref: '#/$defs/TableAddress', - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] }, { - $ref: '#/$defs/TableAddress', - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/TableCellAddress', + "$ref": "#/$defs/TableCellAddress" }, { - $ref: '#/$defs/TableAddress', - }, - ], - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] + } + ] }, { - $ref: '#/$defs/TableCellAddress', - }, - ], + "$ref": "#/$defs/TableCellAddress" + } + ] }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/TableCellAddress', + "$ref": "#/$defs/TableCellAddress" }, { - $ref: '#/$defs/TableAddress', - }, - ], - }, - ], + "$ref": "#/$defs/TableAddress" + } + ] + } + ] }, { - $ref: '#/$defs/TableOrCellAddress', - }, - ], + "$ref": "#/$defs/TableOrCellAddress" + } + ] }, { - $ref: '#/$defs/BlockNodeAddress', - }, - ], + "$ref": "#/$defs/BlockNodeAddress" + } + ] }, { - $ref: '#/$defs/BlockNodeAddress', - }, - ], + "$ref": "#/$defs/BlockNodeAddress" + } + ] }, { - $ref: '#/$defs/BlockNodeAddress', - }, + "$ref": "#/$defs/BlockNodeAddress" + } ], - description: - "Target address. For inline/set_style: prefer 'ref' from superdoc_search, or use {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. For paragraph actions (set_alignment, set_indentation, set_spacing, set_direction, set_flow_options): use {kind:'block', nodeType:'paragraph'|'heading'|'listItem', nodeId:''}.", - }, - nodeId: { - type: 'string', - }, - preferredWidth: { - type: 'number', - description: "Only for action 'set_layout'. Omit for other actions.", - }, - alignment: { - enum: ['left', 'center', 'right'], - description: "Only for action 'set_layout'. Omit for other actions.", - }, - leftIndentPt: { - type: 'number', - description: "Only for action 'set_layout'. Omit for other actions.", - }, - autoFitMode: { - enum: ['fixedWidth', 'fitContents', 'fitWindow'], - description: "Only for action 'set_layout'. Omit for other actions.", - }, - tableDirection: { - enum: ['ltr', 'rtl'], - description: "Only for action 'set_layout'. Omit for other actions.", - }, - position: { - enum: ['above', 'below', 'left', 'right', 'first', 'last'], - description: "Required for action 'insert_column'.", - }, - count: { - type: 'integer', - minimum: 1, - description: "Only for actions 'insert_row', 'insert_column'. Omit for other actions.", - }, - rowIndex: { - type: 'integer', - minimum: 0, - description: - "Only for actions 'insert_row', 'delete_row', 'set_row', 'set_row_options', 'unmerge_cells', 'set_cell_text'. Omit for other actions.", - }, - heightPt: { - type: 'number', - exclusiveMinimum: 0, - description: "Required for action 'set_row'.", - }, - rule: { - enum: ['atLeast', 'exact', 'auto'], - description: "Required for action 'set_row'.", - }, - allowBreakAcrossPages: { - type: 'boolean', - description: "Only for action 'set_row_options'. Omit for other actions.", - }, - repeatHeader: { - type: 'boolean', - description: "Only for action 'set_row_options'. Omit for other actions.", - }, - columnIndex: { - type: 'integer', - minimum: 0, - description: "Required for actions 'delete_column', 'set_column'.", - }, - widthPt: { - type: 'number', - exclusiveMinimum: 0, - description: "Required for action 'set_column'.", - }, - start: { - type: 'object', - properties: { - rowIndex: { - type: 'integer', - minimum: 0, - }, - columnIndex: { - type: 'integer', - minimum: 0, + "description": "Target address. For inline/set_style: prefer 'ref' from superdoc_search, or use {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. For paragraph actions (set_alignment, set_indentation, set_spacing, set_direction, set_flow_options): use {kind:'block', nodeType:'paragraph'|'heading'|'listItem', nodeId:''}." + }, + "nodeId": { + "type": "string" + }, + "preferredWidth": { + "type": "number", + "description": "Only for action 'set_layout'. Omit for other actions." + }, + "alignment": { + "enum": [ + "left", + "center", + "right" + ], + "description": "Only for action 'set_layout'. Omit for other actions." + }, + "leftIndentPt": { + "type": "number", + "description": "Only for action 'set_layout'. Omit for other actions." + }, + "autoFitMode": { + "enum": [ + "fixedWidth", + "fitContents", + "fitWindow" + ], + "description": "Only for action 'set_layout'. Omit for other actions." + }, + "tableDirection": { + "enum": [ + "ltr", + "rtl" + ], + "description": "Only for action 'set_layout'. Omit for other actions." + }, + "position": { + "enum": [ + "above", + "below", + "left", + "right", + "first", + "last" + ], + "description": "Required for action 'insert_column'." + }, + "count": { + "type": "integer", + "minimum": 1, + "description": "Only for actions 'insert_row', 'insert_column'. Omit for other actions." + }, + "rowIndex": { + "type": "integer", + "minimum": 0, + "description": "Only for actions 'insert_row', 'delete_row', 'set_row', 'set_row_options', 'unmerge_cells', 'set_cell_text'. Omit for other actions." + }, + "heightPt": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Required for action 'set_row'." + }, + "rule": { + "enum": [ + "atLeast", + "exact", + "auto" + ], + "description": "Required for action 'set_row'." + }, + "allowBreakAcrossPages": { + "type": "boolean", + "description": "Only for action 'set_row_options'. Omit for other actions." + }, + "repeatHeader": { + "type": "boolean", + "description": "Only for action 'set_row_options'. Omit for other actions." + }, + "columnIndex": { + "type": "integer", + "minimum": 0, + "description": "Required for actions 'delete_column', 'set_column'." + }, + "widthPt": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Required for action 'set_column'." + }, + "start": { + "type": "object", + "properties": { + "rowIndex": { + "type": "integer", + "minimum": 0 }, + "columnIndex": { + "type": "integer", + "minimum": 0 + } }, - additionalProperties: false, - required: ['rowIndex', 'columnIndex'], - description: "Required for action 'merge_cells'.", - }, - end: { - type: 'object', - properties: { - rowIndex: { - type: 'integer', - minimum: 0, - }, - columnIndex: { - type: 'integer', - minimum: 0, + "additionalProperties": false, + "required": [ + "rowIndex", + "columnIndex" + ], + "description": "Required for action 'merge_cells'." + }, + "end": { + "type": "object", + "properties": { + "rowIndex": { + "type": "integer", + "minimum": 0 }, + "columnIndex": { + "type": "integer", + "minimum": 0 + } }, - additionalProperties: false, - required: ['rowIndex', 'columnIndex'], - description: "Required for action 'merge_cells'.", + "additionalProperties": false, + "required": [ + "rowIndex", + "columnIndex" + ], + "description": "Required for action 'merge_cells'." }, - preferredWidthPt: { - type: 'number', - description: "Only for action 'set_cell'. Omit for other actions.", + "preferredWidthPt": { + "type": "number", + "description": "Only for action 'set_cell'. Omit for other actions." }, - verticalAlign: { - enum: ['top', 'center', 'bottom'], - description: "Only for action 'set_cell'. Omit for other actions.", + "verticalAlign": { + "enum": [ + "top", + "center", + "bottom" + ], + "description": "Only for action 'set_cell'. Omit for other actions." }, - wrapText: { - type: 'boolean', - description: "Only for action 'set_cell'. Omit for other actions.", + "wrapText": { + "type": "boolean", + "description": "Only for action 'set_cell'. Omit for other actions." }, - fitText: { - type: 'boolean', - description: "Only for action 'set_cell'. Omit for other actions.", + "fitText": { + "type": "boolean", + "description": "Only for action 'set_cell'. Omit for other actions." }, - text: { - type: 'string', - description: "Required for action 'set_cell_text'.", + "text": { + "type": "string", + "description": "Required for action 'set_cell_text'." }, - color: { - oneOf: [ + "color": { + "oneOf": [ { - type: 'string', - pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', + "type": "string", + "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" }, { - type: 'null', - }, + "type": "null" + } ], - description: "Required for action 'set_shading'.", + "description": "Required for action 'set_shading'." }, - styleId: { - type: 'string', - description: "Only for action 'set_style_options'. Omit for other actions.", + "styleId": { + "type": "string", + "description": "Only for action 'set_style_options'. Omit for other actions." }, - styleOptions: { - type: 'object', - properties: { - headerRow: { - type: 'boolean', + "styleOptions": { + "type": "object", + "properties": { + "headerRow": { + "type": "boolean" }, - lastRow: { - type: 'boolean', + "lastRow": { + "type": "boolean" }, - totalRow: { - type: 'boolean', + "totalRow": { + "type": "boolean" }, - firstColumn: { - type: 'boolean', + "firstColumn": { + "type": "boolean" }, - lastColumn: { - type: 'boolean', + "lastColumn": { + "type": "boolean" }, - bandedRows: { - type: 'boolean', - }, - bandedColumns: { - type: 'boolean', + "bandedRows": { + "type": "boolean" }, + "bandedColumns": { + "type": "boolean" + } }, - additionalProperties: false, - description: "Only for action 'set_style_options'. Omit for other actions.", - }, - mode: { - enum: ['applyTo', 'edges'], - description: "Required for action 'set_borders'.", + "additionalProperties": false, + "description": "Only for action 'set_style_options'. Omit for other actions." }, - applyTo: { - enum: ['all', 'outside', 'inside', 'top', 'bottom', 'left', 'right', 'insideH', 'insideV'], - description: "Only for action 'set_borders'. Omit for other actions.", + "mode": { + "enum": [ + "applyTo", + "edges" + ], + "description": "Required for action 'set_borders'." + }, + "applyTo": { + "enum": [ + "all", + "outside", + "inside", + "top", + "bottom", + "left", + "right", + "insideH", + "insideV" + ], + "description": "Only for action 'set_borders'. Omit for other actions." }, - border: { - oneOf: [ + "border": { + "oneOf": [ { - type: 'object', - properties: { - lineStyle: { - type: 'string', - }, - lineWeightPt: { - type: 'number', - exclusiveMinimum: 0, - }, - color: { - type: 'string', - pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', - }, + "type": "object", + "properties": { + "lineStyle": { + "type": "string" + }, + "lineWeightPt": { + "type": "number", + "exclusiveMinimum": 0 + }, + "color": { + "type": "string", + "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" + } }, - additionalProperties: false, - required: ['lineStyle', 'lineWeightPt', 'color'], + "additionalProperties": false, + "required": [ + "lineStyle", + "lineWeightPt", + "color" + ] }, { - type: 'null', - }, + "type": "null" + } ], - description: "Only for action 'set_borders'. Omit for other actions.", - }, - edges: { - type: 'object', - properties: { - top: { - oneOf: [ - { - type: 'object', - properties: { - lineStyle: { - type: 'string', + "description": "Only for action 'set_borders'. Omit for other actions." + }, + "edges": { + "type": "object", + "properties": { + "top": { + "oneOf": [ + { + "type": "object", + "properties": { + "lineStyle": { + "type": "string" }, - lineWeightPt: { - type: 'number', - exclusiveMinimum: 0, - }, - color: { - type: 'string', - pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', + "lineWeightPt": { + "type": "number", + "exclusiveMinimum": 0 }, + "color": { + "type": "string", + "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" + } }, - additionalProperties: false, - required: ['lineStyle', 'lineWeightPt', 'color'], + "additionalProperties": false, + "required": [ + "lineStyle", + "lineWeightPt", + "color" + ] }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - bottom: { - oneOf: [ + "bottom": { + "oneOf": [ { - type: 'object', - properties: { - lineStyle: { - type: 'string', - }, - lineWeightPt: { - type: 'number', - exclusiveMinimum: 0, + "type": "object", + "properties": { + "lineStyle": { + "type": "string" }, - color: { - type: 'string', - pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', + "lineWeightPt": { + "type": "number", + "exclusiveMinimum": 0 }, + "color": { + "type": "string", + "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" + } }, - additionalProperties: false, - required: ['lineStyle', 'lineWeightPt', 'color'], + "additionalProperties": false, + "required": [ + "lineStyle", + "lineWeightPt", + "color" + ] }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - left: { - oneOf: [ + "left": { + "oneOf": [ { - type: 'object', - properties: { - lineStyle: { - type: 'string', + "type": "object", + "properties": { + "lineStyle": { + "type": "string" }, - lineWeightPt: { - type: 'number', - exclusiveMinimum: 0, - }, - color: { - type: 'string', - pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', + "lineWeightPt": { + "type": "number", + "exclusiveMinimum": 0 }, + "color": { + "type": "string", + "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" + } }, - additionalProperties: false, - required: ['lineStyle', 'lineWeightPt', 'color'], + "additionalProperties": false, + "required": [ + "lineStyle", + "lineWeightPt", + "color" + ] }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - right: { - oneOf: [ + "right": { + "oneOf": [ { - type: 'object', - properties: { - lineStyle: { - type: 'string', - }, - lineWeightPt: { - type: 'number', - exclusiveMinimum: 0, + "type": "object", + "properties": { + "lineStyle": { + "type": "string" }, - color: { - type: 'string', - pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', + "lineWeightPt": { + "type": "number", + "exclusiveMinimum": 0 }, + "color": { + "type": "string", + "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" + } }, - additionalProperties: false, - required: ['lineStyle', 'lineWeightPt', 'color'], + "additionalProperties": false, + "required": [ + "lineStyle", + "lineWeightPt", + "color" + ] }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - insideH: { - oneOf: [ + "insideH": { + "oneOf": [ { - type: 'object', - properties: { - lineStyle: { - type: 'string', + "type": "object", + "properties": { + "lineStyle": { + "type": "string" }, - lineWeightPt: { - type: 'number', - exclusiveMinimum: 0, - }, - color: { - type: 'string', - pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', + "lineWeightPt": { + "type": "number", + "exclusiveMinimum": 0 }, + "color": { + "type": "string", + "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" + } }, - additionalProperties: false, - required: ['lineStyle', 'lineWeightPt', 'color'], + "additionalProperties": false, + "required": [ + "lineStyle", + "lineWeightPt", + "color" + ] }, { - type: 'null', - }, - ], + "type": "null" + } + ] }, - insideV: { - oneOf: [ + "insideV": { + "oneOf": [ { - type: 'object', - properties: { - lineStyle: { - type: 'string', - }, - lineWeightPt: { - type: 'number', - exclusiveMinimum: 0, + "type": "object", + "properties": { + "lineStyle": { + "type": "string" }, - color: { - type: 'string', - pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', + "lineWeightPt": { + "type": "number", + "exclusiveMinimum": 0 }, + "color": { + "type": "string", + "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" + } }, - additionalProperties: false, - required: ['lineStyle', 'lineWeightPt', 'color'], - }, - { - type: 'null', - }, - ], - }, + "additionalProperties": false, + "required": [ + "lineStyle", + "lineWeightPt", + "color" + ] + }, + { + "type": "null" + } + ] + } }, - additionalProperties: false, - description: "Only for action 'set_borders'. Omit for other actions.", - }, - defaultCellMargins: { - type: 'object', - properties: { - topPt: { - type: 'number', - minimum: 0, - }, - rightPt: { - type: 'number', - minimum: 0, + "additionalProperties": false, + "description": "Only for action 'set_borders'. Omit for other actions." + }, + "defaultCellMargins": { + "type": "object", + "properties": { + "topPt": { + "type": "number", + "minimum": 0 }, - bottomPt: { - type: 'number', - minimum: 0, + "rightPt": { + "type": "number", + "minimum": 0 }, - leftPt: { - type: 'number', - minimum: 0, + "bottomPt": { + "type": "number", + "minimum": 0 }, + "leftPt": { + "type": "number", + "minimum": 0 + } }, - additionalProperties: false, - required: ['topPt', 'rightPt', 'bottomPt', 'leftPt'], - description: "Only for action 'set_options'. Omit for other actions.", + "additionalProperties": false, + "required": [ + "topPt", + "rightPt", + "bottomPt", + "leftPt" + ], + "description": "Only for action 'set_options'. Omit for other actions." }, - cellSpacingPt: { - oneOf: [ + "cellSpacingPt": { + "oneOf": [ { - type: 'number', - minimum: 0, + "type": "number", + "minimum": 0 }, { - type: 'null', - }, + "type": "null" + } ], - description: "Only for action 'set_options'. Omit for other actions.", - }, + "description": "Only for action 'set_options'. Omit for other actions." + } }, - required: ['action'], - additionalProperties: false, + "required": [ + "action" + ], + "additionalProperties": false }, - mutates: true, - operations: [ + "mutates": true, + "operations": [ { - operationId: 'doc.tables.delete', - intentAction: 'delete', - requiredOneOf: [['target'], ['nodeId']], + "operationId": "doc.tables.delete", + "intentAction": "delete", + "requiredOneOf": [ + [ + "target" + ], + [ + "nodeId" + ] + ] }, { - operationId: 'doc.tables.setLayout', - intentAction: 'set_layout', - requiredOneOf: [['target'], ['nodeId']], + "operationId": "doc.tables.setLayout", + "intentAction": "set_layout", + "requiredOneOf": [ + [ + "target" + ], + [ + "nodeId" + ] + ] }, { - operationId: 'doc.tables.insertRow', - intentAction: 'insert_row', - requiredOneOf: [ - ['target', 'position'], - ['target', 'rowIndex', 'position'], - ['nodeId', 'rowIndex', 'position'], - ['target'], - ['nodeId'], - ], + "operationId": "doc.tables.insertRow", + "intentAction": "insert_row", + "requiredOneOf": [ + [ + "target", + "position" + ], + [ + "target", + "rowIndex", + "position" + ], + [ + "nodeId", + "rowIndex", + "position" + ], + [ + "target" + ], + [ + "nodeId" + ] + ] }, { - operationId: 'doc.tables.deleteRow', - intentAction: 'delete_row', - requiredOneOf: [['target'], ['target', 'rowIndex'], ['nodeId', 'rowIndex']], + "operationId": "doc.tables.deleteRow", + "intentAction": "delete_row", + "requiredOneOf": [ + [ + "target" + ], + [ + "target", + "rowIndex" + ], + [ + "nodeId", + "rowIndex" + ] + ] }, { - operationId: 'doc.tables.setRowHeight', - intentAction: 'set_row', - requiredOneOf: [ - ['target', 'heightPt', 'rule'], - ['target', 'rowIndex', 'heightPt', 'rule'], - ['nodeId', 'rowIndex', 'heightPt', 'rule'], - ], + "operationId": "doc.tables.setRowHeight", + "intentAction": "set_row", + "requiredOneOf": [ + [ + "target", + "heightPt", + "rule" + ], + [ + "target", + "rowIndex", + "heightPt", + "rule" + ], + [ + "nodeId", + "rowIndex", + "heightPt", + "rule" + ] + ] }, { - operationId: 'doc.tables.setRowOptions', - intentAction: 'set_row_options', - requiredOneOf: [['target'], ['target', 'rowIndex'], ['nodeId', 'rowIndex']], + "operationId": "doc.tables.setRowOptions", + "intentAction": "set_row_options", + "requiredOneOf": [ + [ + "target" + ], + [ + "target", + "rowIndex" + ], + [ + "nodeId", + "rowIndex" + ] + ] }, { - operationId: 'doc.tables.insertColumn', - intentAction: 'insert_column', - requiredOneOf: [ - ['position', 'target'], - ['position', 'nodeId'], - ], + "operationId": "doc.tables.insertColumn", + "intentAction": "insert_column", + "requiredOneOf": [ + [ + "position", + "target" + ], + [ + "position", + "nodeId" + ] + ] }, { - operationId: 'doc.tables.deleteColumn', - intentAction: 'delete_column', - requiredOneOf: [ - ['columnIndex', 'target'], - ['columnIndex', 'nodeId'], - ], + "operationId": "doc.tables.deleteColumn", + "intentAction": "delete_column", + "requiredOneOf": [ + [ + "columnIndex", + "target" + ], + [ + "columnIndex", + "nodeId" + ] + ] }, { - operationId: 'doc.tables.setColumnWidth', - intentAction: 'set_column', - requiredOneOf: [ - ['columnIndex', 'widthPt', 'target'], - ['columnIndex', 'widthPt', 'nodeId'], - ], + "operationId": "doc.tables.setColumnWidth", + "intentAction": "set_column", + "requiredOneOf": [ + [ + "columnIndex", + "widthPt", + "target" + ], + [ + "columnIndex", + "widthPt", + "nodeId" + ] + ] }, { - operationId: 'doc.tables.mergeCells', - intentAction: 'merge_cells', - requiredOneOf: [ - ['start', 'end', 'target'], - ['start', 'end', 'nodeId'], - ], + "operationId": "doc.tables.mergeCells", + "intentAction": "merge_cells", + "requiredOneOf": [ + [ + "start", + "end", + "target" + ], + [ + "start", + "end", + "nodeId" + ] + ] }, { - operationId: 'doc.tables.unmergeCells', - intentAction: 'unmerge_cells', - requiredOneOf: [ - ['target'], - ['nodeId'], - ['target', 'rowIndex', 'columnIndex'], - ['nodeId', 'rowIndex', 'columnIndex'], - ], + "operationId": "doc.tables.unmergeCells", + "intentAction": "unmerge_cells", + "requiredOneOf": [ + [ + "target" + ], + [ + "nodeId" + ], + [ + "target", + "rowIndex", + "columnIndex" + ], + [ + "nodeId", + "rowIndex", + "columnIndex" + ] + ] }, { - operationId: 'doc.tables.setCellProperties', - intentAction: 'set_cell', - requiredOneOf: [['target'], ['nodeId']], + "operationId": "doc.tables.setCellProperties", + "intentAction": "set_cell", + "requiredOneOf": [ + [ + "target" + ], + [ + "nodeId" + ] + ] }, { - operationId: 'doc.tables.setCellText', - intentAction: 'set_cell_text', - requiredOneOf: [ - ['target', 'text'], - ['nodeId', 'text'], - ['target', 'rowIndex', 'columnIndex', 'text'], - ['nodeId', 'rowIndex', 'columnIndex', 'text'], - ], + "operationId": "doc.tables.setCellText", + "intentAction": "set_cell_text", + "requiredOneOf": [ + [ + "target", + "text" + ], + [ + "nodeId", + "text" + ], + [ + "target", + "rowIndex", + "columnIndex", + "text" + ], + [ + "nodeId", + "rowIndex", + "columnIndex", + "text" + ] + ] }, { - operationId: 'doc.tables.setShading', - intentAction: 'set_shading', - requiredOneOf: [ - ['color', 'target'], - ['color', 'nodeId'], - ], + "operationId": "doc.tables.setShading", + "intentAction": "set_shading", + "requiredOneOf": [ + [ + "color", + "target" + ], + [ + "color", + "nodeId" + ] + ] }, { - operationId: 'doc.tables.applyStyle', - intentAction: 'set_style_options', - requiredOneOf: [['target'], ['nodeId']], + "operationId": "doc.tables.applyStyle", + "intentAction": "set_style_options", + "requiredOneOf": [ + [ + "target" + ], + [ + "nodeId" + ] + ] }, { - operationId: 'doc.tables.setBorders', - intentAction: 'set_borders', - requiredOneOf: [ - ['mode', 'applyTo', 'border', 'target'], - ['mode', 'applyTo', 'border', 'nodeId'], - ['mode', 'edges', 'target'], - ['mode', 'edges', 'nodeId'], - ], + "operationId": "doc.tables.setBorders", + "intentAction": "set_borders", + "requiredOneOf": [ + [ + "mode", + "applyTo", + "border", + "target" + ], + [ + "mode", + "applyTo", + "border", + "nodeId" + ], + [ + "mode", + "edges", + "target" + ], + [ + "mode", + "edges", + "nodeId" + ] + ] }, { - operationId: 'doc.tables.setTableOptions', - intentAction: 'set_options', - requiredOneOf: [['target'], ['nodeId']], - }, - ], - }, - ], + "operationId": "doc.tables.setTableOptions", + "intentAction": "set_options", + "requiredOneOf": [ + [ + "target" + ], + [ + "nodeId" + ] + ] + } + ] + } + ] } as const; diff --git a/apps/mcp/src/generated/intent-dispatch.generated.ts b/apps/mcp/src/generated/intent-dispatch.generated.ts index 82bbd96b37..caaba98191 100644 --- a/apps/mcp/src/generated/intent-dispatch.generated.ts +++ b/apps/mcp/src/generated/intent-dispatch.generated.ts @@ -10,132 +10,84 @@ export function dispatchIntentTool( case 'superdoc_get_content': { const { action, ...rest } = args; switch (action) { - case 'text': - return execute('doc.getText', rest); - case 'markdown': - return execute('doc.getMarkdown', rest); - case 'html': - return execute('doc.getHtml', rest); - case 'info': - return execute('doc.info', rest); - case 'extract': - return execute('doc.extract', rest); - case 'blocks': - return execute('doc.blocks.list', rest); - default: - throw new Error(`Unknown action for superdoc_get_content: ${action}`); + case 'text': return execute('doc.getText', rest); + case 'markdown': return execute('doc.getMarkdown', rest); + case 'html': return execute('doc.getHtml', rest); + case 'info': return execute('doc.info', rest); + case 'extract': return execute('doc.extract', rest); + case 'blocks': return execute('doc.blocks.list', rest); + default: throw new Error(`Unknown action for superdoc_get_content: ${action}`); } } case 'superdoc_edit': { const { action, ...rest } = args; switch (action) { - case 'insert': - return execute('doc.insert', rest); - case 'replace': - return execute('doc.replace', rest); - case 'delete': - return execute('doc.delete', rest); - case 'undo': - return execute('doc.history.undo', rest); - case 'redo': - return execute('doc.history.redo', rest); - default: - throw new Error(`Unknown action for superdoc_edit: ${action}`); + case 'insert': return execute('doc.insert', rest); + case 'replace': return execute('doc.replace', rest); + case 'delete': return execute('doc.delete', rest); + case 'undo': return execute('doc.history.undo', rest); + case 'redo': return execute('doc.history.redo', rest); + default: throw new Error(`Unknown action for superdoc_edit: ${action}`); } } case 'superdoc_format': { const { action, ...rest } = args; switch (action) { - case 'inline': - return execute('doc.format.apply', rest); - case 'set_style': - return execute('doc.styles.paragraph.setStyle', rest); - case 'set_alignment': - return execute('doc.format.paragraph.setAlignment', rest); - case 'set_indentation': - return execute('doc.format.paragraph.setIndentation', rest); - case 'set_spacing': - return execute('doc.format.paragraph.setSpacing', rest); - case 'set_flow_options': - return execute('doc.format.paragraph.setFlowOptions', rest); - case 'set_direction': - return execute('doc.format.paragraph.setDirection', rest); - default: - throw new Error(`Unknown action for superdoc_format: ${action}`); + case 'inline': return execute('doc.format.apply', rest); + case 'set_style': return execute('doc.styles.paragraph.setStyle', rest); + case 'set_alignment': return execute('doc.format.paragraph.setAlignment', rest); + case 'set_indentation': return execute('doc.format.paragraph.setIndentation', rest); + case 'set_spacing': return execute('doc.format.paragraph.setSpacing', rest); + case 'set_flow_options': return execute('doc.format.paragraph.setFlowOptions', rest); + case 'set_direction': return execute('doc.format.paragraph.setDirection', rest); + default: throw new Error(`Unknown action for superdoc_format: ${action}`); } } case 'superdoc_create': { const { action, ...rest } = args; switch (action) { - case 'paragraph': - return execute('doc.create.paragraph', rest); - case 'heading': - return execute('doc.create.heading', rest); - case 'table': - return execute('doc.create.table', rest); - default: - throw new Error(`Unknown action for superdoc_create: ${action}`); + case 'paragraph': return execute('doc.create.paragraph', rest); + case 'heading': return execute('doc.create.heading', rest); + case 'table': return execute('doc.create.table', rest); + default: throw new Error(`Unknown action for superdoc_create: ${action}`); } } case 'superdoc_list': { const { action, ...rest } = args; switch (action) { - case 'insert': - return execute('doc.lists.insert', rest); - case 'create': - return execute('doc.lists.create', rest); - case 'attach': - return execute('doc.lists.attach', rest); - case 'detach': - return execute('doc.lists.detach', rest); - case 'delete': - return execute('doc.lists.delete', rest); - case 'indent': - return execute('doc.lists.indent', rest); - case 'outdent': - return execute('doc.lists.outdent', rest); - case 'merge': - return execute('doc.lists.merge', rest); - case 'split': - return execute('doc.lists.split', rest); - case 'set_level': - return execute('doc.lists.setLevel', rest); - case 'set_value': - return execute('doc.lists.setValue', rest); - case 'continue_previous': - return execute('doc.lists.continuePrevious', rest); - case 'set_type': - return execute('doc.lists.setType', rest); - default: - throw new Error(`Unknown action for superdoc_list: ${action}`); + case 'insert': return execute('doc.lists.insert', rest); + case 'create': return execute('doc.lists.create', rest); + case 'attach': return execute('doc.lists.attach', rest); + case 'detach': return execute('doc.lists.detach', rest); + case 'delete': return execute('doc.lists.delete', rest); + case 'indent': return execute('doc.lists.indent', rest); + case 'outdent': return execute('doc.lists.outdent', rest); + case 'merge': return execute('doc.lists.merge', rest); + case 'split': return execute('doc.lists.split', rest); + case 'set_level': return execute('doc.lists.setLevel', rest); + case 'set_value': return execute('doc.lists.setValue', rest); + case 'continue_previous': return execute('doc.lists.continuePrevious', rest); + case 'set_type': return execute('doc.lists.setType', rest); + default: throw new Error(`Unknown action for superdoc_list: ${action}`); } } case 'superdoc_comment': { const { action, ...rest } = args; switch (action) { - case 'create': - return execute('doc.comments.create', rest); - case 'update': - return execute('doc.comments.patch', rest); - case 'delete': - return execute('doc.comments.delete', rest); - case 'get': - return execute('doc.comments.get', rest); - case 'list': - return execute('doc.comments.list', rest); - default: - throw new Error(`Unknown action for superdoc_comment: ${action}`); + case 'create': return execute('doc.comments.create', rest); + case 'update': return execute('doc.comments.patch', rest); + case 'delete': return execute('doc.comments.delete', rest); + case 'get': return execute('doc.comments.get', rest); + case 'list': return execute('doc.comments.list', rest); + default: throw new Error(`Unknown action for superdoc_comment: ${action}`); } } case 'superdoc_track_changes': { const { action, ...rest } = args; switch (action) { - case 'list': - return execute('doc.trackChanges.list', rest); - case 'decide': - return execute('doc.trackChanges.decide', rest); - default: - throw new Error(`Unknown action for superdoc_track_changes: ${action}`); + case 'list': return execute('doc.trackChanges.list', rest); + case 'decide': return execute('doc.trackChanges.decide', rest); + default: throw new Error(`Unknown action for superdoc_track_changes: ${action}`); } } case 'superdoc_search': @@ -143,53 +95,32 @@ export function dispatchIntentTool( case 'superdoc_mutations': { const { action, ...rest } = args; switch (action) { - case 'preview': - return execute('doc.mutations.preview', rest); - case 'apply': - return execute('doc.mutations.apply', rest); - default: - throw new Error(`Unknown action for superdoc_mutations: ${action}`); + case 'preview': return execute('doc.mutations.preview', rest); + case 'apply': return execute('doc.mutations.apply', rest); + default: throw new Error(`Unknown action for superdoc_mutations: ${action}`); } } case 'superdoc_table': { const { action, ...rest } = args; switch (action) { - case 'delete': - return execute('doc.tables.delete', rest); - case 'set_layout': - return execute('doc.tables.setLayout', rest); - case 'insert_row': - return execute('doc.tables.insertRow', rest); - case 'delete_row': - return execute('doc.tables.deleteRow', rest); - case 'set_row': - return execute('doc.tables.setRowHeight', rest); - case 'set_row_options': - return execute('doc.tables.setRowOptions', rest); - case 'insert_column': - return execute('doc.tables.insertColumn', rest); - case 'delete_column': - return execute('doc.tables.deleteColumn', rest); - case 'set_column': - return execute('doc.tables.setColumnWidth', rest); - case 'merge_cells': - return execute('doc.tables.mergeCells', rest); - case 'unmerge_cells': - return execute('doc.tables.unmergeCells', rest); - case 'set_cell': - return execute('doc.tables.setCellProperties', rest); - case 'set_cell_text': - return execute('doc.tables.setCellText', rest); - case 'set_shading': - return execute('doc.tables.setShading', rest); - case 'set_style_options': - return execute('doc.tables.applyStyle', rest); - case 'set_borders': - return execute('doc.tables.setBorders', rest); - case 'set_options': - return execute('doc.tables.setTableOptions', rest); - default: - throw new Error(`Unknown action for superdoc_table: ${action}`); + case 'delete': return execute('doc.tables.delete', rest); + case 'set_layout': return execute('doc.tables.setLayout', rest); + case 'insert_row': return execute('doc.tables.insertRow', rest); + case 'delete_row': return execute('doc.tables.deleteRow', rest); + case 'set_row': return execute('doc.tables.setRowHeight', rest); + case 'set_row_options': return execute('doc.tables.setRowOptions', rest); + case 'insert_column': return execute('doc.tables.insertColumn', rest); + case 'delete_column': return execute('doc.tables.deleteColumn', rest); + case 'set_column': return execute('doc.tables.setColumnWidth', rest); + case 'merge_cells': return execute('doc.tables.mergeCells', rest); + case 'unmerge_cells': return execute('doc.tables.unmergeCells', rest); + case 'set_cell': return execute('doc.tables.setCellProperties', rest); + case 'set_cell_text': return execute('doc.tables.setCellText', rest); + case 'set_shading': return execute('doc.tables.setShading', rest); + case 'set_style_options': return execute('doc.tables.applyStyle', rest); + case 'set_borders': return execute('doc.tables.setBorders', rest); + case 'set_options': return execute('doc.tables.setTableOptions', rest); + default: throw new Error(`Unknown action for superdoc_table: ${action}`); } } default: diff --git a/packages/document-api/README.md b/packages/document-api/README.md index 8b4cee33a1..f2f2863612 100644 --- a/packages/document-api/README.md +++ b/packages/document-api/README.md @@ -60,6 +60,12 @@ operation-definitions.ts types.ts (re-exports + CommandCatalog, guards) - `operation-registry.ts` is the single source of truth for type signatures (input/options/output per operation). - `TypedDispatchTable` (in `invoke.ts`) validates at compile time that dispatch wiring conforms to the registry. +## Receipt Failure Metadata + +For operations that return receipt-shaped results, `possibleFailureCodes` must be the complete set of codes that can appear in a returned `{ success: false, failure }` receipt. `throws.preApply` is only for validation or adapter errors thrown before a receipt is produced; listing a code there does not make it valid for receipt output. + +Generated SDK/CLI schemas derive their `failure.code` enums from `possibleFailureCodes`. When an adapter bridges runtime engine failures into receipt failures, add a local parity test proving every bridged code is present in `possibleFailureCodes` before regenerating downstream artifacts. + ## OperationRegistry and invoke `operation-registry.ts` is the canonical type-level mapping from `OperationId` to `{ input, options, output }`. Bidirectional `Assert` checks guarantee every `OperationId` has a registry entry and vice versa. diff --git a/packages/document-api/src/comments/comments.test.ts b/packages/document-api/src/comments/comments.test.ts index ca6d3d7793..6ca739bc17 100644 --- a/packages/document-api/src/comments/comments.test.ts +++ b/packages/document-api/src/comments/comments.test.ts @@ -9,9 +9,17 @@ import { const stubAdapter = () => ({ - add: mock(() => ({ success: true })), + add: mock(() => ({ + success: true, + id: 'c1', + inserted: [{ kind: 'entity', entityType: 'comment', entityId: 'c1' }], + })), edit: mock(() => ({ success: true })), - reply: mock(() => ({ success: true })), + reply: mock(() => ({ + success: true, + id: 'c2', + inserted: [{ kind: 'entity', entityType: 'comment', entityId: 'c2' }], + })), move: mock(() => ({ success: true })), resolve: mock(() => ({ success: true })), reopen: mock(() => ({ success: true })), @@ -40,6 +48,14 @@ describe('executeCommentsCreate validation', () => { expect(e.code).toBe('INVALID_INPUT'); } }); + + it('returns the created comment id on success', () => { + const adapter = stubAdapter(); + const target = { kind: 'text', blockId: 'b1', range: { start: 0, end: 5 } }; + const receipt = executeCommentsCreate(adapter, { text: 'hello', target }); + expect(receipt.success).toBe(true); + expect(receipt.id).toBe('c1'); + }); }); describe('executeCommentsPatch validation', () => { diff --git a/packages/document-api/src/comments/comments.ts b/packages/document-api/src/comments/comments.ts index a908a26d80..fb37b5e10d 100644 --- a/packages/document-api/src/comments/comments.ts +++ b/packages/document-api/src/comments/comments.ts @@ -1,8 +1,16 @@ -import type { Receipt, TextAddress, TextTarget } from '../types/index.js'; -import type { CommentInfo, CommentsListQuery, CommentsListResult } from './comments.types.js'; +import type { Receipt, ReceiptFailureResult, ReceiptSuccess } from '../types/index.js'; +import type { + CommentInfo, + CommentTarget, + CommentsListQuery, + CommentsListResult, + TrackedChangeCommentTarget, +} from './comments.types.js'; import type { RevisionGuardOptions } from '../write/write.js'; import { DocumentApiValidationError } from '../errors.js'; import { isRecord, isTextAddress, isTextTarget, assertNoUnknownFields } from '../validation-primitives.js'; +import { isSelectionTarget } from '../validation/selection-target-validator.js'; +import { validateStoryLocator } from '../validation/story-validator.js'; /** * Input for adding a comment to a text range. @@ -20,7 +28,7 @@ export interface AddCommentInput { * `textRanges[0]`) or a {@link TextTarget} with multi-segment for * selections that span multiple blocks. */ - target?: TextAddress | TextTarget; + target?: CommentTarget; /** The comment body text. */ text: string; } @@ -37,7 +45,7 @@ export interface ReplyToCommentInput { export interface MoveCommentInput { commentId: string; - target: TextAddress; + target: CommentTarget; } export interface ResolveCommentInput { @@ -93,7 +101,7 @@ export interface CommentsCreateInput { * {@link TextTarget}. Prefer passing `editor.doc.selection.current().target` * directly for selections that may span multiple blocks. */ - target?: TextAddress | TextTarget; + target?: CommentTarget; /** Parent comment ID: when provided, creates a reply instead of a root comment. */ parentCommentId?: string; } @@ -111,7 +119,7 @@ export interface CommentsPatchInput { /** New body text (routes to edit). */ text?: string; /** New anchor range (routes to move). */ - target?: TextAddress; + target?: CommentTarget; /** * Lifecycle transition. `'resolved'` routes to resolve, `'active'` * routes to reopen: symmetric inverse that removes the resolve @@ -130,16 +138,23 @@ export interface CommentsDeleteInput { commentId: string; } +export type CommentsCreateReceiptSuccess = ReceiptSuccess & { + /** Convenience alias for the created comment id. */ + id: string; +}; + +export type CommentsCreateReceipt = CommentsCreateReceiptSuccess | ReceiptFailureResult; + /** * Engine-specific adapter that the comments API delegates to. */ export interface CommentsAdapter { /** Add a comment at the specified text range. */ - add(input: AddCommentInput, options?: RevisionGuardOptions): Receipt; + add(input: AddCommentInput, options?: RevisionGuardOptions): CommentsCreateReceipt; /** Edit the body text of an existing comment. */ edit(input: EditCommentInput, options?: RevisionGuardOptions): Receipt; /** Reply to an existing comment thread. */ - reply(input: ReplyToCommentInput, options?: RevisionGuardOptions): Receipt; + reply(input: ReplyToCommentInput, options?: RevisionGuardOptions): CommentsCreateReceipt; /** Move a comment to a different text range. */ move(input: MoveCommentInput, options?: RevisionGuardOptions): Receipt; /** Resolve an open comment. */ @@ -176,7 +191,7 @@ export interface CommentsAdapter { * of the document-api contract. */ export interface CommentsApi { - create(input: CommentsCreateInput, options?: RevisionGuardOptions): Receipt; + create(input: CommentsCreateInput, options?: RevisionGuardOptions): CommentsCreateReceipt; patch(input: CommentsPatchInput, options?: RevisionGuardOptions): Receipt; delete(input: CommentsDeleteInput, options?: RevisionGuardOptions): Receipt; get(input: GetCommentInput): CommentInfo; @@ -185,6 +200,32 @@ export interface CommentsApi { const CREATE_COMMENT_ALLOWED_KEYS = new Set(['target', 'text', 'parentCommentId']); +function isTrackedChangeCommentTarget(value: unknown): value is TrackedChangeCommentTarget { + if (!isRecord(value)) return false; + if (value.kind !== undefined && value.kind !== 'trackedChange') return false; + return typeof value.trackedChangeId === 'string' && value.trackedChangeId.length > 0; +} + +function validateCommentTarget(target: unknown, operationName: string): void { + if (isTextAddress(target) || isTextTarget(target) || isSelectionTarget(target)) { + return; + } + + if (isTrackedChangeCommentTarget(target)) { + validateStoryLocator(target.story, 'target.story'); + return; + } + + throw new DocumentApiValidationError( + 'INVALID_TARGET', + `${operationName} target must be a TextAddress, TextTarget, SelectionTarget, or tracked-change target.`, + { + field: 'target', + value: target, + }, + ); +} + /** * Validates CommentsCreateInput for root comments (non-reply) and throws DocumentApiValidationError on violations. */ @@ -230,12 +271,7 @@ function validateCreateCommentInput(input: unknown): asserts input is CommentsCr }); } - if (!isTextAddress(target) && !isTextTarget(target)) { - throw new DocumentApiValidationError('INVALID_TARGET', 'target must be a TextAddress or TextTarget object.', { - field: 'target', - value: target, - }); - } + validateCommentTarget(target, 'comments.create'); } const PATCH_COMMENT_ALLOWED_KEYS = new Set(['commentId', 'target', 'text', 'status', 'isInternal']); @@ -306,11 +342,8 @@ function validatePatchCommentInput(input: unknown): asserts input is CommentsPat }); } - if (hasTarget && !isTextAddress(target)) { - throw new DocumentApiValidationError('INVALID_TARGET', 'target must be a text address object.', { - field: 'target', - value: target, - }); + if (hasTarget) { + validateCommentTarget(target, 'comments.patch'); } } @@ -331,7 +364,7 @@ export function executeCommentsCreate( adapter: CommentsAdapter, input: CommentsCreateInput, options?: RevisionGuardOptions, -): Receipt { +): CommentsCreateReceipt { // Validate the raw input first (catches null, unknown fields, etc.) validateCreateCommentInput(input); diff --git a/packages/document-api/src/comments/comments.types.ts b/packages/document-api/src/comments/comments.types.ts index 95cede1e23..1a4b8d3057 100644 --- a/packages/document-api/src/comments/comments.types.ts +++ b/packages/document-api/src/comments/comments.types.ts @@ -1,8 +1,34 @@ -import type { CommentAddress, CommentStatus, TextTarget } from '../types/index.js'; +import type { + CommentAddress, + CommentStatus, + SelectionTarget, + StoryLocator, + TextAddress, + TextTarget, +} from '../types/index.js'; import type { DiscoveryOutput } from '../types/discovery.js'; +import type { TrackChangeType } from '../types/track-changes.types.js'; export type { CommentStatus } from '../types/index.js'; +export interface TrackedChangeCommentTarget { + kind?: 'trackedChange'; + trackedChangeId: string; + story?: StoryLocator; +} + +export type CommentTarget = TextAddress | TextTarget | SelectionTarget | TrackedChangeCommentTarget; + +export interface CommentTrackedChangeLink { + trackedChange: boolean; + trackedChangeType?: TrackChangeType; + trackedChangeDisplayType?: string | null; + trackedChangeStory?: StoryLocator | null; + trackedChangeAnchorKey?: string | null; + trackedChangeText?: string | null; + deletedText?: string | null; +} + export interface CommentInfo { address: CommentAddress; commentId: string; @@ -16,6 +42,14 @@ export interface CommentInfo { createdTime?: number; creatorName?: string; creatorEmail?: string; + trackedChange?: boolean; + trackedChangeType?: TrackChangeType; + trackedChangeDisplayType?: string | null; + trackedChangeStory?: StoryLocator | null; + trackedChangeAnchorKey?: string | null; + trackedChangeText?: string | null; + deletedText?: string | null; + trackedChangeLink?: CommentTrackedChangeLink | null; } export interface CommentsListQuery { @@ -42,6 +76,14 @@ export interface CommentDomain { createdTime?: number; creatorName?: string; creatorEmail?: string; + trackedChange?: boolean; + trackedChangeType?: TrackChangeType; + trackedChangeDisplayType?: string | null; + trackedChangeStory?: StoryLocator | null; + trackedChangeAnchorKey?: string | null; + trackedChangeText?: string | null; + deletedText?: string | null; + trackedChangeLink?: CommentTrackedChangeLink | null; } /** diff --git a/packages/document-api/src/contract/contract.test.ts b/packages/document-api/src/contract/contract.test.ts index e9f2fe449b..c6ce294626 100644 --- a/packages/document-api/src/contract/contract.test.ts +++ b/packages/document-api/src/contract/contract.test.ts @@ -8,6 +8,26 @@ import { PUBLIC_MUTATION_STEP_OP_IDS, STEP_OP_CATALOG } from './step-op-catalog. import { OPERATION_IDS, PRE_APPLY_THROW_CODES, isValidOperationIdFormat } from './types.js'; import { Z_ORDER_RELATIVE_HEIGHT_MAX, Z_ORDER_RELATIVE_HEIGHT_MIN } from '../images/z-order.js'; +const TRACK_CHANGES_DECIDE_RECEIPT_FAILURE_CODES = [ + 'NO_OP', + 'INVALID_TARGET', + 'TARGET_NOT_FOUND', + 'CAPABILITY_UNAVAILABLE', + 'PERMISSION_DENIED', + 'PRECONDITION_FAILED', + 'COMMENT_CASCADE_PARTIAL', +] as const; + +function expectArrayToIncludeValues( + actual: readonly string[] | undefined, + expected: readonly string[], + label: string, +): void { + expect(Array.isArray(actual), `${label} should be an array`).toBe(true); + const missing = expected.filter((code) => !actual!.includes(code)); + expect(missing, `${label} missing expected codes`).toEqual([]); +} + describe('document-api contract catalog', () => { it('keeps operation ids explicit and format-valid', () => { expect([...new Set(OPERATION_IDS)]).toHaveLength(OPERATION_IDS.length); @@ -194,6 +214,53 @@ describe('document-api contract catalog', () => { } }); + it('allows null trackedChangeLink on comment read models', () => { + const schemas = buildInternalContractSchemas(); + const commentInfoSchema = schemas.operations['comments.get'].output as { + properties?: { + trackedChangeLink?: { oneOf?: Array> }; + }; + }; + const commentsListSchema = schemas.operations['comments.list'].output as { + properties?: { + items?: { + items?: { + properties?: { + trackedChangeLink?: { oneOf?: Array> }; + }; + }; + }; + }; + }; + + const getVariants = commentInfoSchema.properties?.trackedChangeLink?.oneOf ?? []; + const listVariants = commentsListSchema.properties?.items?.items?.properties?.trackedChangeLink?.oneOf ?? []; + + expect(getVariants.some((variant) => variant.type === 'null')).toBe(true); + expect(listVariants.some((variant) => variant.type === 'null')).toBe(true); + }); + + it('requires id on comments.create success receipts', () => { + const schemas = buildInternalContractSchemas(); + const createOutputSchema = schemas.operations['comments.create'].output as { + oneOf?: Array<{ + $ref?: string; + }>; + }; + const defs = schemas.$defs as Record< + string, + { + properties?: Record; + required?: string[]; + } + >; + + const successSchema = createOutputSchema.oneOf?.[0]; + expect(successSchema?.$ref).toBe('#/$defs/CommentsCreateSuccess'); + expect(defs.CommentsCreateSuccess?.properties).toHaveProperty('id'); + expect(defs.CommentsCreateSuccess?.required).toEqual(expect.arrayContaining(['success', 'id'])); + }); + it('declares UNSUPPORTED_ENVIRONMENT for insert metadata and generated failure schema', () => { const schemas = buildInternalContractSchemas(); const insertFailureSchema = schemas.operations.insert.failure as { @@ -212,6 +279,106 @@ describe('document-api contract catalog', () => { expect(insertFailureSchema.properties?.failure?.properties?.code?.enum).toContain('UNSUPPORTED_ENVIRONMENT'); }); + it('declares every trackChanges.decide receipt failure code in command metadata', () => { + expectArrayToIncludeValues( + COMMAND_CATALOG['trackChanges.decide'].possibleFailureCodes, + TRACK_CHANGES_DECIDE_RECEIPT_FAILURE_CODES, + 'trackChanges.decide possibleFailureCodes', + ); + }); + + it('includes every trackChanges.decide receipt failure code in the generated failure schema', () => { + const schemas = buildInternalContractSchemas(); + const decideFailureSchema = schemas.operations['trackChanges.decide'].failure as { + properties?: { + failure?: { + properties?: { + code?: { + enum?: string[]; + }; + }; + }; + }; + }; + + expectArrayToIncludeValues( + decideFailureSchema.properties?.failure?.properties?.code?.enum, + TRACK_CHANGES_DECIDE_RECEIPT_FAILURE_CODES, + 'trackChanges.decide failure schema code enum', + ); + }); + + it('includes every trackChanges.decide receipt failure code in the generated output schema', () => { + const schemas = buildInternalContractSchemas(); + const decideOutputSchema = schemas.operations['trackChanges.decide'].output as { + oneOf?: Array<{ + properties?: { + failure?: { + properties?: { + code?: { + enum?: string[]; + }; + }; + }; + }; + }>; + }; + + expectArrayToIncludeValues( + decideOutputSchema.oneOf?.[1]?.properties?.failure?.properties?.code?.enum, + TRACK_CHANGES_DECIDE_RECEIPT_FAILURE_CODES, + 'trackChanges.decide output schema failure code enum', + ); + }); + + it('publishes replacement as a first-class tracked-change type in list/get/extract schemas', () => { + const schemas = buildInternalContractSchemas(); + const trackChangesListInput = schemas.operations['trackChanges.list'].input as { + properties?: { + type?: { + enum?: string[]; + }; + }; + }; + const trackChangesGetOutput = schemas.operations['trackChanges.get'].output as { + properties?: { + type?: { + enum?: string[]; + }; + grouping?: { + enum?: string[]; + }; + }; + }; + const extractOutput = schemas.operations.extract.output as { + properties?: { + trackedChanges?: { + items?: { + properties?: { + type?: { + enum?: string[]; + }; + }; + }; + }; + }; + }; + + expect(trackChangesListInput.properties?.type?.enum).toEqual( + expect.arrayContaining(['insert', 'delete', 'replacement', 'format']), + ); + expect(trackChangesGetOutput.properties?.type?.enum).toEqual( + expect.arrayContaining(['insert', 'delete', 'replacement', 'format']), + ); + expect(trackChangesGetOutput.properties?.grouping?.enum).toEqual( + expect.arrayContaining(['standalone', 'replacement-pair', 'unknown']), + ); + expect(trackChangesGetOutput.properties?.grouping?.enum).not.toContain('aggregate'); + expect(extractOutput.properties?.trackedChanges?.items?.properties?.type?.enum).toEqual( + expect.arrayContaining(['insert', 'delete', 'replacement', 'format']), + ); + }); + it('includes global.history in capabilities.get output schema', () => { const schemas = buildInternalContractSchemas(); const capabilitiesOutput = schemas.operations['capabilities.get'].output as { diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index 7920d9b8d0..5214f96fa8 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -416,15 +416,23 @@ export const INTENT_GROUP_META: Record = { track_changes: { toolName: 'superdoc_track_changes', description: - 'Review and resolve tracked changes (insertions, deletions, format changes) in the document. ' + - 'Action "list" returns all tracked changes with optional filtering by type (insert, delete, format) and pagination (limit, offset). Each change includes an ID, type, author, timestamp, and content preview. ' + + 'Review and resolve tracked changes (insertions, deletions, replacements, format changes) in the document. ' + + 'Action "list" returns all tracked changes with optional filtering by type (insert, delete, replacement, format) and pagination (limit, offset). Each change includes an ID, type, author, timestamp, and content preview. ' + 'Action "decide" accepts or rejects changes. Pass decision:"accept" to apply the change permanently, or decision:"reject" to discard it. ' + - 'Target a single change with {id:""} or all changes at once with {scope:"all"}. ' + + 'Target a single change with {id:""}, a partial selection with {kind:"range", range:{...}}, or all changes at once with {scope:"all"} (optionally plus story). ' + 'Do NOT use this tool unless the document has tracked changes. Use superdoc_get_content info to check the tracked change count first.', inputExamples: [ { action: 'list' }, - { action: 'list', type: 'insert', limit: 10 }, + { action: 'list', type: 'replacement', limit: 10 }, { action: 'decide', decision: 'accept', target: { id: '' } }, + { + action: 'decide', + decision: 'reject', + target: { + kind: 'range', + range: { kind: 'text', segments: [{ blockId: '', range: { start: 0, end: 5 } }] }, + }, + }, { action: 'decide', decision: 'reject', target: { scope: 'all' } }, ], }, @@ -954,6 +962,23 @@ export const OPERATION_DEFINITIONS = { intentGroup: 'edit', intentAction: 'delete', }, + formatRange: { + memberPath: 'formatRange', + description: + 'Legacy root-level alias for inline range formatting. Routes to `format.apply` for compatibility with older callers.', + expectedResult: 'Returns a TextMutationReceipt confirming inline styles were applied to the target range.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: true, + possibleFailureCodes: ['INVALID_TARGET'], + throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_TARGET', 'INVALID_INPUT', ...T_STORY], + }), + referenceDocPath: 'format/apply.mdx', + referenceGroup: 'format', + skipAsATool: true, + }, 'blocks.list': { memberPath: 'blocks.list', @@ -2374,7 +2399,7 @@ export const OPERATION_DEFINITIONS = { memberPath: 'comments.create', description: 'Create a new comment thread (or reply when parentCommentId is given).', expectedResult: - 'Returns a Receipt confirming the comment was created; reports NO_OP if the anchor target is invalid.', + 'Returns a Receipt confirming the comment was created, including the new comment id; reports NO_OP if the anchor target is invalid.', requiresDocumentContext: true, metadata: mutationOperation({ idempotency: 'non-idempotent', @@ -2456,7 +2481,7 @@ export const OPERATION_DEFINITIONS = { memberPath: 'trackChanges.list', description: 'List all tracked changes in the document.', expectedResult: - 'Returns a TrackChangesListResult with tracked change entries, total count, and raw imported Word OOXML revision IDs (`w:id`) when available.', + 'Returns a TrackChangesListResult with tracked change entries (`insert`, `delete`, `replacement`, `format`), total count, and raw imported Word OOXML revision IDs (`w:id`) when available.', requiresDocumentContext: true, metadata: readOperation({ idempotency: 'idempotent', @@ -2471,7 +2496,7 @@ export const OPERATION_DEFINITIONS = { memberPath: 'trackChanges.get', description: 'Retrieve a single tracked change by ID.', expectedResult: - 'Returns a TrackChangeInfo object with the change type, author, date, affected content, and raw imported Word OOXML revision IDs (`w:id`) when available.', + 'Returns a TrackChangeInfo object with the change type (`insert`, `delete`, `replacement`, `format`), author, date, affected content, and raw imported Word OOXML revision IDs (`w:id`) when available.', requiresDocumentContext: true, metadata: readOperation({ idempotency: 'idempotent', @@ -2482,15 +2507,23 @@ export const OPERATION_DEFINITIONS = { }, 'trackChanges.decide': { memberPath: 'trackChanges.decide', - description: 'Accept or reject a tracked change (by ID or scope: all).', + description: 'Accept or reject tracked changes by ID, range, or scope: all (optionally filtered by story).', expectedResult: - 'Returns a Receipt confirming the decision was applied; reports NO_OP if the change was already resolved.', + 'Returns a Receipt confirming the decision was applied; reports NO_OP if the change was already resolved and typed failures for unsupported or denied tracked-change decisions.', requiresDocumentContext: true, metadata: mutationOperation({ idempotency: 'conditional', supportsDryRun: false, supportsTrackedMode: false, - possibleFailureCodes: ['NO_OP'], + possibleFailureCodes: [ + 'NO_OP', + 'INVALID_TARGET', + 'TARGET_NOT_FOUND', + 'CAPABILITY_UNAVAILABLE', + 'PERMISSION_DENIED', + 'PRECONDITION_FAILED', + 'COMMENT_CASCADE_PARTIAL', + ], throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_INPUT', 'INVALID_TARGET'], }), referenceDocPath: 'track-changes/decide.mdx', diff --git a/packages/document-api/src/contract/operation-registry.ts b/packages/document-api/src/contract/operation-registry.ts index 2bd05fab8f..0f78543249 100644 --- a/packages/document-api/src/contract/operation-registry.ts +++ b/packages/document-api/src/contract/operation-registry.ts @@ -42,10 +42,11 @@ import type { InsertInput } from '../insert/insert.js'; import type { ReplaceInput } from '../replace/replace.js'; import type { DeleteInput } from '../delete/delete.js'; import type { MutationOptions, RevisionGuardOptions } from '../write/write.js'; -import type { FormatInlineAliasInput, StyleApplyInput } from '../format/format.js'; +import type { FormatInlineAliasInput, FormatRangeInput, StyleApplyInput } from '../format/format.js'; import type { InlineRunPatchKey } from '../format/inline-run-patch.js'; import type { StylesApplyInput, StylesApplyOptions, StylesApplyReceipt } from '../styles/index.js'; import type { + CommentsCreateReceipt, CommentsCreateInput, CommentsPatchInput, CommentsDeleteInput, @@ -571,6 +572,7 @@ export interface OperationRegistry extends FormatInlineAliasOperationRegistry { insert: { input: InsertInput; options: MutationOptions; output: SDMutationReceipt }; replace: { input: ReplaceInput; options: MutationOptions; output: SDMutationReceipt }; delete: { input: DeleteInput; options: MutationOptions; output: TextMutationReceipt }; + formatRange: { input: FormatRangeInput; options: MutationOptions; output: TextMutationReceipt }; // --- blocks.* --- 'blocks.list': { input: BlocksListInput | undefined; options: never; output: BlocksListResult }; @@ -867,7 +869,7 @@ export interface OperationRegistry extends FormatInlineAliasOperationRegistry { }; // --- comments.* --- - 'comments.create': { input: CommentsCreateInput; options: RevisionGuardOptions; output: Receipt }; + 'comments.create': { input: CommentsCreateInput; options: RevisionGuardOptions; output: CommentsCreateReceipt }; 'comments.patch': { input: CommentsPatchInput; options: RevisionGuardOptions; output: Receipt }; 'comments.delete': { input: CommentsDeleteInput; options: RevisionGuardOptions; output: Receipt }; 'comments.get': { input: GetCommentInput; options: never; output: CommentInfo }; diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index c0a919e361..6d4ab5188a 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -273,6 +273,23 @@ const SHARED_DEFS: Record = { }, ['kind', 'start', 'end'], ), + CommentTrackedChangeTarget: objectSchema( + { + kind: { const: 'trackedChange' }, + trackedChangeId: { type: 'string' }, + story: ref('StoryLocator'), + }, + ['trackedChangeId'], + ), + CommentTrackedChangeLink: objectSchema({ + trackedChange: { const: true }, + trackedChangeType: { enum: ['insert', 'delete', 'format'] }, + trackedChangeDisplayType: { type: ['string', 'null'] }, + trackedChangeStory: { oneOf: [ref('StoryLocator'), { type: 'null' }] }, + trackedChangeAnchorKey: { type: ['string', 'null'] }, + trackedChangeText: { type: ['string', 'null'] }, + deletedText: { type: ['string', 'null'] }, + }), TargetLocator: { oneOf: [ objectSchema({ target: ref('SelectionTarget') }, ['target']), @@ -428,6 +445,16 @@ const SHARED_DEFS: Record = { }, ['success'], ), + CommentsCreateSuccess: objectSchema( + { + success: { const: true }, + id: { type: 'string' }, + inserted: arraySchema(ref('EntityAddress')), + updated: arraySchema(ref('EntityAddress')), + removed: arraySchema(ref('EntityAddress')), + }, + ['success', 'id'], + ), ReceiptFailure: objectSchema( { code: { type: 'string' }, @@ -594,6 +621,7 @@ const inlineAnchorSchema = ref('InlineAnchor'); const targetKindSchema = ref('TargetKind'); const textAddressSchema = ref('TextAddress'); const textTargetSchema = ref('TextTarget'); +const commentTrackedChangeTargetSchema = ref('CommentTrackedChangeTarget'); const blockNodeAddressSchema = ref('BlockNodeAddress'); const deletableBlockNodeAddressSchema = ref('DeletableBlockNodeAddress'); const tableAddressSchema = ref('TableAddress'); @@ -613,11 +641,13 @@ const commentAddressSchema = ref('CommentAddress'); const trackedChangeAddressSchema = ref('TrackedChangeAddress'); const entityAddressSchema = ref('EntityAddress'); const selectionTargetSchema = ref('SelectionTarget'); +const commentTrackedChangeLinkSchema = ref('CommentTrackedChangeLink'); const targetLocatorSchema = ref('TargetLocator'); const deleteBehaviorSchema = ref('DeleteBehavior'); const resolvedHandleSchema = ref('ResolvedHandle'); const pageInfoSchema = ref('PageInfo'); const receiptSuccessSchema = ref('ReceiptSuccess'); +const commentsCreateSuccessSchema = ref('CommentsCreateSuccess'); const textMutationRangeSchema = ref('TextMutationRange'); const textMutationResolutionSchema = ref('TextMutationResolution'); const textMutationSuccessSchema = ref('TextMutationSuccess'); @@ -746,6 +776,12 @@ function receiptResultSchemaFor(operationId: OperationId): JsonSchema { }; } +function commentsCreateResultSchemaFor(operationId: OperationId): JsonSchema { + return { + oneOf: [commentsCreateSuccessSchema, receiptFailureResultSchemaFor(operationId)], + }; +} + function textMutationFailureSchemaFor(operationId: OperationId): JsonSchema { return objectSchema( { @@ -1456,6 +1492,14 @@ const commentInfoSchema = objectSchema( createdTime: { type: 'number' }, creatorName: { type: 'string' }, creatorEmail: { type: 'string' }, + trackedChange: { type: 'boolean' }, + trackedChangeType: { enum: ['insert', 'delete', 'format'] }, + trackedChangeDisplayType: { type: ['string', 'null'] }, + trackedChangeStory: { oneOf: [storyLocatorSchema, { type: 'null' }] }, + trackedChangeAnchorKey: { type: ['string', 'null'] }, + trackedChangeText: { type: ['string', 'null'] }, + deletedText: { type: ['string', 'null'] }, + trackedChangeLink: { oneOf: [commentTrackedChangeLinkSchema, { type: 'null' }] }, }, ['address', 'commentId', 'status'], ); @@ -1473,6 +1517,14 @@ const commentDomainItemSchema = discoveryItemSchema( createdTime: { type: 'number' }, creatorName: { type: 'string' }, creatorEmail: { type: 'string' }, + trackedChange: { type: 'boolean' }, + trackedChangeType: { enum: ['insert', 'delete', 'format'] }, + trackedChangeDisplayType: { type: ['string', 'null'] }, + trackedChangeStory: { oneOf: [storyLocatorSchema, { type: 'null' }] }, + trackedChangeAnchorKey: { type: ['string', 'null'] }, + trackedChangeText: { type: ['string', 'null'] }, + deletedText: { type: ['string', 'null'] }, + trackedChangeLink: { oneOf: [commentTrackedChangeLinkSchema, { type: 'null' }] }, }, ['address', 'status'], ); @@ -1505,13 +1557,17 @@ const trackChangeInfoSchema = objectSchema( { address: trackedChangeAddressSchema, id: { type: 'string' }, - type: { enum: ['insert', 'delete', 'format'] }, + type: { enum: ['insert', 'delete', 'replacement', 'format'] }, + grouping: { enum: ['standalone', 'replacement-pair', 'unknown'] }, + pairedWithChangeId: { type: ['string', 'null'] }, wordRevisionIds: trackChangeWordRevisionIdsSchema, author: { type: 'string' }, authorEmail: { type: 'string' }, authorImage: { type: 'string' }, date: { type: 'string' }, excerpt: { type: 'string' }, + insertedText: { type: 'string' }, + deletedText: { type: 'string' }, }, ['address', 'id', 'type'], ); @@ -1519,13 +1575,17 @@ const trackChangeInfoSchema = objectSchema( const trackChangeDomainItemSchema = discoveryItemSchema( { address: trackedChangeAddressSchema, - type: { enum: ['insert', 'delete', 'format'] }, + type: { enum: ['insert', 'delete', 'replacement', 'format'] }, + grouping: { enum: ['standalone', 'replacement-pair', 'unknown'] }, + pairedWithChangeId: { type: ['string', 'null'] }, wordRevisionIds: trackChangeWordRevisionIdsSchema, author: { type: 'string' }, authorEmail: { type: 'string' }, authorImage: { type: 'string' }, date: { type: 'string' }, excerpt: { type: 'string' }, + insertedText: { type: 'string' }, + deletedText: { type: 'string' }, }, ['address', 'type'], ); @@ -3105,9 +3165,9 @@ const operationSchemas: Record = { }, type: { type: 'string', - enum: ['insert', 'delete', 'format'], + enum: ['insert', 'delete', 'replacement', 'format'], description: - "Aggregate type at the entity level. In paired replacement mode, a delete+insert pair shares one entity and this collapses to 'insert'; per-half type lives on block.textSpans[].trackedChanges[].", + "Entity-level type. In paired replacement mode, a delete+insert pair shares one entity with type 'replacement'; per-half type lives on block.textSpans[].trackedChanges[].", }, blockIds: { type: 'array', @@ -3212,6 +3272,34 @@ const operationSchemas: Record = { success: textMutationSuccessSchema, failure: textMutationFailureSchemaFor('delete'), }, + formatRange: { + input: { + ...targetLocatorWithPayload( + { + in: storyLocatorSchema, + properties: { + ...buildInlineRunPatchSchema(), + description: + 'Inline formatting properties to apply. Set a property to apply it, use null to clear it. Example: {bold: true, italic: true} or {bold: null} to remove bold.', + }, + changeMode: { + enum: ['direct', 'tracked'], + description: "Edit mode: 'direct' applies changes immediately, 'tracked' records tracked formatting.", + }, + dryRun: { type: 'boolean', description: 'Preview the result without mutating the document.' }, + expectedRevision: { + type: 'string', + description: + 'Document revision for optimistic concurrency. Mutation fails if document was modified since this revision.', + }, + }, + ['properties'], + ), + }, + output: textMutationResultSchemaFor('formatRange'), + success: textMutationSuccessSchema, + failure: textMutationFailureSchemaFor('formatRange'), + }, 'format.apply': { input: { ...targetLocatorWithPayload( @@ -4872,9 +4960,9 @@ const operationSchemas: Record = { { text: { type: 'string', description: 'Comment text content.' }, target: { - oneOf: [textAddressSchema, textTargetSchema], + oneOf: [textAddressSchema, textTargetSchema, selectionTargetSchema, commentTrackedChangeTargetSchema], description: - "Text range to anchor the comment. Accepts either a single-block TextAddress {kind:'text', blockId, range} or a multi-segment TextTarget {kind:'text', segments:[{blockId, range}, ...]} for selections that span blocks.", + "Comment target. Accepts a TextAddress, TextTarget, SelectionTarget, or {trackedChangeId, kind?:'trackedChange'} to anchor directly on tracked content.", }, parentCommentId: { type: 'string', @@ -4883,8 +4971,8 @@ const operationSchemas: Record = { }, ['text'], ), - output: receiptResultSchemaFor('comments.create'), - success: receiptSuccessSchema, + output: commentsCreateResultSchemaFor('comments.create'), + success: commentsCreateSuccessSchema, failure: receiptFailureResultSchemaFor('comments.create'), }, 'comments.patch': { @@ -4892,7 +4980,9 @@ const operationSchemas: Record = { { commentId: { type: 'string' }, text: { type: 'string', description: 'Updated comment text.' }, - target: textAddressSchema, + target: { + oneOf: [textAddressSchema, textTargetSchema, selectionTargetSchema, commentTrackedChangeTargetSchema], + }, status: { enum: ['resolved', 'active'], description: @@ -4935,8 +5025,8 @@ const operationSchemas: Record = { limit: { type: 'integer', description: 'Maximum number of tracked changes to return.' }, offset: { type: 'integer', description: 'Number of tracked changes to skip for pagination.' }, type: { - enum: ['insert', 'delete', 'format'], - description: "Filter by change type: 'insert', 'delete', or 'format'.", + enum: ['insert', 'delete', 'replacement', 'format'], + description: "Filter by change type: 'insert', 'delete', 'replacement', or 'format'.", }, in: { oneOf: [storyLocatorSchema, { const: 'all' }], @@ -4958,7 +5048,26 @@ const operationSchemas: Record = { target: { oneOf: [ objectSchema({ id: { type: 'string' }, story: storyLocatorSchema }, ['id']), - objectSchema({ scope: { enum: ['all'] } }, ['scope']), + objectSchema( + { + kind: { const: 'range' }, + range: textTargetSchema, + story: storyLocatorSchema, + part: { type: 'string', description: 'Optional part discriminator for the range target.' }, + }, + ['kind', 'range'], + ), + objectSchema( + { + scope: { enum: ['all'] }, + story: { + oneOf: [storyLocatorSchema, { const: 'all' }], + description: + "Optional explicit bulk filter. Omit or pass 'all' to target every revision-capable story, or pass a StoryLocator to scope the decision to one story.", + }, + }, + ['scope'], + ), ], }, }, diff --git a/packages/document-api/src/format/format.ts b/packages/document-api/src/format/format.ts index a69d08a1b2..f9107bcc30 100644 --- a/packages/document-api/src/format/format.ts +++ b/packages/document-api/src/format/format.ts @@ -67,6 +67,23 @@ export type StyleApplyInput = TargetLocator & { in?: StoryLocator; }; +/** + * Legacy root-level alias input for `doc.formatRange(...)`. + * + * Kept for SDK compatibility while routing through the canonical + * `format.apply` implementation. + */ +export type FormatRangeInput = TargetLocator & { + target?: SelectionTarget; + ref?: string; + properties: InlineRunPatch; + /** Target a specific document story (body, header, footer, footnote, endnote). */ + in?: StoryLocator; + changeMode?: MutationOptions['changeMode']; + dryRun?: boolean; + expectedRevision?: string; +}; + /** * Named alias for MutationOptions on format.apply. * diff --git a/packages/document-api/src/index.test.ts b/packages/document-api/src/index.test.ts index 2e27a3c8df..8e35d8bdd0 100644 --- a/packages/document-api/src/index.test.ts +++ b/packages/document-api/src/index.test.ts @@ -87,9 +87,17 @@ function makeInfoAdapter(result?: Partial) { function makeCommentsAdapter(): CommentsAdapter { return { - add: mock(() => ({ success: true as const })), + add: mock(() => ({ + success: true as const, + id: 'c1', + inserted: [{ kind: 'entity' as const, entityType: 'comment' as const, entityId: 'c1' }], + })), edit: mock(() => ({ success: true as const })), - reply: mock(() => ({ success: true as const })), + reply: mock(() => ({ + success: true as const, + id: 'c2', + inserted: [{ kind: 'entity' as const, entityType: 'comment' as const, entityId: 'c2' }], + })), move: mock(() => ({ success: true as const })), resolve: mock(() => ({ success: true as const })), remove: mock(() => ({ success: true as const })), @@ -161,6 +169,7 @@ function makeTrackChangesAdapter(): TrackChangesAdapter { reject: mock((_input) => ({ success: true as const })), acceptAll: mock((_input) => ({ success: true as const })), rejectAll: mock((_input) => ({ success: true as const })), + decideRange: mock((_input) => ({ success: true as const })), }; } @@ -543,6 +552,7 @@ describe('createDocumentApi', () => { const receipt = api.comments.create(input); expect(receipt.success).toBe(true); + expect(receipt.id).toBe('c1'); expect(commentsAdpt.add).toHaveBeenCalledWith(input, undefined); }); @@ -566,6 +576,7 @@ describe('createDocumentApi', () => { const receipt = api.comments.create(input); expect(receipt.success).toBe(true); + expect(receipt.id).toBe('c2'); expect(commentsAdpt.reply).toHaveBeenCalledWith({ parentCommentId: 'c1', text: 'reply text' }, undefined); }); @@ -793,6 +804,34 @@ describe('createDocumentApi', () => { ); }); + it('delegates root formatRange to selectionMutation.execute with inline properties', () => { + const selectionAdpt = makeSelectionMutationAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(FIND_RESULT), + get: makeGetAdapter(), + getNode: makeGetNodeAdapter(PARAGRAPH_NODE_RESULT), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + selectionMutation: selectionAdpt, + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const target = { + kind: 'selection' as const, + start: { kind: 'text' as const, blockId: 'p1', offset: 0 }, + end: { kind: 'text' as const, blockId: 'p1', offset: 2 }, + }; + api.formatRange({ target, properties: { bold: true }, changeMode: 'tracked', dryRun: true }); + expect(selectionAdpt.execute).toHaveBeenCalledWith( + { kind: 'format', target, ref: undefined, inline: { bold: true } }, + { changeMode: 'tracked', dryRun: true }, + ); + }); + it('delegates trackChanges read operations', () => { const trackAdpt = makeTrackChangesAdapter(); const footnoteStory = { kind: 'story', storyType: 'footnote', noteId: '5' } as const; @@ -843,20 +882,72 @@ describe('createDocumentApi', () => { const acceptResult = api.trackChanges.decide({ decision: 'accept', target: { id: 'tc-1' } }); const rejectResult = api.trackChanges.decide({ decision: 'reject', target: { id: 'tc-1' } }); api.trackChanges.decide({ decision: 'accept', target: { id: 'tc-2', story: footnoteStory } }); + api.trackChanges.decide({ + decision: 'accept', + target: { + kind: 'range', + range: { kind: 'text', segments: [{ blockId: 'p1', range: { start: 0, end: 2 } }] }, + story: footnoteStory, + }, + }); const acceptAllResult = api.trackChanges.decide({ decision: 'accept', target: { scope: 'all' } }); + const acceptAllInStoryResult = api.trackChanges.decide({ + decision: 'accept', + target: { scope: 'all', story: footnoteStory }, + }); const rejectAllResult = api.trackChanges.decide({ decision: 'reject', target: { scope: 'all' } }); expect(acceptResult.success).toBe(true); expect(rejectResult.success).toBe(true); expect(acceptAllResult.success).toBe(true); + expect(acceptAllInStoryResult.success).toBe(true); expect(rejectAllResult.success).toBe(true); expect(trackAdpt.accept).toHaveBeenCalledWith({ id: 'tc-1' }, undefined); expect(trackAdpt.reject).toHaveBeenCalledWith({ id: 'tc-1' }, undefined); expect(trackAdpt.accept).toHaveBeenCalledWith({ id: 'tc-2', story: footnoteStory }, undefined); + expect(trackAdpt.decideRange).toHaveBeenCalledWith( + { + decision: 'accept', + range: { kind: 'text', segments: [{ blockId: 'p1', range: { start: 0, end: 2 } }] }, + story: footnoteStory, + }, + undefined, + ); expect(trackAdpt.acceptAll).toHaveBeenCalledWith({}, undefined); + expect(trackAdpt.acceptAll).toHaveBeenCalledWith({ story: footnoteStory }, undefined); expect(trackAdpt.rejectAll).toHaveBeenCalledWith({}, undefined); }); + it('returns CAPABILITY_UNAVAILABLE when trackChanges.decide range support is missing', () => { + const trackAdpt = makeTrackChangesAdapter(); + delete trackAdpt.decideRange; + const api = createDocumentApi({ + find: makeFindAdapter(FIND_RESULT), + get: makeGetAdapter(), + getNode: makeGetNodeAdapter(PARAGRAPH_NODE_RESULT), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + selectionMutation: makeSelectionMutationAdapter(), + trackChanges: trackAdpt, + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const result = api.trackChanges.decide({ + decision: 'reject', + target: { kind: 'range', range: { kind: 'text', segments: [{ blockId: 'p1', range: { start: 0, end: 2 } }] } }, + }); + + expect(result).toMatchObject({ + success: false, + failure: { + code: 'CAPABILITY_UNAVAILABLE', + }, + }); + }); + it('delegates history.get to the history adapter', () => { const historyAdpt = makeHistoryAdapter(); const api = createDocumentApi({ @@ -965,7 +1056,7 @@ describe('createDocumentApi', () => { expectError( () => api.trackChanges.decide({ decision: 'accept', target: 'tc-1' } as any), 'INVALID_TARGET', - '{ id: string } or { scope: "all" }', + '{ id }, { kind: "range", range }, or { scope: "all" }', ); }); @@ -974,7 +1065,7 @@ describe('createDocumentApi', () => { expectError( () => api.trackChanges.decide({ decision: 'accept', target: null } as any), 'INVALID_TARGET', - '{ id: string } or { scope: "all" }', + '{ id }, { kind: "range", range }, or { scope: "all" }', ); }); @@ -983,7 +1074,7 @@ describe('createDocumentApi', () => { expectError( () => api.trackChanges.decide({ decision: 'accept', target: { foo: 'bar' } } as any), 'INVALID_TARGET', - '{ id: string } or { scope: "all" }', + '{ id }, { kind: "range", range }, or { scope: "all" }', ); }); @@ -992,7 +1083,16 @@ describe('createDocumentApi', () => { expectError( () => api.trackChanges.decide({ decision: 'accept', target: { id: '' } } as any), 'INVALID_TARGET', - '{ id: string } or { scope: "all" }', + 'non-empty id', + ); + }); + + it('rejects ambiguous targets that mix id and scope', () => { + const api = makeApi(); + expectError( + () => api.trackChanges.decide({ decision: 'accept', target: { id: 'tc-1', scope: 'all' } } as any), + 'INVALID_TARGET', + 'exactly one', ); }); }); @@ -2032,6 +2132,24 @@ describe('createDocumentApi', () => { expect(result.success).toBe(true); }); + it('accepts SelectionTarget', () => { + const api = makeApi(); + const target = { + kind: 'selection' as const, + start: { kind: 'text' as const, blockId: 'p1', offset: 0 }, + end: { kind: 'text' as const, blockId: 'p1', offset: 5 }, + }; + const result = api.comments.create({ target, text: 'comment' }); + expect(result.success).toBe(true); + }); + + it('accepts tracked-change target', () => { + const api = makeApi(); + const target = { kind: 'trackedChange' as const, trackedChangeId: 'tc-1' }; + const result = api.comments.create({ target, text: 'comment' }); + expect(result.success).toBe(true); + }); + it('accepts reply without target (parentCommentId only)', () => { const api = makeApi(); const result = api.comments.create({ parentCommentId: 'c1', text: 'reply' }); @@ -2088,7 +2206,7 @@ describe('createDocumentApi', () => { const api = makeApi(); expectValidationError( () => api.comments.create({ target: { kind: 'text', blockId: 'p1' }, text: 'comment' } as any), - 'target must be a TextAddress or TextTarget object', + 'SelectionTarget, or tracked-change target', ); }); @@ -2143,6 +2261,58 @@ describe('createDocumentApi', () => { api.comments.create({ target, text: 'comment' }); expect(commentsAdpt.add).toHaveBeenCalledWith({ target, text: 'comment' }, undefined); }); + + it('forwards SelectionTarget unchanged', () => { + const commentsAdpt = makeCommentsAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(FIND_RESULT), + get: makeGetAdapter(), + getNode: makeGetNodeAdapter(PARAGRAPH_NODE_RESULT), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + capabilities: makeCapabilitiesAdapter(), + comments: commentsAdpt, + write: makeWriteAdapter(), + selectionMutation: makeSelectionMutationAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const target = { + kind: 'selection' as const, + start: { kind: 'text' as const, blockId: 'p1', offset: 1 }, + end: { kind: 'text' as const, blockId: 'p1', offset: 4 }, + }; + api.comments.create({ target, text: 'comment' }); + expect(commentsAdpt.add).toHaveBeenCalledWith({ target, text: 'comment' }, undefined); + }); + + it('forwards tracked-change target unchanged', () => { + const commentsAdpt = makeCommentsAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(FIND_RESULT), + get: makeGetAdapter(), + getNode: makeGetNodeAdapter(PARAGRAPH_NODE_RESULT), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + capabilities: makeCapabilitiesAdapter(), + comments: commentsAdpt, + write: makeWriteAdapter(), + selectionMutation: makeSelectionMutationAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const target = { + kind: 'trackedChange' as const, + trackedChangeId: 'tc-1', + story: { kind: 'story' as const, storyType: 'body' as const }, + }; + api.comments.create({ target, text: 'comment' }); + expect(commentsAdpt.add).toHaveBeenCalledWith({ target, text: 'comment' }, undefined); + }); }); describe('selection adapter', () => { @@ -2220,6 +2390,24 @@ describe('createDocumentApi', () => { expect(result.success).toBe(true); }); + it('accepts SelectionTarget', () => { + const api = makeApi(); + const target = { + kind: 'selection' as const, + start: { kind: 'text' as const, blockId: 'p1', offset: 0 }, + end: { kind: 'text' as const, blockId: 'p1', offset: 5 }, + }; + const result = api.comments.patch({ commentId: 'c1', target }); + expect(result.success).toBe(true); + }); + + it('accepts tracked-change target', () => { + const api = makeApi(); + const target = { trackedChangeId: 'tc-1' }; + const result = api.comments.patch({ commentId: 'c1', target }); + expect(result.success).toBe(true); + }); + it('accepts text-only patch (no target needed)', () => { const api = makeApi(); const result = api.comments.patch({ commentId: 'c1', text: 'updated' }); @@ -2271,7 +2459,7 @@ describe('createDocumentApi', () => { const api = makeApi(); expectValidationError( () => api.comments.patch({ commentId: 'c1', target: { kind: 'text', blockId: 'p1' } } as any), - 'target must be a text address object', + 'SelectionTarget, or tracked-change target', ); }); @@ -2341,6 +2529,54 @@ describe('createDocumentApi', () => { undefined, ); }); + + it('forwards SelectionTarget to adapter.move unchanged', () => { + const commentsAdpt = makeCommentsAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(FIND_RESULT), + get: makeGetAdapter(), + getNode: makeGetNodeAdapter(PARAGRAPH_NODE_RESULT), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + capabilities: makeCapabilitiesAdapter(), + comments: commentsAdpt, + write: makeWriteAdapter(), + selectionMutation: makeSelectionMutationAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const target = { + kind: 'selection' as const, + start: { kind: 'text' as const, blockId: 'p1', offset: 1 }, + end: { kind: 'text' as const, blockId: 'p1', offset: 4 }, + }; + api.comments.patch({ commentId: 'c1', target }); + expect(commentsAdpt.move).toHaveBeenCalledWith({ commentId: 'c1', target }, undefined); + }); + + it('forwards tracked-change target to adapter.move unchanged', () => { + const commentsAdpt = makeCommentsAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(FIND_RESULT), + get: makeGetAdapter(), + getNode: makeGetNodeAdapter(PARAGRAPH_NODE_RESULT), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + capabilities: makeCapabilitiesAdapter(), + comments: commentsAdpt, + write: makeWriteAdapter(), + selectionMutation: makeSelectionMutationAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const target = { kind: 'trackedChange' as const, trackedChangeId: 'tc-1' }; + api.comments.patch({ commentId: 'c1', target }); + expect(commentsAdpt.move).toHaveBeenCalledWith({ commentId: 'c1', target }, undefined); + }); }); describe('create.* location validation', () => { diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index 5f09bdf468..8607695008 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -69,9 +69,17 @@ import type { TrackChangesListResult, ExtractResult, } from './types/index.js'; -import type { CommentInfo, CommentsListQuery, CommentsListResult } from './comments/comments.types.js'; +import type { + CommentInfo, + CommentTarget, + CommentsListQuery, + CommentsListResult, + CommentTrackedChangeLink, + TrackedChangeCommentTarget, +} from './comments/comments.types.js'; import type { CommentsAdapter, + CommentsCreateReceipt, CommentsApi, CommentsCreateInput, CommentsPatchInput, @@ -90,6 +98,7 @@ import { executeFind, type FindAdapter } from './find/find.js'; import type { SDFindInput, SDFindResult, SDGetInput, SDNodeResult } from './types/sd-envelope.js'; import type { FormatApi, + FormatRangeInput, FormatInlineAliasApi, FormatInlineAliasInput, FormatStrikethroughInput, @@ -957,6 +966,7 @@ export type { FormatInlineAliasInput, FormatBoldInput, FormatItalicInput, + FormatRangeInput, FormatUnderlineInput, FormatStrikethroughInput, StyleApplyInput, @@ -1028,7 +1038,9 @@ export type { TrackChangesRejectInput, TrackChangesAcceptAllInput, TrackChangesRejectAllInput, + TrackChangesRangeInput, ReviewDecideInput, + ReviewDecisionTarget, } from './track-changes/track-changes.js'; export type { BlocksAdapter } from './blocks/blocks.js'; export type { ImagesAdapter, ImagesApi, CreateImageAdapter } from './images/images.js'; @@ -1443,6 +1455,7 @@ export type { SectionsSetVerticalAlignInput, } from './sections/sections.types.js'; export type { + CommentsCreateReceipt, CommentsCreateInput, CommentsPatchInput, CommentsDeleteInput, @@ -1460,7 +1473,14 @@ export type { GoToCommentInput, SetCommentActiveInput, } from './comments/comments.js'; -export type { CommentInfo, CommentsListQuery, CommentsListResult } from './comments/comments.types.js'; +export type { + CommentInfo, + CommentTarget, + CommentsListQuery, + CommentsListResult, + CommentTrackedChangeLink, + TrackedChangeCommentTarget, +} from './comments/comments.types.js'; export { DocumentApiValidationError } from './errors.js'; export { textReceiptToSDReceipt, buildStructuralReceipt } from './receipt-bridge.js'; export type { StructuralReceiptParams } from './receipt-bridge.js'; @@ -1641,6 +1661,12 @@ export interface DocumentApi { * Formatting operations (inline and paragraph direct formatting). */ format: FormatApi & { paragraph: ParagraphFormatApi }; + /** + * Legacy root-level alias for inline range formatting. + * + * Routes to {@link DocumentApi.format.apply}. + */ + formatRange(input: FormatRangeInput, options?: MutationOptions): TextMutationReceipt; /** * Stylesheet operations (docDefaults, style definitions, paragraph style references). */ @@ -2018,7 +2044,7 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { return executeClearContent(adapters.clearContent, input, options); }, comments: { - create(input: CommentsCreateInput, options?: RevisionGuardOptions): Receipt { + create(input: CommentsCreateInput, options?: RevisionGuardOptions): CommentsCreateReceipt { return executeCommentsCreate(adapters.comments, input, options); }, patch(input: CommentsPatchInput, options?: RevisionGuardOptions): Receipt { @@ -2043,6 +2069,22 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { delete(input: DeleteInput, options?: MutationOptions): TextMutationReceipt { return executeDelete(adapters.selectionMutation, input, options); }, + formatRange(input: FormatRangeInput, options?: MutationOptions): TextMutationReceipt { + const raw = input as unknown; + if (!raw || typeof raw !== 'object') { + return executeStyleApply(adapters.selectionMutation, raw as StyleApplyInput, options); + } + const { properties, changeMode, dryRun, expectedRevision, ...rest } = raw as FormatRangeInput; + const styleInput: StyleApplyInput = + typeof rest.ref === 'string' + ? { ref: rest.ref, inline: properties, ...(rest.in ? { in: rest.in } : {}) } + : { target: rest.target, inline: properties, ...(rest.in ? { in: rest.in } : {}) }; + return executeStyleApply(adapters.selectionMutation, styleInput, { + expectedRevision: expectedRevision ?? options?.expectedRevision, + changeMode: changeMode ?? options?.changeMode, + dryRun: dryRun ?? options?.dryRun, + }); + }, format: { ...inlineAliasApi, strikethrough(input: FormatStrikethroughInput, options?: MutationOptions): TextMutationReceipt { diff --git a/packages/document-api/src/invoke/invoke.test.ts b/packages/document-api/src/invoke/invoke.test.ts index 5fb7718f46..4488b03b15 100644 --- a/packages/document-api/src/invoke/invoke.test.ts +++ b/packages/document-api/src/invoke/invoke.test.ts @@ -62,9 +62,17 @@ function makeAdapters() { ), }; const commentsAdapter: CommentsAdapter = { - add: mock(() => ({ success: true as const })), + add: mock(() => ({ + success: true as const, + id: 'c1', + inserted: [{ kind: 'entity' as const, entityType: 'comment' as const, entityId: 'c1' }], + })), edit: mock(() => ({ success: true as const })), - reply: mock(() => ({ success: true as const })), + reply: mock(() => ({ + success: true as const, + id: 'c2', + inserted: [{ kind: 'entity' as const, entityType: 'comment' as const, entityId: 'c2' }], + })), move: mock(() => ({ success: true as const })), resolve: mock(() => ({ success: true as const })), remove: mock(() => ({ success: true as const })), diff --git a/packages/document-api/src/invoke/invoke.ts b/packages/document-api/src/invoke/invoke.ts index c3390ebb9c..403ed5703e 100644 --- a/packages/document-api/src/invoke/invoke.ts +++ b/packages/document-api/src/invoke/invoke.ts @@ -74,6 +74,7 @@ export function buildDispatchTable(api: DocumentApi): TypedDispatchTable { insert: (input, options) => api.insert(input, options), replace: (input, options) => api.replace(input, options), delete: (input, options) => api.delete(input, options), + formatRange: (input, options) => api.formatRange(input, options), // --- blocks.* --- 'blocks.list': (input) => api.blocks.list(input), diff --git a/packages/document-api/src/overview-examples.test.ts b/packages/document-api/src/overview-examples.test.ts index faa870bff2..7dffeca087 100644 --- a/packages/document-api/src/overview-examples.test.ts +++ b/packages/document-api/src/overview-examples.test.ts @@ -169,9 +169,17 @@ function makeParagraphsAdapter() { function makeCommentsAdapter() { return { - add: mock(() => ({ success: true as const })), + add: mock(() => ({ + success: true as const, + id: 'c1', + inserted: [{ kind: 'entity' as const, entityType: 'comment' as const, entityId: 'c1' }], + })), edit: mock(() => ({ success: true as const })), - reply: mock(() => ({ success: true as const })), + reply: mock(() => ({ + success: true as const, + id: 'c2', + inserted: [{ kind: 'entity' as const, entityType: 'comment' as const, entityId: 'c2' }], + })), move: mock(() => ({ success: true as const })), resolve: mock(() => ({ success: true as const })), remove: mock(() => ({ success: true as const })), diff --git a/packages/document-api/src/track-changes/track-changes.test.ts b/packages/document-api/src/track-changes/track-changes.test.ts index b6f9f9ca54..12cb75bc73 100644 --- a/packages/document-api/src/track-changes/track-changes.test.ts +++ b/packages/document-api/src/track-changes/track-changes.test.ts @@ -49,4 +49,71 @@ describe('executeTrackChangesDecide validation', () => { it('rejects missing target', () => { expect(() => executeTrackChangesDecide(stubAdapter(), { decision: 'accept' } as any)).toThrow(/target must be/); }); + + it('routes canonical range targets to decideRange', () => { + const adapter = { + ...stubAdapter(), + decideRange: mock(() => ({ success: true })), + }; + + const result = executeTrackChangesDecide(adapter, { + decision: 'accept', + target: { + kind: 'range', + range: { kind: 'text', segments: [{ blockId: 'p1', range: { start: 0, end: 2 } }] }, + }, + }); + + expect(result.success).toBe(true); + expect(adapter.decideRange).toHaveBeenCalledWith( + { + decision: 'accept', + range: { kind: 'text', segments: [{ blockId: 'p1', range: { start: 0, end: 2 } }] }, + }, + undefined, + ); + }); + + it('fails closed when canonical range targets are not supported by the adapter', () => { + const result = executeTrackChangesDecide(stubAdapter(), { + decision: 'reject', + target: { + kind: 'range', + range: { kind: 'text', segments: [{ blockId: 'p1', range: { start: 0, end: 2 } }] }, + }, + }); + + expect(result).toMatchObject({ + success: false, + failure: { code: 'CAPABILITY_UNAVAILABLE' }, + }); + }); + + it('routes scope: "all" targets with an explicit story filter to acceptAll/rejectAll', () => { + const adapter = stubAdapter(); + const footnoteStory = { kind: 'story', storyType: 'footnote', noteId: '5' } as const; + + const accept = executeTrackChangesDecide(adapter, { + decision: 'accept', + target: { scope: 'all', story: footnoteStory }, + }); + const reject = executeTrackChangesDecide(adapter, { + decision: 'reject', + target: { scope: 'all', story: footnoteStory }, + }); + + expect(accept.success).toBe(true); + expect(reject.success).toBe(true); + expect(adapter.acceptAll).toHaveBeenCalledWith({ story: footnoteStory }, undefined); + expect(adapter.rejectAll).toHaveBeenCalledWith({ story: footnoteStory }, undefined); + }); + + it('rejects ambiguous targets that mix id and scope', () => { + expect(() => + executeTrackChangesDecide(stubAdapter(), { + decision: 'accept', + target: { id: 'tc1', scope: 'all' }, + } as any), + ).toThrow(/exactly one/); + }); }); diff --git a/packages/document-api/src/track-changes/track-changes.ts b/packages/document-api/src/track-changes/track-changes.ts index 4dda0c2f5a..d4734b1ce1 100644 --- a/packages/document-api/src/track-changes/track-changes.ts +++ b/packages/document-api/src/track-changes/track-changes.ts @@ -1,7 +1,9 @@ import type { Receipt, TrackChangeInfo, TrackChangesListQuery, TrackChangesListResult } from '../types/index.js'; import type { StoryLocator } from '../types/story.types.js'; +import type { TextTarget } from '../types/address.js'; import type { RevisionGuardOptions } from '../write/write.js'; import { DocumentApiValidationError } from '../errors.js'; +import { validateStoryLocator } from '../validation/story-validator.js'; export type TrackChangesListInput = TrackChangesListQuery; @@ -23,19 +25,59 @@ export interface TrackChangesRejectInput { story?: StoryLocator; } -export type TrackChangesAcceptAllInput = Record; +export interface TrackChangesAcceptAllInput { + /** + * Optional explicit bulk filter. Omit or pass `'all'` to operate across + * every revision-capable story; pass a StoryLocator to scope the decision + * to one story. + */ + story?: StoryLocator | 'all'; +} + +export interface TrackChangesRejectAllInput { + /** + * Optional explicit bulk filter. Omit or pass `'all'` to operate across + * every revision-capable story; pass a StoryLocator to scope the decision + * to one story. + */ + story?: StoryLocator | 'all'; +} -export type TrackChangesRejectAllInput = Record; +/** + * Range target for partial-range decisions. + * + * `range` is a canonical {@link TextTarget}; the engine resolves the + * selected overlap with each affected logical tracked change and applies + * the {@link https://www.w3.org/TR/selection-api/ selection-style} partial + * resolution rules from the tracked-changes spec § 9. + */ +export interface TrackChangesRangeInput { + range: TextTarget; + /** Story containing the range. Omit for body (backward compatible). */ + story?: StoryLocator; +} // --------------------------------------------------------------------------- // trackChanges.decide: consolidated accept/reject operation // --------------------------------------------------------------------------- +/** + * Public decide target surface: + * - `{ id, story? }` for a single logical tracked change + * - `{ kind: 'range', range, story? }` for partial-range decisions + * - `{ scope: 'all', story? }` for bulk decisions, optionally filtered by story + * + * The executor also accepts internal legacy aliases (`kind: 'id'` / + * `kind: 'all'`) so JS-only callers keep working during the migration. + */ +export type ReviewDecisionTarget = + | { id: string; story?: StoryLocator } + | { kind: 'range'; range: TextTarget; story?: StoryLocator; part?: string } + | { scope: 'all'; story?: StoryLocator | 'all' }; + export type ReviewDecideInput = - | { decision: 'accept'; target: { id: string; story?: StoryLocator } } - | { decision: 'reject'; target: { id: string; story?: StoryLocator } } - | { decision: 'accept'; target: { scope: 'all' } } - | { decision: 'reject'; target: { scope: 'all' } }; + | { decision: 'accept'; target: ReviewDecisionTarget } + | { decision: 'reject'; target: ReviewDecisionTarget }; export interface TrackChangesAdapter { /** List tracked changes matching the given query. */ @@ -46,10 +88,21 @@ export interface TrackChangesAdapter { accept(input: TrackChangesAcceptInput, options?: RevisionGuardOptions): Receipt; /** Reject a tracked change, reverting it from the document. */ reject(input: TrackChangesRejectInput, options?: RevisionGuardOptions): Receipt; - /** Accept all tracked changes in the document. */ + /** Accept all tracked changes matching the requested bulk filter. */ acceptAll(input: TrackChangesAcceptAllInput, options?: RevisionGuardOptions): Receipt; - /** Reject all tracked changes in the document. */ + /** Reject all tracked changes matching the requested bulk filter. */ rejectAll(input: TrackChangesRejectAllInput, options?: RevisionGuardOptions): Receipt; + /** + * Accept or reject a tracked-change selection range. Adapters + * that have not been updated to handle `kind: 'range'` may return a + * `CAPABILITY_UNAVAILABLE` failure receipt; the document-api executor + * surfaces that to callers without falling back to the legacy id/all + * paths because their semantics are not equivalent. + */ + decideRange?( + input: { decision: 'accept' | 'reject' } & TrackChangesRangeInput, + options?: RevisionGuardOptions, + ): Receipt; } /** Public surface for trackChanges on DocumentApi. */ @@ -122,31 +175,100 @@ export function executeTrackChangesDecide( if (typeof input.target !== 'object' || input.target == null) { throw new DocumentApiValidationError( 'INVALID_TARGET', - 'trackChanges.decide target must be an object with { id: string } or { scope: "all" }.', + 'trackChanges.decide target must be an object with { id }, { kind: "range", range }, or { scope: "all" }.', { field: 'target', value: input.target }, ); } const target = input.target as Record; - const isAll = target.scope === 'all'; + const decision = input.decision as 'accept' | 'reject'; + const rawStory = target.story; - if (!isAll) { - if (typeof target.id !== 'string' || target.id.length === 0) { + if (rawStory !== undefined && rawStory !== 'all') { + validateStoryLocator(rawStory, 'target.story'); + } + + const story = rawStory as StoryLocator | undefined; + const bulkStory = rawStory as StoryLocator | 'all' | undefined; + + if ((target.scope === 'all' || target.kind === 'all') && (target.id !== undefined || target.kind === 'id')) { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + 'trackChanges.decide target must specify exactly one of { id }, { kind: "range", range }, or { scope: "all" }.', + { field: 'target', value: input.target }, + ); + } + + if (target.kind === 'range') { + if (target.id !== undefined || target.scope !== undefined) { throw new DocumentApiValidationError( 'INVALID_TARGET', - 'trackChanges.decide target must have { id: string } or { scope: "all" }.', + 'trackChanges.decide range targets must not include id or scope fields.', { field: 'target', value: input.target }, ); } + if (rawStory === 'all') { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + 'trackChanges.decide range targets do not support story: "all".', + { field: 'target.story', value: rawStory }, + ); + } + if (typeof adapter.decideRange !== 'function') { + return { + success: false, + failure: { + code: 'CAPABILITY_UNAVAILABLE', + message: 'trackChanges.decide range targets are not supported by the active adapter.', + details: { target: input.target }, + }, + }; + } + const range = target.range as TextTarget; + if ( + !range || + typeof range !== 'object' || + range.kind !== 'text' || + !Array.isArray(range.segments) || + range.segments.length === 0 + ) { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + 'trackChanges.decide target.kind = "range" requires a non-empty TextTarget range.', + { field: 'target.range', value: target.range }, + ); + } + return adapter.decideRange({ decision, range, ...(story ? { story } : {}) }, options); } - const story = (target as { story?: StoryLocator }).story; + if (target.scope === 'all' || target.kind === 'all') { + if (decision === 'accept') { + return adapter.acceptAll({ ...(bulkStory ? { story: bulkStory } : {}) }, options); + } + return adapter.rejectAll({ ...(bulkStory ? { story: bulkStory } : {}) }, options); + } - if (input.decision === 'accept') { - if (isAll) return adapter.acceptAll({} as TrackChangesAcceptAllInput, options); - return adapter.accept({ id: target.id as string, ...(story ? { story } : {}) }, options); + if (target.kind === 'id' || target.id !== undefined) { + if (rawStory === 'all') { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + 'trackChanges.decide id targets do not support story: "all".', + { field: 'target.story', value: rawStory }, + ); + } + if (typeof target.id !== 'string' || target.id.length === 0) { + throw new DocumentApiValidationError('INVALID_TARGET', 'trackChanges.decide id targets require a non-empty id.', { + field: 'target', + value: input.target, + }); + } + if (decision === 'accept') return adapter.accept({ id: target.id, ...(story ? { story } : {}) }, options); + return adapter.reject({ id: target.id, ...(story ? { story } : {}) }, options); } - if (isAll) return adapter.rejectAll({} as TrackChangesRejectAllInput, options); - return adapter.reject({ id: target.id as string, ...(story ? { story } : {}) }, options); + throw new DocumentApiValidationError( + 'INVALID_TARGET', + 'trackChanges.decide target must have { id }, { kind: "range", range }, or { scope: "all" }.', + { field: 'target', value: input.target }, + ); } diff --git a/packages/document-api/src/types/extract.types.ts b/packages/document-api/src/types/extract.types.ts index 3799337dc6..6d0dc06e49 100644 --- a/packages/document-api/src/types/extract.types.ts +++ b/packages/document-api/src/types/extract.types.ts @@ -43,7 +43,11 @@ export interface ExtractTableContext { export interface ExtractTextSpanTrackedChange { /** Tracked change entity ID. */ entityId: string; - /** The mark type carried on this run: insert, delete, or format. */ + /** + * The mark type carried on this run: insert, delete, or format. + * Entity-level paired replacements surface as `replacement` only on + * `ExtractResult.trackedChanges[]`, not on span marks. + */ type: TrackChangeType; } @@ -123,9 +127,9 @@ export interface ExtractTrackedChange { * In paired replacement mode (the default: set * `modules.trackChanges.replacements: 'independent'` for one entity per * `` / `` instead), a delete + insert pair shares one entity - * and the aggregate `type` collapses to `'insert'`. Per-half information - * lives on `block.textSpans[].trackedChanges[].type`, which is the source - * of truth for what each run actually represents. + * and `type` is `'replacement'`. Per-half information still lives on + * `block.textSpans[].trackedChanges[].type`, which is the source of truth + * for what each run actually represents. * * In independent mode every revision is its own entity and `type` is the * entity's only type. diff --git a/packages/document-api/src/types/receipt.ts b/packages/document-api/src/types/receipt.ts index c7a4f3ff4f..5963e7cb86 100644 --- a/packages/document-api/src/types/receipt.ts +++ b/packages/document-api/src/types/receipt.ts @@ -8,7 +8,9 @@ export type ReceiptFailureCode = | 'INVALID_TARGET' | 'TARGET_NOT_FOUND' | 'CAPABILITY_UNAVAILABLE' + | 'PERMISSION_DENIED' | 'REVISION_MISMATCH' + | 'COMMENT_CASCADE_PARTIAL' | 'MATCH_NOT_FOUND' | 'AMBIGUOUS_MATCH' | 'STYLE_CONFLICT' diff --git a/packages/document-api/src/types/sd-sections.ts b/packages/document-api/src/types/sd-sections.ts index c30969321b..bd7c23cce6 100644 --- a/packages/document-api/src/types/sd-sections.ts +++ b/packages/document-api/src/types/sd-sections.ts @@ -107,7 +107,7 @@ export interface SDCommentThread { export interface SDTrackedChange { id: string; - type: 'insert' | 'delete' | 'format'; + type: 'insert' | 'delete' | 'replacement' | 'format'; author?: string; date?: string; target?: SDAnchorRange; diff --git a/packages/document-api/src/types/track-changes.types.ts b/packages/document-api/src/types/track-changes.types.ts index ce4c380840..37e570d684 100644 --- a/packages/document-api/src/types/track-changes.types.ts +++ b/packages/document-api/src/types/track-changes.types.ts @@ -2,7 +2,22 @@ import type { TrackedChangeAddress } from './address.js'; import type { DiscoveryOutput } from './discovery.js'; import type { StoryLocator } from './story.types.js'; -export type TrackChangeType = 'insert' | 'delete' | 'format'; +export type TrackChangeType = 'insert' | 'delete' | 'replacement' | 'format'; +export type TrackChangeOverlapRelationship = 'parent' | 'child' | 'standalone'; +export type TrackChangeGrouping = 'standalone' | 'replacement-pair' | 'unknown'; +export type TrackChangeProvenanceOrigin = 'word' | 'google-docs' | 'superdoc' | 'custom' | 'unknown'; + +export interface TrackChangeOverlapLayer { + id: string; + type: TrackChangeType; + relationship: TrackChangeOverlapRelationship; +} + +export interface TrackChangeOverlapInfo { + visualLayers?: TrackChangeOverlapLayer[]; + preferredContextTargetId?: string; + preferredContextTarget?: TrackChangeOverlapLayer; +} /** * Scope marker used by {@link TrackChangesListQuery.in} to request changes @@ -32,13 +47,25 @@ export interface TrackChangeInfo { /** Convenience alias for `address.entityId`. */ id: string; type: TrackChangeType; + grouping?: TrackChangeGrouping; + pairedWithChangeId?: string | null; /** Raw imported Word OOXML revision IDs (`w:id`) from the source document when available. */ wordRevisionIds?: TrackChangeWordRevisionIds; + /** Overlap metadata for nested tracked changes that share the same text range. */ + overlap?: TrackChangeOverlapInfo; author?: string; authorEmail?: string; authorImage?: string; date?: string; excerpt?: string; + /** Inserted content for insertion-style changes when available. */ + insertedText?: string; + /** Deleted content for deletion-style changes when available. */ + deletedText?: string; + /** Source application or package family detected on import. */ + origin?: TrackChangeProvenanceOrigin; + /** True when this tracked change came from an imported document revision. */ + imported?: boolean; } export interface TrackChangesListQuery { @@ -60,13 +87,25 @@ export interface TrackChangesListQuery { export interface TrackChangeDomain { address: TrackedChangeAddress; type: TrackChangeType; + grouping?: TrackChangeGrouping; + pairedWithChangeId?: string | null; /** Raw imported Word OOXML revision IDs (`w:id`) from the source document when available. */ wordRevisionIds?: TrackChangeWordRevisionIds; + /** Overlap metadata for nested tracked changes that share the same text range. */ + overlap?: TrackChangeOverlapInfo; author?: string; authorEmail?: string; authorImage?: string; date?: string; excerpt?: string; + /** Inserted content for insertion-style changes when available. */ + insertedText?: string; + /** Deleted content for deletion-style changes when available. */ + deletedText?: string; + /** Source application or package family detected on import. */ + origin?: TrackChangeProvenanceOrigin; + /** True when this tracked change came from an imported document revision. */ + imported?: boolean; } /** diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 165b379d95..04e2a1a185 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -257,6 +257,8 @@ export type RunMark = { export type TrackedChangeMeta = { kind: TrackedChangeKind; id: string; + overlapParentId?: string; + relationship?: 'parent' | 'child' | 'standalone'; /** * Internal story key identifying which content story owns this tracked * change (`'body'`, `'hf:part:…'`, `'fn:…'`, `'en:…'`). @@ -356,6 +358,8 @@ export type TextRun = RunMarks & { }; /** Tracked-change metadata from ProseMirror marks. */ trackedChange?: TrackedChangeMeta; + /** All tracked-change layers on this run, preserving overlap order. */ + trackedChanges?: TrackedChangeMeta[]; /** * Run-level bidi signals preserved from the source DOCX (run rtl flag, * embedding/override directions). Direction-only - script formatting lives @@ -497,6 +501,7 @@ export type BreakRun = { pmEnd?: number; sdt?: SdtMetadata; trackedChange?: TrackedChangeMeta; + trackedChanges?: TrackedChangeMeta[]; }; /** diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index dfb4f2470a..fe25197cb6 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -16,6 +16,9 @@ import type { ResolvedLayout, TableBlock, TableMeasure, + TextRun, + TrackedChangeMeta, + TrackedChangesMode, } from '@superdoc/contracts'; const emptyResolved: ResolvedLayout = { version: 1, flowMode: 'paginated', pageGap: 0, pages: [] }; @@ -227,6 +230,39 @@ const buildSingleParagraphData = (blockId: string, runLength: number) => { return { paragraphMeasure, paragraphLayout }; }; +const createOverlapTrackedBlock = (mode: TrackedChangesMode): FlowBlock => { + const parentInsert: TrackedChangeMeta = { + kind: 'insert', + id: 'ins-parent', + relationship: 'parent', + author: 'Author 1', + }; + const childDelete: TrackedChangeMeta = { + kind: 'delete', + id: 'del-child', + relationship: 'child', + overlapParentId: parentInsert.id, + author: 'Reviewer 2', + }; + const run: TextRun = { + text: 'Overlapped text', + fontFamily: 'Arial', + fontSize: 16, + trackedChange: parentInsert, + trackedChanges: [parentInsert, childDelete], + }; + + return { + kind: 'paragraph', + id: `overlap-block-${mode}`, + runs: [run], + attrs: { + trackedChangesMode: mode, + trackedChangesEnabled: true, + }, + }; +}; + const createResolvedTestLine = (textLength: number, overrides: Partial = {}): Line => ({ fromRun: 0, fromChar: 0, @@ -4585,6 +4621,93 @@ describe('DomPainter', () => { expect(span.dataset.trackChangeAuthorEmail).toBe('reviewer@example.com'); }); + it('renders overlapping parent insert and child delete as an insertion with delete strikethrough metadata', () => { + const overlapBlock = createOverlapTrackedBlock('review'); + const { paragraphMeasure, paragraphLayout } = buildSingleParagraphData( + overlapBlock.id, + (overlapBlock.runs[0] as TextRun).text.length, + ); + + const painter = createTestPainter({ blocks: [overlapBlock], measures: [paragraphMeasure] }); + painter.paint(paragraphLayout, mount); + + const span = mount.querySelector('[data-track-change-id="ins-parent"]') as HTMLElement; + expect(span).toBeTruthy(); + expect(span.classList.contains('track-insert-dec')).toBe(true); + expect(span.classList.contains('track-delete-dec')).toBe(true); + expect(span.classList.contains('track-overlap-insert-delete-dec')).toBe(true); + expect(span.classList.contains('highlighted')).toBe(true); + expect(span.dataset.trackChangeKind).toBe('insert'); + expect(span.dataset.trackChangeIds).toBe('ins-parent,del-child'); + expect(span.dataset.trackChangeKinds).toBe('insert,delete'); + expect(span.dataset.trackChangePreferredTargetId).toBe('del-child'); + + const styleEl = document.head.querySelector('[data-superdoc-track-change-styles="true"]') as HTMLStyleElement; + expect(styleEl).toBeTruthy(); + const cssText = styleEl.textContent ?? ''; + const deleteRuleIndex = cssText.indexOf('.superdoc-layout .track-delete-dec.highlighted'); + const overlapRuleIndex = cssText.indexOf( + '.superdoc-layout .track-overlap-insert-delete-dec.track-insert-dec.track-delete-dec.highlighted', + ); + expect(deleteRuleIndex).toBeGreaterThanOrEqual(0); + expect(overlapRuleIndex).toBeGreaterThan(deleteRuleIndex); + + const overlapRule = + cssText.match( + /\.superdoc-layout \.track-overlap-insert-delete-dec\.track-insert-dec\.track-delete-dec\.highlighted\s*\{([\s\S]*?)\}/, + )?.[1] ?? ''; + expect(overlapRule).toContain('var(--sd-tracked-changes-insert-border, #00853d)'); + expect(overlapRule).toContain('background-color: var(--sd-tracked-changes-insert-background, #399c7222);'); + expect(overlapRule).toContain('color: var(--sd-tracked-changes-insert-text, currentColor);'); + expect(overlapRule).toMatch( + /text-decoration:\s*line-through\s+solid\s+var\(--sd-tracked-changes-delete-text,\s*#cb0e47\)\s+var\(--sd-tracked-changes-delete-decoration-thickness,\s*2px\)\s*!important;/, + ); + + const overlapFocusRule = + cssText.match( + /\.superdoc-layout \.track-overlap-insert-delete-dec\.track-insert-dec\.track-delete-dec\.highlighted\.track-change-focused\s*\{([\s\S]*?)\}/, + )?.[1] ?? ''; + expect(overlapFocusRule).toContain( + 'background-color: var(--sd-tracked-changes-insert-background-focused, #399c7244);', + ); + expect(overlapFocusRule).toContain('border-top-style: solid;'); + expect(overlapFocusRule).toContain('border-bottom-style: solid;'); + expect(overlapFocusRule).toMatch( + /text-decoration:\s*line-through\s+solid\s+var\(--sd-tracked-changes-delete-text,\s*#cb0e47\)\s+var\(--sd-tracked-changes-delete-decoration-thickness,\s*2px\)\s*!important;/, + ); + }); + + it('keeps overlap visibility driven by all revision layers', () => { + const originalBlock = createOverlapTrackedBlock('original'); + const finalBlock = createOverlapTrackedBlock('final'); + const { paragraphMeasure: originalMeasure, paragraphLayout: originalLayout } = buildSingleParagraphData( + originalBlock.id, + (originalBlock.runs[0] as TextRun).text.length, + ); + const { paragraphMeasure: finalMeasure, paragraphLayout: finalLayout } = buildSingleParagraphData( + finalBlock.id, + (finalBlock.runs[0] as TextRun).text.length, + ); + + const painter = createTestPainter({ blocks: [originalBlock], measures: [originalMeasure] }); + painter.paint(originalLayout, mount); + + let span = mount.querySelector('[data-track-change-id="ins-parent"]') as HTMLElement; + expect(span).toBeTruthy(); + expect(span.classList.contains('track-overlap-insert-delete-dec')).toBe(true); + expect(span.classList.contains('hidden')).toBe(true); + + painter.setData([finalBlock], [finalMeasure]); + painter.paint(finalLayout, mount); + + span = mount.querySelector('[data-track-change-id="ins-parent"]') as HTMLElement; + expect(span).toBeTruthy(); + expect(span.classList.contains('track-overlap-insert-delete-dec')).toBe(true); + expect(span.classList.contains('normal')).toBe(true); + expect(span.classList.contains('hidden')).toBe(true); + expect(span.classList.contains('highlighted')).toBe(false); + }); + it('stamps comment metadata on tracked-change text', () => { const trackedCommentBlock: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index f89b43b9d1..1610775655 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -43,6 +43,7 @@ import type { MathRun, TextRun, TrackedChangeKind, + TrackedChangeMeta, TrackedChangesMode, VectorShapeDrawing, VectorShapeStyle, @@ -995,6 +996,7 @@ const TRACK_CHANGE_BASE_CLASS: Record = { delete: 'track-delete-dec', format: 'track-format-dec', }; +const TRACK_CHANGE_OVERLAP_INSERT_DELETE_CLASS = 'track-overlap-insert-delete-dec'; // TRACK_CHANGE_FOCUSED_CLASS moved to CommentHighlightDecorator (super-editor). const TRACK_CHANGE_MODIFIER_CLASS: Record> = { @@ -1023,6 +1025,31 @@ type TrackedChangesRenderConfig = { enabled: boolean; }; +type InsertDeleteOverlap = { + parentInsert: TrackedChangeMeta; + childDelete: TrackedChangeMeta; +}; + +const getTrackedChangeLayers = (run: TextRun): TrackedChangeMeta[] => { + if (Array.isArray(run.trackedChanges) && run.trackedChanges.length > 0) { + return run.trackedChanges; + } + return run.trackedChange ? [run.trackedChange] : []; +}; + +const resolveInsertDeleteOverlap = (layers: TrackedChangeMeta[]): InsertDeleteOverlap | undefined => { + for (const parentInsert of layers) { + if (parentInsert.kind !== 'insert') { + continue; + } + const childDelete = layers.find((layer) => layer.kind === 'delete' && layer.overlapParentId === parentInsert.id); + if (childDelete) { + return { parentInsert, childDelete }; + } + } + return undefined; +}; + /** * Sanitize a URL to prevent XSS attacks. * Only allows http, https, mailto, tel, and internal anchors. @@ -7066,23 +7093,33 @@ export class DomPainter { } const textRun = run as TextRun; - const meta = textRun.trackedChange; - if (!meta) { + const layers = getTrackedChangeLayers(textRun); + if (layers.length === 0) { return; } + const overlap = resolveInsertDeleteOverlap(layers); + const meta = overlap?.parentInsert ?? textRun.trackedChange ?? layers[0]; - const baseClass = TRACK_CHANGE_BASE_CLASS[meta.kind]; - if (baseClass) { - elem.classList.add(baseClass); - } + layers.forEach((layer) => { + const baseClass = TRACK_CHANGE_BASE_CLASS[layer.kind]; + if (baseClass) { + elem.classList.add(baseClass); + } - const modifier = TRACK_CHANGE_MODIFIER_CLASS[meta.kind]?.[config.mode]; - if (modifier) { - elem.classList.add(modifier); - } + const modifier = TRACK_CHANGE_MODIFIER_CLASS[layer.kind]?.[config.mode]; + if (modifier) { + elem.classList.add(modifier); + } + }); + if (overlap) { + elem.classList.add(TRACK_CHANGE_OVERLAP_INSERT_DELETE_CLASS); + elem.dataset.trackChangePreferredTargetId = overlap.childDelete.id; + } elem.dataset.trackChangeId = meta.id; elem.dataset.trackChangeKind = meta.kind; + elem.dataset.trackChangeIds = layers.map((layer) => layer.id).join(','); + elem.dataset.trackChangeKinds = layers.map((layer) => layer.kind).join(','); elem.dataset.storyKey = meta.storyKey ?? 'body'; if (meta.author) { elem.dataset.trackChangeAuthor = meta.author; @@ -7864,19 +7901,23 @@ const deriveBlockVersion = (block: FlowBlock): string => { // Handle TextRun (kind is 'text' or undefined) const textRun = run as TextRun; - const trackedChangeVersion = textRun.trackedChange - ? [ - textRun.trackedChange.kind ?? '', - textRun.trackedChange.id ?? '', - textRun.trackedChange.storyKey ?? '', - textRun.trackedChange.author ?? '', - textRun.trackedChange.authorEmail ?? '', - textRun.trackedChange.authorImage ?? '', - textRun.trackedChange.date ?? '', - textRun.trackedChange.before ? JSON.stringify(textRun.trackedChange.before) : '', - textRun.trackedChange.after ? JSON.stringify(textRun.trackedChange.after) : '', - ].join(':') - : ''; + const trackedChangeVersion = getTrackedChangeLayers(textRun) + .map((trackedChange) => + [ + trackedChange.kind ?? '', + trackedChange.id ?? '', + trackedChange.storyKey ?? '', + trackedChange.overlapParentId ?? '', + trackedChange.relationship ?? '', + trackedChange.author ?? '', + trackedChange.authorEmail ?? '', + trackedChange.authorImage ?? '', + trackedChange.date ?? '', + trackedChange.before ? JSON.stringify(trackedChange.before) : '', + trackedChange.after ? JSON.stringify(trackedChange.after) : '', + ].join(':'), + ) + .join('|'); return [ textRun.text ?? '', textRun.fontFamily, diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index cd916debff..440334df98 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -310,6 +310,32 @@ const TRACK_CHANGE_STYLES = ` background-color: var(--sd-tracked-changes-delete-background-focused, #cb0e4744); } +.superdoc-layout .track-overlap-insert-delete-dec.track-insert-dec.track-delete-dec.highlighted { + border-top: var(--sd-tracked-changes-insert-border-width, 1px) dashed var(--sd-tracked-changes-insert-border, #00853d); + border-bottom: var(--sd-tracked-changes-insert-border-width, 1px) dashed var(--sd-tracked-changes-insert-border, #00853d); + background-color: var(--sd-tracked-changes-insert-background, #399c7222); + color: var(--sd-tracked-changes-insert-text, currentColor); + text-decoration: + line-through + solid + var(--sd-tracked-changes-delete-text, #cb0e47) + var(--sd-tracked-changes-delete-decoration-thickness, 2px) !important; +} + +.superdoc-layout .track-overlap-insert-delete-dec.track-insert-dec.track-delete-dec.highlighted.track-change-focused { + border-left: none; + border-right: none; + border-top-style: solid; + border-bottom-style: solid; + background-color: var(--sd-tracked-changes-insert-background-focused, #399c7244); + color: var(--sd-tracked-changes-insert-text, currentColor); + text-decoration: + line-through + solid + var(--sd-tracked-changes-delete-text, #cb0e47) + var(--sd-tracked-changes-delete-decoration-thickness, 2px) !important; +} + .superdoc-layout .track-format-dec.highlighted.track-change-focused { background-color: var(--sd-tracked-changes-format-background-focused, #ffd70033); } diff --git a/packages/layout-engine/pm-adapter/src/marks/application.test.ts b/packages/layout-engine/pm-adapter/src/marks/application.test.ts index 4f9bbda87a..29e8c35937 100644 --- a/packages/layout-engine/pm-adapter/src/marks/application.test.ts +++ b/packages/layout-engine/pm-adapter/src/marks/application.test.ts @@ -411,6 +411,56 @@ describe('mark application', () => { expect(trackedChangesCompatible(a, b)).toBe(false); }); + + it('returns true for the same overlap layers in different order', () => { + const insertParent: TrackedChangeMeta = { kind: 'insert', id: 'ins-parent' }; + const deleteChild: TrackedChangeMeta = { + kind: 'delete', + id: 'del-child', + overlapParentId: 'ins-parent', + relationship: 'child', + }; + const a: TextRun = { + text: 'Hello', + fontFamily: 'Arial', + fontSize: 12, + trackedChanges: [deleteChild, insertParent], + }; + const b: TextRun = { + text: 'World', + fontFamily: 'Arial', + fontSize: 12, + trackedChanges: [insertParent, deleteChild], + }; + + expect(trackedChangesCompatible(a, b)).toBe(true); + }); + + it('returns false when one run has an extra overlap layer', () => { + const insertParent: TrackedChangeMeta = { kind: 'insert', id: 'ins-parent' }; + const a: TextRun = { + text: 'Hello', + fontFamily: 'Arial', + fontSize: 12, + trackedChanges: [insertParent], + }; + const b: TextRun = { + text: 'World', + fontFamily: 'Arial', + fontSize: 12, + trackedChanges: [ + insertParent, + { + kind: 'delete', + id: 'del-child', + overlapParentId: 'ins-parent', + relationship: 'child', + }, + ], + }; + + expect(trackedChangesCompatible(a, b)).toBe(false); + }); }); describe('collectTrackedChangeFromMarks', () => { @@ -1041,6 +1091,31 @@ describe('mark application', () => { expect(run.trackedChange?.author).toBe('John'); }); + it('normalizes overlapping tracked change layers with parent insert first', () => { + const run: TextRun = { text: 'Hello', fontFamily: 'Arial', fontSize: 12 }; + applyMarksToRun(run, [ + { + type: TRACK_DELETE_MARK, + attrs: { id: 'del-child', overlapParentId: 'ins-parent', author: 'Reviewer' }, + }, + { + type: TRACK_INSERT_MARK, + attrs: { id: 'ins-parent', author: 'Author' }, + }, + ]); + + expect(run.trackedChanges?.map((layer) => `${layer.kind}:${layer.id}`)).toEqual([ + 'insert:ins-parent', + 'delete:del-child', + ]); + expect(run.trackedChanges?.[0].relationship).toBe('parent'); + expect(run.trackedChanges?.[1].relationship).toBe('child'); + expect(run.trackedChanges?.[1].overlapParentId).toBe('ins-parent'); + expect(run.trackedChange).toBe(run.trackedChanges?.[0]); + expect(run.trackedChange?.kind).toBe('insert'); + expect(run.trackedChange?.id).toBe('ins-parent'); + }); + it('applies link mark with enableRichHyperlinks disabled', () => { const run: TextRun = { text: 'Hello', fontFamily: 'Arial', fontSize: 12 }; applyMarksToRun(run, [{ type: 'link', attrs: { href: 'https://example.com' } }], { enableRichHyperlinks: false }); @@ -1072,6 +1147,7 @@ describe('mark application', () => { // Insert should take priority over format expect(run.trackedChange?.kind).toBe('insert'); + expect(run.trackedChanges?.map((layer) => layer.kind)).toEqual(['insert', 'format']); }); it('ignores unknown mark types', () => { diff --git a/packages/layout-engine/pm-adapter/src/marks/application.ts b/packages/layout-engine/pm-adapter/src/marks/application.ts index 493b43232f..d1ea4c9c31 100644 --- a/packages/layout-engine/pm-adapter/src/marks/application.ts +++ b/packages/layout-engine/pm-adapter/src/marks/application.ts @@ -459,6 +459,10 @@ export const buildTrackedChangeMetaFromMark = (mark: PMMark, storyKey?: string): kind, id: deriveTrackedChangeId(kind, attrs), }; + if (typeof attrs.overlapParentId === 'string' && attrs.overlapParentId) { + meta.overlapParentId = attrs.overlapParentId; + meta.relationship = 'child'; + } if (typeof attrs.author === 'string' && attrs.author) { meta.author = attrs.author; } @@ -502,36 +506,143 @@ export const selectTrackedChangeMeta = ( return existing; }; +const trackedChangeLayerKey = (meta: TrackedChangeMeta): string => `${meta.kind}:${meta.id}`; + +const trackedChangeLayerIdentity = (meta: TrackedChangeMeta): string => + [meta.kind, meta.id, meta.overlapParentId ?? ''].join(':'); + +const compareTrackedChangeLayerStrings = (a: string, b: string): number => { + if (a < b) return -1; + if (a > b) return 1; + return 0; +}; + +const trackedChangeContentRank = (meta: TrackedChangeMeta): number => (meta.kind === 'format' ? 1 : 0); + +const trackedChangeRelationshipRank = (meta: TrackedChangeMeta): number => { + if (meta.relationship === 'parent') return 0; + if (meta.relationship === 'child') return 2; + return 1; +}; + +const compareTrackedChangeLayers = (a: TrackedChangeMeta, b: TrackedChangeMeta): number => { + const aIsParentOfB = a.id === b.overlapParentId; + const bIsParentOfA = b.id === a.overlapParentId; + if (aIsParentOfB && !bIsParentOfA) return -1; + if (bIsParentOfA && !aIsParentOfB) return 1; + + const contentRank = trackedChangeContentRank(a) - trackedChangeContentRank(b); + if (contentRank !== 0) return contentRank; + + const relationshipRank = trackedChangeRelationshipRank(a) - trackedChangeRelationshipRank(b); + if (relationshipRank !== 0) return relationshipRank; + + const idOrder = compareTrackedChangeLayerStrings(a.id, b.id); + if (idOrder !== 0) return idOrder; + + const kindOrder = compareTrackedChangeLayerStrings(a.kind, b.kind); + if (kindOrder !== 0) return kindOrder; + + const parentOrder = compareTrackedChangeLayerStrings(a.overlapParentId ?? '', b.overlapParentId ?? ''); + if (parentOrder !== 0) return parentOrder; + + return compareTrackedChangeLayerStrings(a.storyKey ?? '', b.storyKey ?? ''); +}; + +const normalizeTrackedChangeLayerList = (layers: TrackedChangeMeta[]): TrackedChangeMeta[] => { + if (layers.length === 0) return []; + + const uniqueLayers = new Map(); + layers.forEach((layer) => { + const key = trackedChangeLayerKey(layer); + if (!uniqueLayers.has(key)) { + uniqueLayers.set(key, { ...layer }); + } + }); + + const parentIds = new Set(); + uniqueLayers.forEach((layer) => { + if (layer.overlapParentId) { + parentIds.add(layer.overlapParentId); + } + }); + + return Array.from(uniqueLayers.values()) + .map((layer) => { + const normalized = { ...layer }; + if (normalized.overlapParentId) { + normalized.relationship = 'child'; + } else if (parentIds.has(normalized.id)) { + normalized.relationship = 'parent'; + } else { + delete normalized.relationship; + } + return normalized; + }) + .sort(compareTrackedChangeLayers); +}; + +const normalizeTrackedChangeLayers = (run: TextRun): TrackedChangeMeta[] => { + if (Array.isArray(run.trackedChanges) && run.trackedChanges.length > 0) { + return normalizeTrackedChangeLayerList(run.trackedChanges); + } + return run.trackedChange ? normalizeTrackedChangeLayerList([run.trackedChange]) : []; +}; + +const appendTrackedChangeLayer = (run: TextRun, meta: TrackedChangeMeta): void => { + const layers = normalizeTrackedChangeLayers(run); + const key = trackedChangeLayerKey(meta); + if (!layers.some((layer) => trackedChangeLayerKey(layer) === key)) { + layers.push(meta); + } + const normalizedLayers = normalizeTrackedChangeLayerList(layers); + run.trackedChanges = normalizedLayers; + run.trackedChange = normalizedLayers[0]; +}; + /** * Checks if two text runs have compatible tracked change metadata for merging. - * Runs are compatible if they have the same kind and ID, or both have no metadata. + * Runs are compatible if their normalized tracked-change layer identities match, + * or both have no metadata. * * @param a - First text run * @param b - Second text run * @returns true if runs can be merged, false otherwise */ export const trackedChangesCompatible = (a: TextRun, b: TextRun): boolean => { - const aMeta = a.trackedChange; - const bMeta = b.trackedChange; - if (!aMeta && !bMeta) return true; - if (!aMeta || !bMeta) return false; - return aMeta.kind === bMeta.kind && aMeta.id === bMeta.id; + const aLayers = normalizeTrackedChangeLayers(a); + const bLayers = normalizeTrackedChangeLayers(b); + if (aLayers.length !== bLayers.length) return false; + return aLayers.every((aMeta, index) => { + const bMeta = bLayers[index]; + return Boolean(bMeta && trackedChangeLayerIdentity(aMeta) === trackedChangeLayerIdentity(bMeta)); + }); +}; + +export const collectTrackedChangesFromMarks = (marks?: PMMark[], storyKey?: string): TrackedChangeMeta[] => { + if (!marks || !marks.length) return []; + const seen = new Set(); + const trackedChanges: TrackedChangeMeta[] = []; + marks.forEach((mark) => { + const meta = buildTrackedChangeMetaFromMark(mark, storyKey); + if (!meta) return; + const key = trackedChangeLayerKey(meta); + if (seen.has(key)) return; + seen.add(key); + trackedChanges.push(meta); + }); + return normalizeTrackedChangeLayerList(trackedChanges); }; /** * Collects and prioritizes tracked change metadata from an array of ProseMirror marks. - * When multiple tracked change marks are present, returns the highest-priority one. + * When multiple tracked change marks are present, returns the first normalized layer. * * @param marks - Array of ProseMirror marks to process - * @returns The highest-priority TrackedChangeMeta, or undefined if none found + * @returns The primary TrackedChangeMeta, or undefined if none found */ export const collectTrackedChangeFromMarks = (marks?: PMMark[], storyKey?: string): TrackedChangeMeta | undefined => { - if (!marks || !marks.length) return undefined; - return marks.reduce((current, mark) => { - const meta = buildTrackedChangeMetaFromMark(mark, storyKey); - if (!meta) return current; - return selectTrackedChangeMeta(current, meta); - }, undefined); + return collectTrackedChangesFromMarks(marks, storyKey)[0]; }; /** @@ -862,7 +973,7 @@ export const applyMarksToRun = ( if (!isTabRun) { const tracked = buildTrackedChangeMetaFromMark(mark, storyKey); if (tracked) { - run.trackedChange = selectTrackedChangeMeta(run.trackedChange, tracked); + appendTrackedChangeLayer(run, tracked); } } break; diff --git a/packages/layout-engine/pm-adapter/src/marks/index.ts b/packages/layout-engine/pm-adapter/src/marks/index.ts index b95bd14e69..3c5063091b 100644 --- a/packages/layout-engine/pm-adapter/src/marks/index.ts +++ b/packages/layout-engine/pm-adapter/src/marks/index.ts @@ -27,6 +27,7 @@ export { selectTrackedChangeMeta, trackedChangesCompatible, collectTrackedChangeFromMarks, + collectTrackedChangesFromMarks, normalizeUnderlineStyle, applyTextStyleMark, applyMarksToRun, diff --git a/packages/layout-engine/pm-adapter/src/tracked-changes.ts b/packages/layout-engine/pm-adapter/src/tracked-changes.ts index 808f2f6df7..731573df54 100644 --- a/packages/layout-engine/pm-adapter/src/tracked-changes.ts +++ b/packages/layout-engine/pm-adapter/src/tracked-changes.ts @@ -71,6 +71,9 @@ export const stripTrackedChangeFromRun = (run: Run): void => { if ('trackedChange' in run && run.trackedChange) { delete run.trackedChange; } + if ('trackedChanges' in run && run.trackedChanges) { + delete run.trackedChanges; + } }; /** @@ -222,6 +225,10 @@ export const buildTrackedChangeMetaFromMark = (mark: PMMark, storyKey?: string): kind, id: deriveTrackedChangeId(kind, attrs), }; + if (typeof attrs.overlapParentId === 'string' && attrs.overlapParentId) { + meta.overlapParentId = attrs.overlapParentId; + meta.relationship = 'child'; + } if (typeof attrs.author === 'string' && attrs.author) { meta.author = attrs.author; } @@ -265,6 +272,13 @@ export const selectTrackedChangeMeta = ( return existing; }; +const normalizeTrackedChangeLayers = (run: TextRun): TrackedChangeMeta[] => { + if (Array.isArray(run.trackedChanges) && run.trackedChanges.length > 0) { + return run.trackedChanges; + } + return run.trackedChange ? [run.trackedChange] : []; +}; + /** * Checks if two text runs have compatible tracked change metadata for merging. * Runs are compatible if they have the same kind and ID, or both have no metadata. @@ -274,11 +288,13 @@ export const selectTrackedChangeMeta = ( * @returns true if runs can be merged, false otherwise */ export const trackedChangesCompatible = (a: TextRun, b: TextRun): boolean => { - const aMeta = a.trackedChange; - const bMeta = b.trackedChange; - if (!aMeta && !bMeta) return true; - if (!aMeta || !bMeta) return false; - return aMeta.kind === bMeta.kind && aMeta.id === bMeta.id; + const aLayers = normalizeTrackedChangeLayers(a); + const bLayers = normalizeTrackedChangeLayers(b); + if (aLayers.length !== bLayers.length) return false; + return aLayers.every((aMeta, index) => { + const bMeta = bLayers[index]; + return Boolean(bMeta && aMeta.kind === bMeta.kind && aMeta.id === bMeta.id); + }); }; /** @@ -517,6 +533,7 @@ export const applyTrackedChangesModeToRuns = ( (run.trackedChange.kind === 'insert' || run.trackedChange.kind === 'delete') ) { delete run.trackedChange; + delete run.trackedChanges; } }); } diff --git a/packages/sdk/langs/browser/src/intent-dispatch.ts b/packages/sdk/langs/browser/src/intent-dispatch.ts index 5a5a9d24e7..990e1a01d1 100644 --- a/packages/sdk/langs/browser/src/intent-dispatch.ts +++ b/packages/sdk/langs/browser/src/intent-dispatch.ts @@ -10,132 +10,84 @@ export function dispatchIntentTool( case 'superdoc_get_content': { const { action, ...rest } = args; switch (action) { - case 'text': - return execute('doc.getText', rest); - case 'markdown': - return execute('doc.getMarkdown', rest); - case 'html': - return execute('doc.getHtml', rest); - case 'info': - return execute('doc.info', rest); - case 'extract': - return execute('doc.extract', rest); - case 'blocks': - return execute('doc.blocks.list', rest); - default: - throw new Error(`Unknown action for superdoc_get_content: ${action}`); + case 'text': return execute('doc.getText', rest); + case 'markdown': return execute('doc.getMarkdown', rest); + case 'html': return execute('doc.getHtml', rest); + case 'info': return execute('doc.info', rest); + case 'extract': return execute('doc.extract', rest); + case 'blocks': return execute('doc.blocks.list', rest); + default: throw new Error(`Unknown action for superdoc_get_content: ${action}`); } } case 'superdoc_edit': { const { action, ...rest } = args; switch (action) { - case 'insert': - return execute('doc.insert', rest); - case 'replace': - return execute('doc.replace', rest); - case 'delete': - return execute('doc.delete', rest); - case 'undo': - return execute('doc.history.undo', rest); - case 'redo': - return execute('doc.history.redo', rest); - default: - throw new Error(`Unknown action for superdoc_edit: ${action}`); + case 'insert': return execute('doc.insert', rest); + case 'replace': return execute('doc.replace', rest); + case 'delete': return execute('doc.delete', rest); + case 'undo': return execute('doc.history.undo', rest); + case 'redo': return execute('doc.history.redo', rest); + default: throw new Error(`Unknown action for superdoc_edit: ${action}`); } } case 'superdoc_format': { const { action, ...rest } = args; switch (action) { - case 'inline': - return execute('doc.format.apply', rest); - case 'set_style': - return execute('doc.styles.paragraph.setStyle', rest); - case 'set_alignment': - return execute('doc.format.paragraph.setAlignment', rest); - case 'set_indentation': - return execute('doc.format.paragraph.setIndentation', rest); - case 'set_spacing': - return execute('doc.format.paragraph.setSpacing', rest); - case 'set_flow_options': - return execute('doc.format.paragraph.setFlowOptions', rest); - case 'set_direction': - return execute('doc.format.paragraph.setDirection', rest); - default: - throw new Error(`Unknown action for superdoc_format: ${action}`); + case 'inline': return execute('doc.format.apply', rest); + case 'set_style': return execute('doc.styles.paragraph.setStyle', rest); + case 'set_alignment': return execute('doc.format.paragraph.setAlignment', rest); + case 'set_indentation': return execute('doc.format.paragraph.setIndentation', rest); + case 'set_spacing': return execute('doc.format.paragraph.setSpacing', rest); + case 'set_flow_options': return execute('doc.format.paragraph.setFlowOptions', rest); + case 'set_direction': return execute('doc.format.paragraph.setDirection', rest); + default: throw new Error(`Unknown action for superdoc_format: ${action}`); } } case 'superdoc_create': { const { action, ...rest } = args; switch (action) { - case 'paragraph': - return execute('doc.create.paragraph', rest); - case 'heading': - return execute('doc.create.heading', rest); - case 'table': - return execute('doc.create.table', rest); - default: - throw new Error(`Unknown action for superdoc_create: ${action}`); + case 'paragraph': return execute('doc.create.paragraph', rest); + case 'heading': return execute('doc.create.heading', rest); + case 'table': return execute('doc.create.table', rest); + default: throw new Error(`Unknown action for superdoc_create: ${action}`); } } case 'superdoc_list': { const { action, ...rest } = args; switch (action) { - case 'insert': - return execute('doc.lists.insert', rest); - case 'create': - return execute('doc.lists.create', rest); - case 'attach': - return execute('doc.lists.attach', rest); - case 'detach': - return execute('doc.lists.detach', rest); - case 'delete': - return execute('doc.lists.delete', rest); - case 'indent': - return execute('doc.lists.indent', rest); - case 'outdent': - return execute('doc.lists.outdent', rest); - case 'merge': - return execute('doc.lists.merge', rest); - case 'split': - return execute('doc.lists.split', rest); - case 'set_level': - return execute('doc.lists.setLevel', rest); - case 'set_value': - return execute('doc.lists.setValue', rest); - case 'continue_previous': - return execute('doc.lists.continuePrevious', rest); - case 'set_type': - return execute('doc.lists.setType', rest); - default: - throw new Error(`Unknown action for superdoc_list: ${action}`); + case 'insert': return execute('doc.lists.insert', rest); + case 'create': return execute('doc.lists.create', rest); + case 'attach': return execute('doc.lists.attach', rest); + case 'detach': return execute('doc.lists.detach', rest); + case 'delete': return execute('doc.lists.delete', rest); + case 'indent': return execute('doc.lists.indent', rest); + case 'outdent': return execute('doc.lists.outdent', rest); + case 'merge': return execute('doc.lists.merge', rest); + case 'split': return execute('doc.lists.split', rest); + case 'set_level': return execute('doc.lists.setLevel', rest); + case 'set_value': return execute('doc.lists.setValue', rest); + case 'continue_previous': return execute('doc.lists.continuePrevious', rest); + case 'set_type': return execute('doc.lists.setType', rest); + default: throw new Error(`Unknown action for superdoc_list: ${action}`); } } case 'superdoc_comment': { const { action, ...rest } = args; switch (action) { - case 'create': - return execute('doc.comments.create', rest); - case 'update': - return execute('doc.comments.patch', rest); - case 'delete': - return execute('doc.comments.delete', rest); - case 'get': - return execute('doc.comments.get', rest); - case 'list': - return execute('doc.comments.list', rest); - default: - throw new Error(`Unknown action for superdoc_comment: ${action}`); + case 'create': return execute('doc.comments.create', rest); + case 'update': return execute('doc.comments.patch', rest); + case 'delete': return execute('doc.comments.delete', rest); + case 'get': return execute('doc.comments.get', rest); + case 'list': return execute('doc.comments.list', rest); + default: throw new Error(`Unknown action for superdoc_comment: ${action}`); } } case 'superdoc_track_changes': { const { action, ...rest } = args; switch (action) { - case 'list': - return execute('doc.trackChanges.list', rest); - case 'decide': - return execute('doc.trackChanges.decide', rest); - default: - throw new Error(`Unknown action for superdoc_track_changes: ${action}`); + case 'list': return execute('doc.trackChanges.list', rest); + case 'decide': return execute('doc.trackChanges.decide', rest); + default: throw new Error(`Unknown action for superdoc_track_changes: ${action}`); } } case 'superdoc_search': @@ -143,53 +95,32 @@ export function dispatchIntentTool( case 'superdoc_mutations': { const { action, ...rest } = args; switch (action) { - case 'preview': - return execute('doc.mutations.preview', rest); - case 'apply': - return execute('doc.mutations.apply', rest); - default: - throw new Error(`Unknown action for superdoc_mutations: ${action}`); + case 'preview': return execute('doc.mutations.preview', rest); + case 'apply': return execute('doc.mutations.apply', rest); + default: throw new Error(`Unknown action for superdoc_mutations: ${action}`); } } case 'superdoc_table': { const { action, ...rest } = args; switch (action) { - case 'delete': - return execute('doc.tables.delete', rest); - case 'set_layout': - return execute('doc.tables.setLayout', rest); - case 'insert_row': - return execute('doc.tables.insertRow', rest); - case 'delete_row': - return execute('doc.tables.deleteRow', rest); - case 'set_row': - return execute('doc.tables.setRowHeight', rest); - case 'set_row_options': - return execute('doc.tables.setRowOptions', rest); - case 'insert_column': - return execute('doc.tables.insertColumn', rest); - case 'delete_column': - return execute('doc.tables.deleteColumn', rest); - case 'set_column': - return execute('doc.tables.setColumnWidth', rest); - case 'merge_cells': - return execute('doc.tables.mergeCells', rest); - case 'unmerge_cells': - return execute('doc.tables.unmergeCells', rest); - case 'set_cell': - return execute('doc.tables.setCellProperties', rest); - case 'set_cell_text': - return execute('doc.tables.setCellText', rest); - case 'set_shading': - return execute('doc.tables.setShading', rest); - case 'set_style_options': - return execute('doc.tables.applyStyle', rest); - case 'set_borders': - return execute('doc.tables.setBorders', rest); - case 'set_options': - return execute('doc.tables.setTableOptions', rest); - default: - throw new Error(`Unknown action for superdoc_table: ${action}`); + case 'delete': return execute('doc.tables.delete', rest); + case 'set_layout': return execute('doc.tables.setLayout', rest); + case 'insert_row': return execute('doc.tables.insertRow', rest); + case 'delete_row': return execute('doc.tables.deleteRow', rest); + case 'set_row': return execute('doc.tables.setRowHeight', rest); + case 'set_row_options': return execute('doc.tables.setRowOptions', rest); + case 'insert_column': return execute('doc.tables.insertColumn', rest); + case 'delete_column': return execute('doc.tables.deleteColumn', rest); + case 'set_column': return execute('doc.tables.setColumnWidth', rest); + case 'merge_cells': return execute('doc.tables.mergeCells', rest); + case 'unmerge_cells': return execute('doc.tables.unmergeCells', rest); + case 'set_cell': return execute('doc.tables.setCellProperties', rest); + case 'set_cell_text': return execute('doc.tables.setCellText', rest); + case 'set_shading': return execute('doc.tables.setShading', rest); + case 'set_style_options': return execute('doc.tables.applyStyle', rest); + case 'set_borders': return execute('doc.tables.setBorders', rest); + case 'set_options': return execute('doc.tables.setTableOptions', rest); + default: throw new Error(`Unknown action for superdoc_table: ${action}`); } } default: diff --git a/packages/sdk/langs/node/src/__tests__/handle-and-tools.test.ts b/packages/sdk/langs/node/src/__tests__/handle-and-tools.test.ts index 3464ba0a43..f0413b1d87 100644 --- a/packages/sdk/langs/node/src/__tests__/handle-and-tools.test.ts +++ b/packages/sdk/langs/node/src/__tests__/handle-and-tools.test.ts @@ -16,8 +16,48 @@ describe('SuperDocDocument', () => { expect(typeof doc.getMarkdown).toBe('function'); expect(typeof doc.query.match).toBe('function'); + expect(typeof doc.formatRange).toBe('function'); expect('api' in (doc as unknown as Record)).toBe(false); }); + + test('formatRange delegates to doc.format.apply with inline properties', async () => { + const calls: Array<{ operationId: string; params: unknown }> = []; + const boundRuntime = { + invoke: async (operation: { operationId: string }, params: unknown) => { + calls.push({ operationId: operation.operationId, params }); + return { ok: true }; + }, + markClosed: () => {}, + }; + const client = { removeHandle: () => {} }; + + const doc = new SuperDocDocument(boundRuntime as any, 'session-1', { contextId: 'session-1' }, client as any); + const target = { + kind: 'selection' as const, + start: { kind: 'text' as const, blockId: 'p1', offset: 2 }, + end: { kind: 'text' as const, blockId: 'p1', offset: 5 }, + }; + + const result = await doc.formatRange({ + target, + properties: { bold: true, italic: false }, + changeMode: 'tracked', + dryRun: true, + }); + + expect(result).toEqual({ ok: true }); + expect(calls).toEqual([ + { + operationId: 'doc.format.apply', + params: { + target, + inline: { bold: true, italic: false }, + changeMode: 'tracked', + dryRun: true, + }, + }, + ]); + }); }); describe('SuperDocClient handle lifecycle', () => { diff --git a/packages/sdk/langs/node/src/index.ts b/packages/sdk/langs/node/src/index.ts index 632e84fcbd..b3e1e8b25a 100644 --- a/packages/sdk/langs/node/src/index.ts +++ b/packages/sdk/langs/node/src/index.ts @@ -4,6 +4,8 @@ import { type BoundDocApi, type DocCloseBoundParams, type DocCloseResult, + type DocFormatApplyBoundParams, + type DocFormatApplyResult, type DocOpenParams as GeneratedDocOpenParams, type DocOpenResult, type DocSaveBoundParams, @@ -58,6 +60,10 @@ class BoundRuntime implements RuntimeInvoker { } } +export interface DocFormatRangeBoundParams extends Omit { + properties: NonNullable; +} + // --------------------------------------------------------------------------- // Document handle // --------------------------------------------------------------------------- @@ -114,6 +120,18 @@ class SuperDocDocumentCore { markClosed(): void { this.boundRuntime.markClosed(); } + + async formatRange(params: DocFormatRangeBoundParams, options: InvokeOptions = {}): Promise { + const { properties, ...rest } = params; + return this.boundRuntime.invoke( + CONTRACT.operations['doc.format.apply'], + { + ...rest, + inline: properties, + }, + options, + ); + } } type SuperDocDocumentInstance = SuperDocDocumentCore & BoundDocApi; diff --git a/packages/sdk/langs/node/src/runtime/__tests__/transport-common.test.ts b/packages/sdk/langs/node/src/runtime/__tests__/transport-common.test.ts index 5e9daf7541..0177be9f8c 100644 --- a/packages/sdk/langs/node/src/runtime/__tests__/transport-common.test.ts +++ b/packages/sdk/langs/node/src/runtime/__tests__/transport-common.test.ts @@ -350,4 +350,26 @@ describe('buildOperationArgv with real generated contract', () => { const argv = buildOperationArgv(realOpenOp, { doc: 'plain.docx' }, {}, undefined); expect(argv).not.toContain('--password'); }); + + test('generated doc.open spec includes trackChanges JSON param', () => { + const trackChangesParam = realOpenOp.params.find((p) => p.name === 'trackChanges'); + + expect(trackChangesParam).toBeDefined(); + expect(trackChangesParam!.kind).toBe('jsonFlag'); + expect(trackChangesParam!.type).toBe('json'); + expect(trackChangesParam!.flag).toBe('track-changes-json'); + }); + + test('trackChanges replacement mode emits --track-changes-json with real doc.open spec', () => { + const argv = buildOperationArgv( + realOpenOp, + { doc: 'test.docx', trackChanges: { replacements: 'independent' } }, + {}, + undefined, + ); + const flagIndex = argv.indexOf('--track-changes-json'); + + expect(flagIndex).toBeGreaterThan(-1); + expect(JSON.parse(argv[flagIndex + 1])).toEqual({ replacements: 'independent' }); + }); }); diff --git a/packages/super-editor/src/editors/v1/components/context-menu/tests/utils.test.js b/packages/super-editor/src/editors/v1/components/context-menu/tests/utils.test.js index 01a8c91ae4..373880052e 100644 --- a/packages/super-editor/src/editors/v1/components/context-menu/tests/utils.test.js +++ b/packages/super-editor/src/editors/v1/components/context-menu/tests/utils.test.js @@ -65,6 +65,7 @@ import { __isCollaborationEnabledForTest, __getCellSelectionInfoForTest, __resolveProofingContextForTest, + __choosePreferredTrackedChangeIdForTest, } from '../utils.js'; import { readFromClipboard } from '../../../core/utilities/clipboardUtils.js'; import { selectionHasNodeOrMark } from '../../cursor-helpers.js'; @@ -114,6 +115,19 @@ describe('utils.js', () => { ); describe('getEditorContext', () => { + it('prefers a child deletion as the context target for overlapping tracked marks', () => { + const parentInsert = { + type: { name: 'trackInsert' }, + attrs: { id: 'parent-insert' }, + }; + const childDelete = { + type: { name: 'trackDelete' }, + attrs: { id: 'child-delete', overlapParentId: 'parent-insert' }, + }; + + expect(__choosePreferredTrackedChangeIdForTest([parentInsert, childDelete])).toBe('child-delete'); + }); + it('should return comprehensive editor context', async () => { // Note: getEditorContext() no longer reads clipboard proactively. // Clipboard reading is deferred to paste action to avoid permission prompts. diff --git a/packages/super-editor/src/editors/v1/components/context-menu/utils.js b/packages/super-editor/src/editors/v1/components/context-menu/utils.js index dc7d47edf6..07982afc43 100644 --- a/packages/super-editor/src/editors/v1/components/context-menu/utils.js +++ b/packages/super-editor/src/editors/v1/components/context-menu/utils.js @@ -17,6 +17,47 @@ import { selectedRect } from 'prosemirror-tables'; export const resolveContextMenuCommandEditor = (editor) => { return typeof editor?.getActiveEditor === 'function' ? editor.getActiveEditor() : editor; }; + +const TRACKED_MARK_NAMES = new Set(['trackInsert', 'trackDelete', 'trackFormat']); +const TRACKED_MARK_PRIORITY = { + trackDelete: 3, + trackFormat: 2, + trackInsert: 1, +}; + +function isTrackedChangeMark(mark) { + return Boolean(mark?.type?.name && TRACKED_MARK_NAMES.has(mark.type.name)); +} + +function choosePreferredTrackedChangeId(marks) { + const candidates = []; + const seen = new Set(); + + marks.forEach((mark, index) => { + if (!isTrackedChangeMark(mark)) return; + const id = typeof mark.attrs?.id === 'string' ? mark.attrs.id : ''; + if (!id) return; + const key = `${mark.type.name}:${id}`; + if (seen.has(key)) return; + seen.add(key); + candidates.push({ + id, + markType: mark.type.name, + isChildOverlap: Boolean(mark.attrs?.overlapParentId), + index, + }); + }); + + candidates.sort((a, b) => { + if (a.isChildOverlap !== b.isChildOverlap) return a.isChildOverlap ? -1 : 1; + const priorityDelta = (TRACKED_MARK_PRIORITY[b.markType] ?? 0) - (TRACKED_MARK_PRIORITY[a.markType] ?? 0); + if (priorityDelta !== 0) return priorityDelta; + return a.index - b.index; + }); + + return candidates[0]?.id ?? null; +} + /** * Get props by item id * @@ -141,6 +182,7 @@ export async function getEditorContext(editor, event) { const activeMarks = []; let trackedChangeId = null; + const trackedMarkCandidates = []; if (event && pos !== null) { const $pos = state.doc.resolve(pos); @@ -149,11 +191,8 @@ export async function getEditorContext(editor, event) { if (!activeMarks.includes(mark.type.name)) { activeMarks.push(mark.type.name); } - if ( - !trackedChangeId && - (mark.type.name === 'trackInsert' || mark.type.name === 'trackDelete' || mark.type.name === 'trackFormat') - ) { - trackedChangeId = mark.attrs.id; + if (isTrackedChangeMark(mark)) { + trackedMarkCandidates.push(mark); } }; @@ -171,6 +210,7 @@ export async function getEditorContext(editor, event) { } state.storedMarks?.forEach(processMark); + trackedChangeId = choosePreferredTrackedChangeId(trackedMarkCandidates); } else { state.storedMarks?.forEach((mark) => activeMarks.push(mark.type.name)); state.selection.$head.marks().forEach((mark) => activeMarks.push(mark.type.name)); @@ -458,4 +498,5 @@ export { isCollaborationEnabled as __isCollaborationEnabledForTest, getCellSelectionInfo as __getCellSelectionInfoForTest, resolveProofingContext as __resolveProofingContextForTest, + choosePreferredTrackedChangeId as __choosePreferredTrackedChangeIdForTest, }; diff --git a/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js b/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js index 33e391570d..80b467e89f 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js +++ b/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js @@ -1,9 +1,129 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { initTestEditor } from '@tests/helpers/helpers.js'; +import { Editor } from '@core/Editor.js'; import { getTrackChanges } from '@extensions/track-changes/trackChangesHelpers/getTrackChanges.js'; -import { TrackInsertMarkName } from '@extensions/track-changes/constants.js'; +import { TrackDeleteMarkName, TrackInsertMarkName } from '@extensions/track-changes/constants.js'; import { TrackChangesBasePluginKey } from '@extensions/track-changes/plugins/trackChangesBasePlugin.js'; +const ALICE = { name: 'Alice Reviewer', email: 'alice@example.com' }; +const BOB = { name: 'Bob Reviewer', email: 'bob@example.com' }; +const FIXED_DATE = '2026-05-21T00:00:00.000Z'; +const FOREIGN_INSERT_ID = 'foreign-insert'; +const INSERTED_TEXT = 'here is my new text, do you like it?'; +const INSERTED_TAIL = 'do you like it?'; +const INSERTED_TEXT_AFTER_DIRECT_DELETE = INSERTED_TEXT.replace(INSERTED_TAIL, ''); +const CURRENT_DIR = dirname(fileURLToPath(import.meta.url)); +const WORD_REPLACEMENT_FIXTURE = resolve( + CURRENT_DIR, + '../../../../../../tests/behavior/tests/comments/fixtures/sd-1960-word-replacement-no-comments.docx', +); + +const findTextRange = (editor, text) => { + let found = null; + editor.state.doc.descendants((node, pos) => { + if (found || !node.isText || !node.text) return; + const index = node.text.indexOf(text); + if (index === -1) return; + found = { from: pos + index, to: pos + index + text.length }; + return false; + }); + if (!found) throw new Error(`Text not found: ${text}`); + return found; +}; + +const markEntries = (editor, markName) => { + const entries = []; + editor.state.doc.descendants((node) => { + if (!node.isText || !node.text) return; + for (const mark of node.marks ?? []) { + if (mark.type?.name === markName) entries.push({ text: node.text, mark }); + } + }); + return entries; +}; + +const textForMarkId = (editor, markName, id) => + markEntries(editor, markName) + .filter(({ mark }) => mark.attrs?.id === id) + .map(({ text }) => text) + .join(''); + +const setDocumentWithTrackedInsertion = (editor, { author = ALICE, id = FOREIGN_INSERT_ID } = {}) => { + const { schema } = editor; + const insertMark = schema.marks[TrackInsertMarkName].create({ + id, + author: author.name, + authorEmail: author.email, + date: FIXED_DATE, + }); + const doc = schema.nodes.doc.create( + {}, + schema.nodes.paragraph.create( + {}, + schema.nodes.run.create({}, [ + schema.text('hello there '), + schema.text(INSERTED_TEXT, [insertMark]), + schema.text(' after'), + ]), + ), + ); + + editor.dispatch( + editor.state.tr + .replaceWith(0, editor.state.doc.content.size, doc.content) + .setMeta('skipTrackChanges', true) + .setMeta('inputType', 'test-setup'), + ); +}; + +const setDocumentWithSeparateRunTrackedInsertion = (editor, { author = ALICE, id = FOREIGN_INSERT_ID } = {}) => { + const { schema } = editor; + const insertMark = schema.marks[TrackInsertMarkName].create({ + id, + author: author.name, + authorEmail: author.email, + date: FIXED_DATE, + }); + const doc = schema.nodes.doc.create( + {}, + schema.nodes.paragraph.create({}, [ + schema.nodes.run.create({}, schema.text('The quick brown fox jumps over the ')), + schema.nodes.run.create({}, schema.text('lazy ', [insertMark])), + schema.nodes.run.create({}, schema.text('dog.')), + ]), + ); + + editor.dispatch( + editor.state.tr + .replaceWith(0, editor.state.doc.content.size, doc.content) + .setMeta('skipTrackChanges', true) + .setMeta('inputType', 'test-setup'), + ); +}; + +const deleteText = (editor, text) => { + const { from, to } = findTextRange(editor, text); + editor.dispatch(editor.state.tr.delete(from, to).setMeta('inputType', 'deleteContentBackward')); +}; + +const replaceText = (editor, text, replacement) => { + const { from, to } = findTextRange(editor, text); + editor.dispatch(editor.state.tr.insertText(replacement, from, to).setMeta('inputType', 'insertText')); +}; + +const getFirstMatchRef = (editor, pattern) => { + const match = editor.doc.query.match({ + select: { type: 'text', pattern }, + require: 'first', + }); + const ref = match?.items?.[0]?.handle?.ref; + if (!ref) throw new Error(`Could not resolve ref for pattern "${pattern}"`); + return ref; +}; + describe('Editor dispatch tracked-change meta', () => { let editor; @@ -104,4 +224,313 @@ describe('Editor dispatch tracked-change meta', () => { const tracked = getTrackChanges(editor.state); expect(tracked.some((entry) => entry.mark.type.name === TrackInsertMarkName)).toBe(true); }); + + it('emits an add commentsUpdate when a native suggesting insert creates a tracked insertion', () => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

Hello

', + user: { name: 'Test', email: 'test@example.com' }, + useImmediateSetTimeout: false, + })); + + editor.setDocumentMode('suggesting'); + + const emitSpy = vi.spyOn(editor, 'emit'); + const tr = editor.state.tr.insertText('Tracked ', 1, 1).setMeta('inputType', 'insertText'); + + editor.dispatch(tr); + + const tracked = getTrackChanges(editor.state); + expect(tracked.some((entry) => entry.mark.type.name === TrackInsertMarkName)).toBe(true); + + const addPayload = emitSpy.mock.calls.find( + ([eventName, payload]) => + eventName === 'commentsUpdate' && + payload?.type === 'trackedChange' && + payload?.event === 'add' && + payload?.trackedChangeType === TrackInsertMarkName, + )?.[1]; + + expect(addPayload).toEqual( + expect.objectContaining({ + trackedChangeText: expect.stringContaining('Tracked'), + author: 'Test', + authorEmail: 'test@example.com', + }), + ); + }); + + it('normalizes modules.trackChanges.replacements for direct Editor.open callers', async () => { + const opened = await Editor.open(undefined, { + isHeadless: true, + modules: { trackChanges: { replacements: 'independent' } }, + }); + + try { + expect(opened.options.trackedChanges?.replacements).toBe('independent'); + } finally { + opened.destroy(); + } + }); + + it('uses modules.trackChanges.replacements during Word replacement import projection', async () => { + const fixture = await readFile(WORD_REPLACEMENT_FIXTURE); + const paired = await Editor.open(fixture, { + isHeadless: true, + modules: { trackChanges: { replacements: 'paired' } }, + }); + const independent = await Editor.open(fixture, { + isHeadless: true, + modules: { trackChanges: { replacements: 'independent' } }, + }); + + try { + const pairedItems = paired.doc.trackChanges.list().items; + const independentItems = independent.doc.trackChanges.list().items; + + expect(pairedItems).toEqual( + expect.arrayContaining([expect.objectContaining({ type: 'replacement', grouping: 'replacement-pair' })]), + ); + expect(independentItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'insert', grouping: 'standalone' }), + expect.objectContaining({ type: 'delete', grouping: 'standalone' }), + ]), + ); + expect(independentItems.some((item) => item.grouping === 'replacement-pair')).toBe(false); + expect(independentItems.length).toBeGreaterThan(pairedItems.length); + } finally { + paired.destroy(); + independent.destroy(); + } + }); + + it('mutates another user tracked insertion in place on direct delete while local track mode is off', () => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

', + user: BOB, + useImmediateSetTimeout: false, + })); + setDocumentWithTrackedInsertion(editor); + + const trackState = TrackChangesBasePluginKey.getState(editor.state); + expect(trackState?.isTrackChangesActive ?? false).toBe(false); + + deleteText(editor, INSERTED_TAIL); + + expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe(INSERTED_TEXT_AFTER_DIRECT_DELETE); + expect(markEntries(editor, TrackDeleteMarkName)).toHaveLength(0); + }); + + it('mutates an anonymous live tracked insertion in place on direct delete without a configured editor user', () => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

', + useImmediateSetTimeout: false, + })); + setDocumentWithTrackedInsertion(editor, { author: { name: '', email: '' }, id: FOREIGN_INSERT_ID }); + + const trackState = TrackChangesBasePluginKey.getState(editor.state); + expect(trackState?.isTrackChangesActive ?? false).toBe(false); + + deleteText(editor, INSERTED_TAIL); + + expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe(INSERTED_TEXT_AFTER_DIRECT_DELETE); + expect(markEntries(editor, TrackDeleteMarkName)).toHaveLength(0); + }); + + it('mutates an anonymous live tracked insertion in place from document-api direct delete', () => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

', + useImmediateSetTimeout: false, + })); + setDocumentWithTrackedInsertion(editor, { author: { name: '', email: '' }, id: FOREIGN_INSERT_ID }); + + const receipt = editor.doc.delete({ ref: getFirstMatchRef(editor, INSERTED_TAIL) }, { changeMode: 'direct' }); + + expect(receipt.success).toBe(true); + expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe(INSERTED_TEXT_AFTER_DIRECT_DELETE); + expect(markEntries(editor, TrackDeleteMarkName)).toHaveLength(0); + }); + + it('mutates tracked insertion created by document-api insert from document-api direct delete', () => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

', + user: { id: 'cli', name: 'CLI' }, + useImmediateSetTimeout: false, + })); + + expect(editor.doc.trackChanges.list().total).toBe(0); + expect(editor.doc.comments.list().total).toBe(0); + + const insertReceipt = editor.doc.insert({ value: 'live-review-comment' }, { changeMode: 'tracked' }); + expect(insertReceipt.success).toBe(true); + + const insertMark = markEntries(editor, TrackInsertMarkName).find(({ text }) => text === 'live-review-comment'); + expect(insertMark?.mark.attrs).toEqual( + expect.objectContaining({ + author: 'CLI', + authorId: 'cli', + authorEmail: '', + }), + ); + + const deleteReceipt = editor.doc.delete({ ref: getFirstMatchRef(editor, 'review') }, { changeMode: 'direct' }); + + expect(deleteReceipt.success).toBe(true); + expect(editor.state.doc.textContent).toContain('live--comment'); + + expect(textForMarkId(editor, TrackInsertMarkName, insertMark?.mark.attrs.id)).toBe('live--comment'); + expect(markEntries(editor, TrackDeleteMarkName)).toHaveLength(0); + + const trackedChanges = editor.doc.trackChanges.list(); + expect(trackedChanges.total).toBe(1); + expect(trackedChanges.items.map((item) => item.raw?.type ?? item.type)).toEqual(['insert']); + + const comments = editor.doc.comments.list(); + expect(comments.total).toBe(1); + expect(comments.items).toEqual( + expect.arrayContaining([expect.objectContaining({ trackedChangeType: 'insert', trackedChangeText: 'live--comment' })]), + ); + }); + + it('protects another user tracked insertion from direct replace while local track mode is off', () => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

', + user: BOB, + useImmediateSetTimeout: false, + })); + setDocumentWithTrackedInsertion(editor); + + replaceText(editor, INSERTED_TAIL, 'yes'); + + expect(editor.state.doc.textContent).toContain(INSERTED_TAIL); + expect(editor.state.doc.textContent).toContain('yes'); + + const childDeletion = markEntries(editor, TrackDeleteMarkName).find(({ text }) => text === INSERTED_TAIL); + expect(childDeletion?.mark.attrs).toEqual( + expect.objectContaining({ + authorEmail: BOB.email, + overlapParentId: FOREIGN_INSERT_ID, + }), + ); + + const childInsertion = markEntries(editor, TrackInsertMarkName).find( + ({ text, mark }) => text === 'yes' && mark.attrs?.id !== FOREIGN_INSERT_ID, + ); + expect(childInsertion?.mark.attrs).toEqual( + expect.objectContaining({ + authorEmail: BOB.email, + overlapParentId: FOREIGN_INSERT_ID, + }), + ); + }); + + it('protects an imported-style separate-run tracked insertion from direct doc.replace', () => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

', + user: BOB, + useImmediateSetTimeout: false, + })); + setDocumentWithSeparateRunTrackedInsertion(editor); + + const receipt = editor.doc.replace( + { + ref: getFirstMatchRef(editor, 'lazy'), + text: 'quickly', + }, + { changeMode: 'direct' }, + ); + + expect(receipt.success).toBe(true); + expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe('lazy '); + + const childDeletion = markEntries(editor, TrackDeleteMarkName).find(({ text }) => text === 'lazy'); + expect(childDeletion?.mark.attrs).toEqual( + expect.objectContaining({ + authorEmail: BOB.email, + overlapParentId: FOREIGN_INSERT_ID, + }), + ); + + const childInsertion = markEntries(editor, TrackInsertMarkName).find( + ({ text, mark }) => text === 'quickly' && mark.attrs?.id !== FOREIGN_INSERT_ID, + ); + expect(childInsertion?.mark.attrs).toEqual( + expect.objectContaining({ + authorEmail: BOB.email, + overlapParentId: FOREIGN_INSERT_ID, + }), + ); + }); + + it('does not emit a child deletion review comment on direct delete inside a tracked insertion', () => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

', + user: BOB, + useImmediateSetTimeout: false, + })); + setDocumentWithTrackedInsertion(editor); + + const emitSpy = vi.spyOn(editor, 'emit'); + deleteText(editor, INSERTED_TAIL); + + const childDeletionEvent = emitSpy.mock.calls.find( + ([eventName, payload]) => + eventName === 'commentsUpdate' && + payload?.type === 'trackedChange' && + payload?.event === 'add' && + payload?.trackedChangeType === TrackDeleteMarkName, + )?.[1]; + + expect(childDeletionEvent).toBeUndefined(); + expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe(INSERTED_TEXT_AFTER_DIRECT_DELETE); + expect(editor.doc.comments.list().items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + trackedChangeType: 'insert', + trackedChangeText: INSERTED_TEXT_AFTER_DIRECT_DELETE, + }), + ]), + ); + }); + + it('still allows direct deletion of untracked plain text while local track mode is off', () => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

hello plain text

', + user: BOB, + useImmediateSetTimeout: false, + })); + + deleteText(editor, 'plain '); + + expect(editor.state.doc.textContent).toBe('hello text'); + expect(markEntries(editor, TrackInsertMarkName)).toHaveLength(0); + expect(markEntries(editor, TrackDeleteMarkName)).toHaveLength(0); + }); + + it('collapses the current user own insertion on direct delete without creating a child review item', () => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

', + user: BOB, + useImmediateSetTimeout: false, + })); + setDocumentWithTrackedInsertion(editor, { author: BOB, id: 'own-insert' }); + + deleteText(editor, INSERTED_TEXT); + + expect(editor.state.doc.textContent).toBe('hello there after'); + expect(markEntries(editor, TrackInsertMarkName).filter(({ mark }) => mark.attrs?.id === 'own-insert')).toHaveLength( + 0, + ); + expect(markEntries(editor, TrackDeleteMarkName)).toHaveLength(0); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index 54a7583657..3f7528733e 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -1,7 +1,7 @@ import type { EditorState, Transaction, Plugin } from 'prosemirror-state'; -import { Transform } from 'prosemirror-transform'; +import { AddMarkStep, RemoveMarkStep, ReplaceAroundStep, ReplaceStep, Transform } from 'prosemirror-transform'; import type { EditorView as PmEditorView } from 'prosemirror-view'; -import type { Node as PmNode, Schema } from 'prosemirror-model'; +import type { Mark as PmMark, Node as PmNode, Schema } from 'prosemirror-model'; import type { Doc as YDoc } from 'yjs'; import type { EditorOptions, User, FieldValue, DocxFileEntry } from './types/EditorConfig.js'; import type { EditorHelpers, ExtensionStorage, ProseMirrorJSON, PageStyles, Toolbar } from './types/EditorTypes.js'; @@ -34,6 +34,12 @@ import { import { createDocument } from './helpers/createDocument.js'; import { isActive } from './helpers/isActive.js'; import { trackedTransaction } from '@extensions/track-changes/trackChangesHelpers/trackedTransaction.js'; +import { + createWordIdAllocator, + isDecimalWordId, + TRACKED_CHANGE_SOURCE_ID_MAP_PROPERTY, +} from '@extensions/track-changes/review-model/word-id-allocator.js'; +import { TrackDeleteMarkName, TrackFormatMarkName, TrackInsertMarkName } from '@extensions/track-changes/constants.js'; import { TrackChangesBasePluginKey } from '@extensions/track-changes/plugins/index.js'; import { CommentsPluginKey } from '@extensions/comment/comments-plugin.js'; import { getNecessaryMigrations } from '@core/migrations/index.js'; @@ -65,6 +71,7 @@ import { createDocFromMarkdown, createDocFromHTML } from '@core/helpers/index.js import { COMMENT_FILE_BASENAMES } from '@core/super-converter/constants.js'; import { isHeadless } from '../utils/headless-helpers.js'; import { canUseDOM } from '../utils/canUseDOM.js'; +import { buildCommentJsonFromText } from '../utils/comment-content.js'; import { buildSchemaSummary } from './schema-summary.js'; import type { PresentationEditor } from './presentation-editor/index.js'; import type { EditorRenderer } from './renderers/EditorRenderer.js'; @@ -92,6 +99,31 @@ import { getViewModeSelectionWithoutStructuredContent } from './helpers/getViewM import { resolveMainBodyEditor } from '../document-api-adapters/helpers/word-statistics.js'; import { commitLiveStorySessionRuntimes } from '../document-api-adapters/story-runtime/live-story-session-runtime-registry.js'; +type TrackChangesRuntimeConfig = NonNullable; + +function normalizeEditorTrackChangesOptions(options: Partial): Partial { + const canonical = options.modules?.trackChanges; + const legacy = options.trackedChanges; + + if (!canonical && !legacy) return options; + + const normalized: TrackChangesRuntimeConfig = { + ...(legacy ?? {}), + ...(canonical ?? {}), + }; + + return { + ...options, + trackedChanges: normalized, + }; +} + +type ConverterWithInternalWordIdAllocator = EditorConverterSurface & { + wordIdAllocator?: { + getSourceIdMap?: () => Record>; + } | null; +}; + declare const __APP_VERSION__: string | undefined; declare const version: string | undefined; @@ -104,6 +136,216 @@ const CURRENT_APP_VERSION = const PIXELS_PER_INCH = 96; const MAX_HEIGHT_BUFFER_PX = 50; const MAX_WIDTH_BUFFER_PX = 20; +const TRACKED_REVIEW_MARK_NAMES = new Set([TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName]); + +const isTrackedReviewMark = (mark: { type?: { name?: string } } | null | undefined): boolean => + Boolean(mark?.type?.name && TRACKED_REVIEW_MARK_NAMES.has(mark.type.name)); + +const trackedReviewMarkKey = (mark: { type?: { name?: string }; attrs?: Record }): string | null => { + if (!isTrackedReviewMark(mark)) return null; + const id = typeof mark.attrs?.id === 'string' ? mark.attrs.id : ''; + return `${mark.type?.name ?? ''}:${id}`; +}; + +const trackedReviewMarkKeysForNode = (node: PmNode | null | undefined): Set => { + const keys = new Set(); + if (!node) return keys; + for (const mark of node.marks ?? []) { + const key = trackedReviewMarkKey(mark); + if (key) keys.add(key); + } + return keys; +}; + +const inlineNodeHasTrackedReviewMark = (node: PmNode): boolean => + Boolean(node.isInline && node.marks?.some(isTrackedReviewMark)); + +const rangeHasTrackedReviewMark = (doc: PmNode, from: number, to: number): boolean => { + if (from >= to) return false; + + const docStart = 0; + const docEnd = doc.content.size; + const clampedFrom = Math.max(docStart, Math.min(docEnd, from)); + const clampedTo = Math.max(docStart, Math.min(docEnd, to)); + if (clampedFrom >= clampedTo) return false; + + let found = false; + doc.nodesBetween(clampedFrom, clampedTo, (node) => { + if (inlineNodeHasTrackedReviewMark(node)) { + found = true; + return false; + } + return undefined; + }); + return found; +}; + +const rangeIsTrackedInsertionOnly = (doc: PmNode, from: number, to: number): boolean => { + if (from >= to) return false; + + const docStart = 0; + const docEnd = doc.content.size; + const clampedFrom = Math.max(docStart, Math.min(docEnd, from)); + const clampedTo = Math.max(docStart, Math.min(docEnd, to)); + if (clampedFrom >= clampedTo) return false; + + let sawTrackedInsertion = false; + let sawUntrackedInline = false; + let sawNonInsertionTrackedMark = false; + + doc.nodesBetween(clampedFrom, clampedTo, (node, pos) => { + if (!node.isInline || !node.isLeaf) return; + if (pos + node.nodeSize <= clampedFrom || pos >= clampedTo) return; + + const trackedMarks = (node.marks ?? []).filter(isTrackedReviewMark); + if (!trackedMarks.length) { + sawUntrackedInline = true; + return; + } + + sawTrackedInsertion = true; + if (!trackedMarks.every((mark) => mark.type?.name === TrackInsertMarkName)) { + sawNonInsertionTrackedMark = true; + } + }); + + return sawTrackedInsertion && !sawUntrackedInline && !sawNonInsertionTrackedMark; +}; + +const getSingleTrackedInsertionMarkInRange = ( + doc: PmNode, + from: number, + to: number, +): PmMark | null => { + if (!rangeIsTrackedInsertionOnly(doc, from, to)) return null; + + /** @type {import('prosemirror-model').Mark[]} */ + const insertionMarks = []; + const seenIds = new Set(); + doc.nodesBetween(from, to, (node, pos) => { + if (!node.isInline || !node.isLeaf) return; + if (pos + node.nodeSize <= from || pos >= to) return; + + const insertionMark = (node.marks ?? []).find((mark) => mark.type?.name === TrackInsertMarkName); + const id = typeof insertionMark?.attrs?.id === 'string' ? insertionMark.attrs.id : null; + if (!insertionMark || !id || seenIds.has(id)) return; + seenIds.add(id); + insertionMarks.push(insertionMark); + }); + + return insertionMarks.length === 1 ? insertionMarks[0] : null; +}; + +const resolveDirectInsertionDeleteCommentMeta = ( + state: EditorState, + tr: Transaction, +): + | { + insertedMark: PmMark; + deletionMark: null; + formatMark: null; + deletionNodes: []; + step: ReplaceStep; + emitCommentEvent: true; + } + | null => { + if (!tr.docChanged || tr.steps.length !== 1) return null; + + const [step] = tr.steps; + if (!(step instanceof ReplaceStep) || step.from === step.to || step.slice.content.size !== 0) return null; + + const docs = (tr as unknown as { docs?: PmNode[] }).docs ?? []; + const docBeforeStep = docs[0] ?? state.doc; + const insertedMark = getSingleTrackedInsertionMarkInRange(docBeforeStep, step.from, step.to); + if (!insertedMark) return null; + + return { + insertedMark, + deletionMark: null, + formatMark: null, + deletionNodes: [], + step, + emitCommentEvent: true, + }; +}; + +const collapsedPositionIsInsideTrackedReviewMark = (doc: PmNode, pos: number): boolean => { + const boundedPos = Math.max(0, Math.min(doc.content.size, pos)); + const $pos = doc.resolve(boundedPos); + const beforeKeys = trackedReviewMarkKeysForNode($pos.nodeBefore); + if (!beforeKeys.size) return false; + + const afterKeys = trackedReviewMarkKeysForNode($pos.nodeAfter); + for (const key of beforeKeys) { + if (afterKeys.has(key)) return true; + } + return false; +}; + +const fragmentHasTrackedReviewMark = (fragment: { + descendants?: (fn: (node: PmNode) => false | void) => void; +}): boolean => { + let found = false; + fragment.descendants?.((node) => { + if (inlineNodeHasTrackedReviewMark(node)) { + found = true; + return false; + } + return undefined; + }); + return found; +}; + +const collapsedInsertionExtendsTrackedReviewMark = ( + doc: PmNode, + pos: number, + slice: { content?: { descendants?: (fn: (node: PmNode) => false | void) => void } }, +): boolean => { + if (!slice.content || !fragmentHasTrackedReviewMark(slice.content)) return false; + const boundedPos = Math.max(0, Math.min(doc.content.size, pos)); + const $pos = doc.resolve(boundedPos); + return ( + trackedReviewMarkKeysForNode($pos.nodeBefore).size > 0 || trackedReviewMarkKeysForNode($pos.nodeAfter).size > 0 + ); +}; + +const stepTouchesTrackedReviewState = (step: unknown, doc: PmNode): boolean => { + if (step instanceof ReplaceStep) { + // Direct deletes wholly inside an unresolved insertion mutate that + // insertion in place to match Word. They should not be rerouted into a + // synthetic tracked delete. + if (step.from !== step.to && step.slice.content.size === 0 && rangeIsTrackedInsertionOnly(doc, step.from, step.to)) { + return false; + } + if (rangeHasTrackedReviewMark(doc, step.from, step.to)) return true; + if (step.from === step.to && step.slice.content.size > 0) { + return ( + collapsedPositionIsInsideTrackedReviewMark(doc, step.from) || + collapsedInsertionExtendsTrackedReviewMark(doc, step.from, step.slice) + ); + } + return false; + } + + if (step instanceof AddMarkStep || step instanceof RemoveMarkStep) { + return rangeHasTrackedReviewMark(doc, step.from, step.to); + } + + if (step instanceof ReplaceAroundStep) { + return ( + rangeHasTrackedReviewMark(doc, step.from, step.to) || rangeHasTrackedReviewMark(doc, step.gapFrom, step.gapTo) + ); + } + + return false; +}; + +const transactionTouchesTrackedReviewState = (state: EditorState, tr: Transaction): boolean => { + if (!tr.docChanged || !tr.steps.length) return false; + + const docs = (tr as unknown as { docs?: PmNode[] }).docs ?? []; + return tr.steps.some((step, index) => stepTouchesTrackedReviewState(step, docs[index] ?? state.doc)); +}; type ExtensionInstanceLike = { type?: string; @@ -502,7 +744,7 @@ export class Editor extends EventEmitter { constructor(options: Partial) { super(); - const resolvedOptions = { ...options }; + const resolvedOptions = normalizeEditorTrackChangesOptions(options); const domAvailable = canUseDOM(); const isHeadlessRequested = Boolean(resolvedOptions.isHeadless); const mountRequested = Boolean(resolvedOptions.element || resolvedOptions.selector); @@ -2765,17 +3007,27 @@ export class Editor extends EventEmitter { const trackChangesState = TrackChangesBasePluginKey.getState(prevState); const isTrackChangesActive = trackChangesState?.isTrackChangesActive ?? false; const skipTrackChanges = transactionToApply.getMeta('skipTrackChanges') === true; + const directInsertionDeleteCommentMeta = resolveDirectInsertionDeleteCommentMeta(prevState, transactionToApply); + const protectsExistingTrackedReviewState = transactionTouchesTrackedReviewState(prevState, transactionToApply); + if (protectsExistingTrackedReviewState && skipTrackChanges) { + transactionToApply.setMeta('protectTrackedReviewState', true); + } - const shouldTrack = (isTrackChangesActive || forceTrackChanges) && !skipTrackChanges; + const shouldTrack = + ((isTrackChangesActive || forceTrackChanges) && !skipTrackChanges) || protectsExistingTrackedReviewState; + if (!shouldTrack && directInsertionDeleteCommentMeta && !transactionToApply.getMeta(TrackChangesBasePluginKey)) { + transactionToApply.setMeta(TrackChangesBasePluginKey, directInsertionDeleteCommentMeta); + } if (shouldTrack && forceTrackChanges && !this.options.user) { throw new Error('forceTrackChanges requires a user to be configured on the editor instance.'); } + const trackedUser = this.options.user ?? {}; transactionToApply = shouldTrack ? trackedTransaction({ tr: transactionToApply, state: prevState, - user: this.options.user!, + user: trackedUser, replacements: this.options.trackedChanges?.replacements === 'independent' ? 'independent' : 'paired', }) : transactionToApply; @@ -3195,6 +3447,117 @@ export class Editor extends EventEmitter { return this.#prepareDocumentForExport(); } + /** + * Install a fresh Word revision id allocator on the converter. Walks the + * current document and reserves every decimal `sourceId` so newly minted ids + * never collide with imported revisions. + */ + #installWordIdAllocatorIfNeeded(): void { + if (!this.converter) return; + + const allocator = createWordIdAllocator(); + + const reserveAttrs = (attrs: { sourceId?: unknown } | undefined, path: string): void => { + const sourceId = attrs?.sourceId; + if (isDecimalWordId(sourceId)) { + allocator.reserve(path, sourceId as string | number); + } + }; + + const reserveFromDoc = (doc: PmNode | undefined | null, path: string): void => { + if (!doc) return; + doc.descendants((node) => { + if (!Array.isArray(node.marks)) return; + for (const mark of node.marks) { + const name = mark.type.name; + if (name !== 'trackInsert' && name !== 'trackDelete' && name !== 'trackFormat') continue; + reserveAttrs(mark.attrs as { sourceId?: unknown }, path); + } + }); + }; + + const reserveFromJson = (node: unknown, path: string): void => { + if (!node || typeof node !== 'object') return; + const current = node as { marks?: Array<{ type?: string; attrs?: { sourceId?: unknown } }>; content?: unknown[] }; + if (Array.isArray(current.marks)) { + for (const mark of current.marks) { + if (mark.type !== 'trackInsert' && mark.type !== 'trackDelete' && mark.type !== 'trackFormat') continue; + reserveAttrs(mark.attrs, path); + } + } + if (Array.isArray(current.content)) { + for (const child of current.content) reserveFromJson(child, path); + } + }; + + const wordPartPath = (target: unknown, fallback: string): string => { + if (typeof target !== 'string' || !target) return fallback; + const stripped = target.replace(/^\/+/, ''); + return stripped.startsWith('word/') ? stripped : `word/${stripped}`; + }; + + const relsRoot = this.converter.convertedXml?.['word/_rels/document.xml.rels']?.elements?.find( + (node: { name?: string }) => node.name === 'Relationships', + ); + const resolveRelationshipTarget = (relationshipId: string, fallback: string): string => { + const target = relsRoot?.elements?.find( + (node: { attributes?: { Id?: string; Target?: string } }) => node.attributes?.Id === relationshipId, + )?.attributes?.Target; + return wordPartPath(target, fallback); + }; + + reserveFromDoc(this.state?.doc as PmNode, 'word/document.xml'); + for (const [id, header] of Object.entries(this.converter.headers || {})) { + const partPath = resolveRelationshipTarget(id, 'word/header1.xml'); + const headerEditor = this.converter.headerEditors?.find((item: { id?: string }) => item.id === id); + reserveFromDoc(headerEditor?.editor?.state?.doc as PmNode | undefined, partPath); + reserveFromJson(header, partPath); + } + for (const [id, footer] of Object.entries(this.converter.footers || {})) { + const partPath = resolveRelationshipTarget(id, 'word/footer1.xml'); + const footerEditor = this.converter.footerEditors?.find((item: { id?: string }) => item.id === id); + reserveFromDoc(footerEditor?.editor?.state?.doc as PmNode | undefined, partPath); + reserveFromJson(footer, partPath); + } + reserveFromJson(this.converter.footnotes, 'word/footnotes.xml'); + reserveFromJson(this.converter.endnotes, 'word/endnotes.xml'); + + (this.converter as ConverterWithInternalWordIdAllocator).wordIdAllocator = allocator; + } + + #persistTrackedChangeSourceIdMap(): void { + if (!this.converter) return; + + const allocator = (this.converter as ConverterWithInternalWordIdAllocator).wordIdAllocator; + const sourceIdMap = allocator?.getSourceIdMap?.() ?? {}; + if (Object.keys(sourceIdMap).length === 0 && !this.#hasTrackedChangeSourceIdMapProperty()) return; + + const payload = JSON.stringify({ version: 1, parts: sourceIdMap }); + SuperConverter.setStoredCustomProperty( + this.converter.convertedXml, + TRACKED_CHANGE_SOURCE_ID_MAP_PROPERTY, + payload, + false, + ); + } + + #hasTrackedChangeSourceIdMapProperty(): boolean { + const customXml = this.converter?.convertedXml?.['docProps/custom.xml']; + const properties = customXml?.elements?.find((node: { name?: string }) => { + const name = node?.name; + return name === 'Properties' || name?.endsWith(':Properties'); + }); + return Boolean( + properties?.elements?.some((node: { name?: string; attributes?: { name?: string } }) => { + const name = node?.name; + return ( + (name === 'property' || name?.endsWith(':property')) && + node.attributes?.name === TRACKED_CHANGE_SOURCE_ID_MAP_PROPERTY + ); + }), + ); + } + /** * Export the editor document to DOCX. * @@ -3240,15 +3603,26 @@ export class Editor extends EventEmitter { // Normalize commentJSON property (imported comments provide `elements`) const preparedComments = effectiveComments.map((comment: Comment) => { const elements = Array.isArray(comment.elements) && comment.elements.length ? comment.elements : undefined; + const commentJson = + comment.commentJSON ?? + elements ?? + (typeof comment.commentText === 'string' ? buildCommentJsonFromText(comment.commentText) : undefined); return { ...comment, - commentJSON: comment.commentJSON ?? elements, + commentJSON: commentJson, }; }); // Pre-process the document state to prepare for export const json = this.#prepareDocumentForExport(preparedComments); + // Set up the Word revision id allocator on the converter so + // ins/del/rPrChange decoders can mint + // Word-compatible decimal `w:id` values without overwriting preserved + // imported `sourceId` values. The allocator is reset per export so the + // reserved set always reflects the current document state. + this.#installWordIdAllocatorIfNeeded(); + // Export the document to DOCX // GUID will be handled automatically in converter.exportToDocx if document was modified const documentXml = await this.converter.exportToDocx( @@ -3267,6 +3641,8 @@ export class Editor extends EventEmitter { if (exportXmlOnly || exportJsonOnly) return documentXml; + this.#persistTrackedChangeSourceIdMap(); + const customXml = this.converter.schemaToXml(this.converter.convertedXml['docProps/custom.xml'].elements[0]); const styles = this.converter.schemaToXml(this.converter.convertedXml['word/styles.xml'].elements[0]); const hasCustomSettings = !!this.converter.convertedXml['word/settings.xml']?.elements?.length; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index d72de0811f..803e8d459e 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -266,7 +266,10 @@ import { findAllBookmarksInDocument, resolveBookmarkTarget, } from '../../document-api-adapters/helpers/bookmark-resolver.js'; -import { resolveTrackedChange } from '../../document-api-adapters/helpers/tracked-change-resolver.js'; +import { + resolveTrackedChange, + resolveTrackedChangeInStory, +} from '../../document-api-adapters/helpers/tracked-change-resolver.js'; import { makeTrackedChangeAnchorKey } from '../../document-api-adapters/helpers/tracked-change-runtime-ref.js'; import { getTrackedChangeIndex } from '../../document-api-adapters/tracked-changes/tracked-change-index.js'; import { normalizeVariant } from './header-footer/header-footer-variant.js'; @@ -8563,33 +8566,54 @@ export class PresentationEditor extends EventEmitter { const behavior = options.behavior ?? 'auto'; const block = options.block ?? 'center'; + const navigationIds = this.#resolveTrackedChangeNavigationIds(entityId, storyKey); if (storyKey && storyKey !== BODY_STORY_KEY) { - if (this.#navigateToActiveStoryTrackedChange(entityId, storyKey)) { - return true; + for (const id of navigationIds) { + if (this.#navigateToActiveStoryTrackedChange(id, storyKey)) { + return true; + } } - if (await this.#activateTrackedChangeStorySurface(entityId, storyKey, preferredPageIndex)) { - if (this.#navigateToActiveStoryTrackedChange(entityId, storyKey)) { - return true; + for (const id of navigationIds) { + if (await this.#activateTrackedChangeStorySurface(id, storyKey, preferredPageIndex)) { + for (const activeId of navigationIds) { + if (this.#navigateToActiveStoryTrackedChange(activeId, storyKey)) { + return true; + } + } } } - return this.#scrollToRenderedTrackedChange(entityId, storyKey, preferredPageIndex, { behavior, block }); + for (const id of navigationIds) { + if (await this.#scrollToRenderedTrackedChange(id, storyKey, preferredPageIndex, { behavior, block })) { + return true; + } + } + return false; } const setCursorById = editor.commands?.setCursorById; // Try direct cursor placement, then scroll to the new selection. - if (typeof setCursorById === 'function' && setCursorById(entityId, { preferredActiveThreadId: entityId })) { - await this.scrollToPositionAsync(editor.state.selection.from, { behavior, block }); - return true; + if (typeof setCursorById === 'function') { + for (const id of navigationIds) { + if (setCursorById(id, { preferredActiveThreadId: id })) { + await this.scrollToPositionAsync(editor.state.selection.from, { behavior, block }); + return true; + } + } } // Fall back to resolving the tracked change position and scrolling. - const resolved = resolveTrackedChange(editor, entityId); + const resolved = navigationIds.map((id) => resolveTrackedChange(editor, id)).find(Boolean); if (!resolved) { - return this.#scrollToRenderedTrackedChange(entityId, undefined, preferredPageIndex, { behavior, block }); + for (const id of navigationIds) { + if (await this.#scrollToRenderedTrackedChange(id, undefined, preferredPageIndex, { behavior, block })) { + return true; + } + } + return false; } // Try with the raw ID (tracked changes may use a different internal ID). @@ -8612,6 +8636,45 @@ export class PresentationEditor extends EventEmitter { return true; } + #resolveTrackedChangeNavigationIds(entityId: string, storyKey?: string): string[] { + const ids: string[] = []; + const seen = new Set(); + const add = (value: unknown) => { + if (value === undefined || value === null) return; + const id = String(value).trim(); + if (!id || seen.has(id)) return; + seen.add(id); + ids.push(id); + }; + + add(entityId); + + let story: StoryLocator | undefined; + if (storyKey && storyKey !== BODY_STORY_KEY) { + try { + story = parseStoryKey(storyKey); + } catch { + story = undefined; + } + } + + try { + const resolved = resolveTrackedChangeInStory(this.#editor, { + kind: 'entity', + entityType: 'trackedChange', + entityId, + ...(story ? { story } : {}), + }); + add(resolved?.change?.commandRawId); + add(resolved?.change?.rawId); + add(resolved?.change?.id); + } catch { + // Navigation still has direct-id and rendered-DOM fallbacks. + } + + return ids; + } + async #activateTrackedChangeStorySurface( entityId: string, storyKey: string, diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts index d37adab113..4821270212 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts @@ -3323,6 +3323,51 @@ describe('PresentationEditor', () => { expect(sessionEditor?.view.focus).toHaveBeenCalled(); }); + it('resolves public Word tracked-change ids to runtime mark ids during story navigation', async () => { + const { sessionEditor } = await activateFootnoteSession(); + const setCursorById = vi.fn((id: string) => id === 'runtime-note-1'); + if (sessionEditor?.commands) { + sessionEditor.commands.setCursorById = setCursorById; + } + if (sessionEditor?.state?.doc) { + sessionEditor.state.doc.descendants = vi.fn((callback: (node: unknown, pos: number) => void) => { + callback( + { + isInline: true, + nodeSize: 13, + text: 'FN_TC_CHARLIE', + marks: [ + { + type: { name: 'trackInsert' }, + attrs: { + id: 'runtime-note-1', + sourceId: '101', + author: 'Story Harness', + date: '2024-01-01T00:00:00Z', + }, + }, + ], + }, + 2, + ); + }); + } + + const didNavigate = await editor.navigateTo({ + kind: 'entity', + entityType: 'trackedChange', + entityId: 'word:trackInsert:101', + story: { kind: 'story', storyType: 'footnote', noteId: '1' }, + }); + + expect(didNavigate).toBe(true); + expect(setCursorById).toHaveBeenCalledWith('word:trackInsert:101', { + preferredActiveThreadId: 'word:trackInsert:101', + }); + expect(setCursorById).toHaveBeenCalledWith('runtime-note-1', { preferredActiveThreadId: 'runtime-note-1' }); + expect(sessionEditor?.view.focus).toHaveBeenCalled(); + }); + it('falls back to rendered tracked-change stamps for inactive non-body stories', async () => { const { viewport } = await prepareFootnoteEditor(); const page = document.createElement('div'); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.d.ts b/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.d.ts index 1fa76a804f..ef0c4d4c85 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.d.ts +++ b/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.d.ts @@ -40,6 +40,12 @@ export class SuperConverter { }); static getStoredSuperdocVersion(docx: readonly { readonly name: string; readonly content: string }[]): string | null; + static setStoredCustomProperty( + docx: unknown, + propertyName: string, + value: string | (() => string), + preserveExisting?: boolean, + ): string | null; // The setter accepts either shape (array of file entries or mutable map // keyed by package path); the underlying `setStoredCustomProperty` does // `docx[customLocation] = ...`, which works on both at runtime. diff --git a/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js b/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js index d37bf2637b..cb8ee0b0f2 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js @@ -48,6 +48,8 @@ const FONT_FAMILY_FALLBACKS = Object.freeze({ const DEFAULT_GENERIC_FALLBACK = 'sans-serif'; const DEFAULT_FONT_SIZE_PT = 10; const CURRENT_APP_VERSION = typeof __APP_VERSION__ === 'string' && __APP_VERSION__ ? __APP_VERSION__ : '0.0.0'; +const SUPERDOC_DOCUMENT_ORIGIN_PROPERTY = 'SuperdocDocumentOrigin'; +const STORED_DOCUMENT_ORIGINS = new Set(['word', 'google-docs', 'unknown', 'superdoc']); /** * Pull default run formatting (font family, size, kern) out of a DOCX run properties node. @@ -212,6 +214,14 @@ class SuperConverter { */ this.trackedChangesOptions = params?.trackedChangesOptions || null; + /** + * Word revision id allocator. Built lazily at export time; preserves + * imported decimal `w:id` values and mints fresh part-local decimal ids + * for native revisions / successor fragments. + * @type {import('@extensions/track-changes/review-model/word-id-allocator').WordIdAllocator | null} + */ + this.wordIdAllocator = null; + this.addedMedia = {}; this.comments = []; this.footnotes = []; @@ -1270,6 +1280,13 @@ class SuperConverter { // Store SuperDoc version SuperConverter.setStoredSuperdocVersion(this.convertedXml); + const storedDocumentOrigin = STORED_DOCUMENT_ORIGINS.has(this.documentOrigin) ? this.documentOrigin : 'superdoc'; + SuperConverter.setStoredCustomProperty( + this.convertedXml, + SUPERDOC_DOCUMENT_ORIGIN_PROPERTY, + storedDocumentOrigin, + false, + ); // Store document GUID if document was modified if (this.documentModified || this.documentGuid) { @@ -1303,6 +1320,7 @@ class SuperConverter { preserveSdtWrappers = false, statFieldCacheMap = undefined, existingRelationships = [], + partPath = 'word/document.xml', }) { const bodyNode = this.savedTagsToRestore.find((el) => el.name === 'w:body'); @@ -1340,6 +1358,7 @@ class SuperConverter { preserveSdtWrappers, statFieldCacheMap: resolvedCacheMap, existingRelationships, + currentPartPath: partPath, }); return { result, params }; @@ -1500,6 +1519,7 @@ class SuperConverter { isHeaderFooter: true, isFinalDoc, existingRelationships, + partPath, }); const bodyContent = result.elements[0].elements; @@ -1566,6 +1586,7 @@ class SuperConverter { isHeaderFooter: true, isFinalDoc, existingRelationships, + partPath, }); const bodyContent = result.elements[0].elements; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/footnotesExporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/footnotesExporter.js index c3b3a7a7d5..a2482e4af7 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/footnotesExporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/footnotesExporter.js @@ -237,6 +237,7 @@ const prepareNotesXmlForExport = ({ notes, editor, converter, convertedXml, conf converter, relationships: footnoteRelationships, media: footnoteMedia, + currentPartPath: config.notesPath, }; const footnoteElements = notes.map((fn) => createFootnoteElement(fn, exportContext, config)).filter(Boolean); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js index b4f84e5680..ca8c9cf1a7 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js @@ -47,6 +47,7 @@ import { translator as wNumberingTranslator } from '@converter/v3/handlers/w/num import { baseNumbering } from '@converter/v2/exporter/helpers/base-list.definitions.js'; import { patchNumberingDefinitions } from './patchNumberingDefinitions.js'; import { startCollection, drainDiagnostics } from '@converter/v3/handlers/import-diagnostics.js'; +import { TRACKED_CHANGE_SOURCE_ID_MAP_PROPERTY } from '@extensions/track-changes/review-model/word-id-allocator.js'; /** * @typedef {import()} XmlNode @@ -72,7 +73,39 @@ import { startCollection, drainDiagnostics } from '@converter/v3/handlers/import * @param {ParsedDocx} docx The parsed docx object * @returns {'word' | 'google-docs' | 'unknown'} The detected origin */ +const OFFICE_DOCUMENT_RELATIONSHIP = + 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument'; +const SUPERDOC_DOCUMENT_ORIGIN_PROPERTY = 'SuperdocDocumentOrigin'; +const STORED_DOCUMENT_ORIGINS = new Set(['word', 'google-docs', 'unknown', 'superdoc']); + +const listPackageRelationships = (docx) => { + try { + const relationships = docx?.['_rels/.rels']?.elements?.find((el) => matchesElementName(el?.name, 'Relationships')); + return Array.isArray(relationships?.elements) + ? relationships.elements.filter((el) => matchesElementName(el?.name, 'Relationship')) + : []; + } catch { + return []; + } +}; + +const looksLikeGoogleDocsMinimalPackage = (docx) => { + const packageRelationships = listPackageRelationships(docx); + if (packageRelationships.length !== 1) return false; + if (packageRelationships[0]?.attributes?.Type !== OFFICE_DOCUMENT_RELATIONSHIP) return false; + return !docx?.['docProps/app.xml'] && !docx?.['docProps/core.xml'] && !docx?.['word/webSettings.xml']; +}; + const detectDocumentOrigin = (docx) => { + const storedOrigin = readCustomProperty(docx, SUPERDOC_DOCUMENT_ORIGIN_PROPERTY); + if (storedOrigin && STORED_DOCUMENT_ORIGINS.has(storedOrigin)) { + return storedOrigin; + } + + if (readCustomProperty(docx, 'SuperdocVersion') || readCustomProperty(docx, TRACKED_CHANGE_SOURCE_ID_MAP_PROPERTY)) { + return 'superdoc'; + } + const commentsExtended = docx['word/commentsExtended.xml']; if (commentsExtended) { const { elements: initialElements = [] } = commentsExtended; @@ -92,9 +125,58 @@ const detectDocumentOrigin = (docx) => { return 'google-docs'; } + if (looksLikeGoogleDocsMinimalPackage(docx)) { + return 'google-docs'; + } + return 'unknown'; }; +const matchesElementName = (name, localName) => { + if (typeof name !== 'string') return false; + return name === localName || name.endsWith(`:${localName}`); +}; + +function readCustomProperty(docx, propertyName) { + try { + const customXml = docx?.['docProps/custom.xml']; + const properties = customXml?.elements?.find((el) => matchesElementName(el?.name, 'Properties')); + const property = properties?.elements?.find( + (el) => matchesElementName(el?.name, 'property') && el?.attributes?.name === propertyName, + ); + const value = property?.elements?.[0]?.elements?.[0]?.text; + return typeof value === 'string' && value.length > 0 ? value : null; + } catch { + return null; + } +} + +const parseTrackedChangeSourceIdMap = (raw) => { + if (typeof raw !== 'string' || raw.length === 0) return new Map(); + + try { + const parsed = JSON.parse(raw); + const parts = parsed && typeof parsed === 'object' ? parsed.parts : null; + if (!parts || typeof parts !== 'object') return new Map(); + + const byPart = new Map(); + for (const [partPath, entries] of Object.entries(parts)) { + if (!partPath || !entries || typeof entries !== 'object') continue; + const byWordId = new Map(); + for (const [wordId, sourceId] of Object.entries(entries)) { + if (typeof sourceId === 'string' && sourceId.length > 0) byWordId.set(wordId, sourceId); + } + if (byWordId.size > 0) byPart.set(partPath, byWordId); + } + return byPart; + } catch { + return new Map(); + } +}; + +const readTrackedChangeSourceIdMap = (docx) => + parseTrackedChangeSourceIdMap(readCustomProperty(docx, TRACKED_CHANGE_SOURCE_ID_MAP_PROPERTY)); + /** * Detect the document-level threading profile for comments based on file structure. * @param {ParsedDocx} docx The parsed docx object @@ -125,6 +207,7 @@ export const createDocumentJson = (docx, converter, editor) => { importViewSettingFromSettings(docx, converter); converter.documentOrigin = detectDocumentOrigin(docx); converter.commentThreadingProfile = detectCommentThreadingProfile(docx); + converter.trackedChangeSourceIdMapByPart = readTrackedChangeSourceIdMap(docx); } const nodeListHandler = defaultNodeListHandler(); @@ -324,6 +407,7 @@ const createNodeListHandler = (nodeHandlers) => { parentStyleId, lists, inlineDocumentFonts, + importTrackingContext, path = [], extraParams = {}, }) => { @@ -359,6 +443,7 @@ const createNodeListHandler = (nodeHandlers) => { parentStyleId, lists, inlineDocumentFonts, + importTrackingContext, path, extraParams, }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.test.js index 5a5b41f8aa..d5f8d93449 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.test.js @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { + createDocumentJson, collapseWhitespaceNextToInlinePassthrough, defaultNodeListHandler, filterOutRootInlineNodes, @@ -10,6 +11,92 @@ import { const n = (type, attrs = {}) => ({ type, attrs, marks: [] }); +const makeCustomPropertyDocx = (propertyName, textValue) => ({ + 'word/document.xml': { + elements: [ + { + name: 'w:document', + elements: [{ name: 'w:body', elements: [] }], + }, + ], + }, + 'docProps/custom.xml': { + elements: [ + { + name: 'Properties', + elements: [ + { + name: 'property', + attributes: { name: propertyName }, + elements: [ + { + name: 'vt:lpwstr', + elements: [{ type: 'text', text: textValue }], + }, + ], + }, + ], + }, + ], + }, +}); + +const makeCustomPropertiesDocx = (properties) => ({ + 'word/document.xml': { + elements: [ + { + name: 'w:document', + elements: [{ name: 'w:body', elements: [] }], + }, + ], + }, + 'docProps/custom.xml': { + elements: [ + { + name: 'Properties', + elements: Object.entries(properties).map(([propertyName, textValue]) => ({ + name: 'property', + attributes: { name: propertyName }, + elements: [ + { + name: 'vt:lpwstr', + elements: [{ type: 'text', text: textValue }], + }, + ], + })), + }, + ], + }, +}); + +const makeGoogleDocsLikeDocx = () => ({ + 'word/document.xml': { + elements: [ + { + name: 'w:document', + elements: [{ name: 'w:body', elements: [] }], + }, + ], + }, + '_rels/.rels': { + elements: [ + { + name: 'Relationships', + elements: [ + { + name: 'Relationship', + attributes: { + Id: 'rId1', + Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument', + Target: 'word/document.xml', + }, + }, + ], + }, + ], + }, +}); + describe('filterOutRootInlineNodes', () => { it('removes inline nodes at the root and keeps block nodes', () => { const input = [ @@ -113,6 +200,39 @@ describe('filterOutRootInlineNodes', () => { }); }); +describe('createDocumentJson document origin detection', () => { + it('prefers the stored SuperDoc document origin over the package-level SuperdocVersion marker', () => { + const converter = {}; + + createDocumentJson( + makeCustomPropertiesDocx({ + SuperdocVersion: '1.2.3', + SuperdocDocumentOrigin: 'google-docs', + }), + converter, + undefined, + ); + + expect(converter.documentOrigin).toBe('google-docs'); + }); + + it('classifies SuperDoc-authored packages via the stored SuperdocVersion custom property', () => { + const converter = {}; + + createDocumentJson(makeCustomPropertyDocx('SuperdocVersion', '1.2.3'), converter, undefined); + + expect(converter.documentOrigin).toBe('superdoc'); + }); + + it('classifies minimal Google Docs-like packages when the OPC root lacks metadata parts', () => { + const converter = {}; + + createDocumentJson(makeGoogleDocsLikeDocx(), converter, undefined); + + expect(converter.documentOrigin).toBe('google-docs'); + }); +}); + describe('collapseWhitespaceNextToInlinePassthrough', () => { const paragraph = (content) => ({ type: 'paragraph', content }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/importTrackingContext.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/importTrackingContext.js new file mode 100644 index 0000000000..1441ad1024 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/importTrackingContext.js @@ -0,0 +1,163 @@ +// @ts-check +import { createImportTrackingContext, withParentFrame } from '@extensions/track-changes/review-model/import-context.js'; + +const contextsByConverter = new WeakMap(); + +/** + * @typedef {{ + * trackedChangeIdMapsByPart?: Map>, + * trackedChangeIdMap?: Map, + * trackedChangeSourceIdMapByPart?: Map>, + * trackedChangesOptions?: { replacements?: 'paired' | 'independent' }, + * }} ConverterLike + * + * @typedef {Record & { + * converter?: ConverterLike, + * currentPartPath?: string, + * filename?: string, + * importTrackingContext?: import('@extensions/track-changes/review-model/import-context.js').ImportTrackingContext, + * }} ImportTrackingParams + */ + +/** + * @param {ImportTrackingParams} [params] + * @returns {string} + */ +export function resolveTrackedChangePartPath(params = {}) { + const currentPartPath = params.currentPartPath; + if (typeof currentPartPath === 'string' && currentPartPath.length > 0) return currentPartPath; + + const filename = params.filename; + if (typeof filename === 'string' && filename.length > 0) { + return filename.startsWith('word/') ? filename : `word/${filename}`; + } + + return 'word/document.xml'; +} + +/** + * @param {ImportTrackingParams} [params] + * @param {string} [partPath] + */ +export function getTrackedChangeIdMapForPart(params = {}, partPath = resolveTrackedChangePartPath(params)) { + const converter = params.converter; + if (!converter || typeof converter !== 'object') return null; + + const mapsByPart = converter.trackedChangeIdMapsByPart; + return mapsByPart?.get?.(partPath) ?? converter.trackedChangeIdMap ?? null; +} + +/** + * @param {ImportTrackingParams} [params] + * @param {string} [partPath] + * @param {string} [wordId] + */ +function getTrackedChangeSourceIdForPart(params = {}, partPath = resolveTrackedChangePartPath(params), wordId = '') { + const converter = params.converter; + if (!converter || typeof converter !== 'object') return null; + + const sourceIdsByPart = converter.trackedChangeSourceIdMapByPart; + const restored = sourceIdsByPart?.get?.(partPath)?.get?.(wordId); + return typeof restored === 'string' && restored.length > 0 ? restored : null; +} + +/** + * @param {ImportTrackingParams} [params] + * @param {string} sourceId + * @returns {{ partPath: string, sourceId: string, logicalId: string }} + */ +export function resolveTrackedChangeImportIds(params = {}, sourceId = '') { + const partPath = resolveTrackedChangePartPath(params); + const id = typeof sourceId === 'string' ? sourceId : String(sourceId || ''); + const restoredSourceId = getTrackedChangeSourceIdForPart(params, partPath, id) ?? id; + const trackedChangeIdMap = getTrackedChangeIdMapForPart(params, partPath); + return { + partPath, + sourceId: restoredSourceId, + logicalId: id && trackedChangeIdMap?.has(id) ? (trackedChangeIdMap.get(id) ?? id) : id, + }; +} + +/** + * @param {ImportTrackingParams} [params] + * @param {string} [partPath] + * @returns {import('@extensions/track-changes/review-model/import-context.js').ImportTrackingContext | null} + */ +export function getOrCreateImportTrackingContext(params = {}, partPath = resolveTrackedChangePartPath(params)) { + const supplied = params.importTrackingContext; + if (supplied?.forNestedPart) { + return supplied.partPath === partPath ? supplied : supplied.forNestedPart(partPath); + } + + const converter = params.converter; + const trackedChangesOptions = + converter && typeof converter === 'object' && converter.trackedChangesOptions + ? converter.trackedChangesOptions + : null; + + if (!converter || typeof converter !== 'object') { + return createImportTrackingContext({ + partPath, + replacements: trackedChangesOptions?.replacements ?? 'paired', + }); + } + + let contextsByPart = contextsByConverter.get(converter); + if (!contextsByPart) { + contextsByPart = new Map(); + contextsByConverter.set(converter, contextsByPart); + } + + let context = contextsByPart.get(partPath); + if (!context) { + context = createImportTrackingContext({ + partPath, + replacements: trackedChangesOptions?.replacements ?? 'paired', + }); + contextsByPart.set(partPath, context); + } + + return context; +} + +/** + * @param {{ + * params?: ImportTrackingParams, + * attrs: Record, + * side: 'insertion' | 'deletion' | 'formatting', + * sourceId?: string, + * partPath?: string, + * }} input + * @returns {{ context: import('@extensions/track-changes/review-model/import-context.js').ImportTrackingContext | null, frame: import('@extensions/track-changes/review-model/import-context.js').ParentFrame | null }} + */ +export function stampImportTrackingAttrs(input) { + const { params = {}, attrs, side, sourceId = '', partPath = resolveTrackedChangePartPath(params) } = input; + const context = getOrCreateImportTrackingContext(params, partPath); + if (!context) { + return { context: null, frame: null }; + } + + const logicalId = typeof attrs.id === 'string' ? attrs.id : String(attrs.id || ''); + const parent = context.currentParent(); + if (parent?.logicalId) { + attrs.overlapParentId = parent.logicalId; + } + if (logicalId) { + context.recordLogicalId(logicalId, { sourceId, side }); + } + + return { + context, + frame: logicalId + ? { + logicalId, + side, + sourceId, + author: typeof attrs.author === 'string' ? attrs.author : '', + date: typeof attrs.date === 'string' ? attrs.date : '', + } + : null, + }; +} + +export { withParentFrame }; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/importTrackingContext.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/importTrackingContext.test.js new file mode 100644 index 0000000000..17331586b8 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/importTrackingContext.test.js @@ -0,0 +1,31 @@ +// @ts-check +import { describe, expect, it } from 'vitest'; +import { resolveTrackedChangeImportIds } from './importTrackingContext.js'; + +describe('resolveTrackedChangeImportIds', () => { + it('restores exported non-decimal source ids while keeping logical ids keyed by the Word id', () => { + const converter = { + trackedChangeIdMap: new Map([['1', 'logical-from-import-map']]), + trackedChangeSourceIdMapByPart: new Map([['word/document.xml', new Map([['1', 'uuid-source-id']])]]), + }; + + expect(resolveTrackedChangeImportIds({ converter }, '1')).toEqual({ + partPath: 'word/document.xml', + sourceId: 'uuid-source-id', + logicalId: 'logical-from-import-map', + }); + }); + + it('preserves raw decimal Word ids when no source-id restore entry exists', () => { + const converter = { + trackedChangeIdMap: new Map([['2', 'logical-two']]), + trackedChangeSourceIdMapByPart: new Map([['word/document.xml', new Map([['1', 'uuid-source-id']])]]), + }; + + expect(resolveTrackedChangeImportIds({ converter }, '2')).toEqual({ + partPath: 'word/document.xml', + sourceId: '2', + logicalId: 'logical-two', + }); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/markImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/markImporter.js index c49b3c20a9..39a5e3554c 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/markImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/markImporter.js @@ -3,6 +3,7 @@ import { TrackFormatMarkName } from '@extensions/track-changes/constants.js'; import { getHexColorFromDocxSystem, isValidHexColor, twipsToInches, twipsToLines, twipsToPt } from '../../helpers.js'; import { translator as wRPrTranslator } from '../../v3/handlers/w/rpr/index.js'; import { encodeMarksFromRPr } from '@converter/styles.js'; +import { resolveTrackedChangeImportIds, stampImportTrackingAttrs } from './importTrackingContext.js'; /** * @@ -112,13 +113,21 @@ export function handleStyleChangeMarksV2(rPrChange, currentMarks, params) { } const attributes = rPrChange.attributes || {}; + const { partPath, sourceId, logicalId } = resolveTrackedChangeImportIds(params, attributes['w:id']); const mappedAttributes = { - id: attributes['w:id'], - sourceId: attributes['w:id'], + id: logicalId, + sourceId, date: attributes['w:date'], author: attributes['w:author'], authorEmail: attributes['w:authorEmail'], }; + stampImportTrackingAttrs({ + params, + attrs: mappedAttributes, + side: 'formatting', + sourceId, + partPath, + }); let submarks = []; const rPr = rPrChange.elements?.find((el) => el.name === 'w:rPr'); if (rPr) { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/markImporter.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/markImporter.test.js index 4df8788ae0..09995acfdc 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/markImporter.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/markImporter.test.js @@ -13,6 +13,7 @@ import { } from './markImporter.js'; import { SuperConverter } from '../../SuperConverter.js'; import { TrackFormatMarkName } from '@extensions/track-changes/constants.js'; +import { createImportTrackingContext } from '@extensions/track-changes/review-model/import-context.js'; const themeDoc = { elements: [ @@ -188,6 +189,40 @@ describe('handleStyleChangeMarksV2', () => { expect(result[0].attrs.before).toEqual([]); expect(result[0].attrs.after).toEqual([]); }); + + it('remaps format change ids and stamps overlapParentId through import tracking context', () => { + const context = createImportTrackingContext({}); + context.pushParent({ + logicalId: 'parent-insert', + side: 'insertion', + sourceId: '4', + author: 'Parent', + date: '2026-05-21T00:00:00Z', + }); + const rPrChange = { + name: 'w:rPrChange', + attributes: { + 'w:id': '2', + 'w:date': '2024-09-04T09:29:00Z', + 'w:author': 'author@example.com', + }, + elements: [{ name: 'w:rPr', elements: [] }], + }; + + const result = handleStyleChangeMarksV2(rPrChange, [], { + docx: {}, + importTrackingContext: context, + converter: { + trackedChangeIdMap: new Map([['2', 'format-logical-id']]), + }, + }); + + expect(result[0].attrs).toMatchObject({ + id: 'format-logical-id', + sourceId: '2', + overlapParentId: 'parent-insert', + }); + }); }); describe('createImportMarks', () => { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers.js index f9bc1313dd..20b26f5828 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers.js @@ -25,7 +25,7 @@ export const findTrackFormatMark = (marks = []) => * @param {Object|null|undefined} trackFormatMark * @returns {Object|undefined} */ -export const createRunPropertiesChangeElement = (trackFormatMark) => { +export const createRunPropertiesChangeElement = (trackFormatMark, options = {}) => { if (!trackFormatMark) return undefined; const beforeMarks = Array.isArray(trackFormatMark.attrs?.before) ? trackFormatMark.attrs.before : []; @@ -35,11 +35,20 @@ export const createRunPropertiesChangeElement = (trackFormatMark) => { elements: toRunPropertyElements(beforeMarks), }; + // Phase 005 — if an allocator was passed in, mint a Word-native decimal + // `w:id`. Legacy callers (no `options.wordIdAllocator`) keep the prior + // `sourceId || id` behavior so the exported byte stream is unchanged. + const allocator = options?.wordIdAllocator || null; + const partPath = options?.partPath || 'word/document.xml'; + const sourceId = trackFormatMark.attrs?.sourceId; + const logicalId = trackFormatMark.attrs?.id; + const wordId = allocator ? allocator.allocate({ partPath, sourceId, logicalId }) : sourceId || logicalId; + return { type: 'element', name: 'w:rPrChange', attributes: { - 'w:id': trackFormatMark.attrs?.sourceId || trackFormatMark.attrs?.id, + 'w:id': wordId, 'w:author': trackFormatMark.attrs?.author, 'w:authorEmail': trackFormatMark.attrs?.authorEmail, 'w:date': trackFormatMark.attrs?.date, @@ -55,7 +64,7 @@ export const createRunPropertiesChangeElement = (trackFormatMark) => { * @param {Array} marks * @returns {Object|null|undefined} */ -export const appendTrackFormatChangeToRunProperties = (runPropertiesNode, marks = []) => { +export const appendTrackFormatChangeToRunProperties = (runPropertiesNode, marks = [], options = {}) => { if (!runPropertiesNode) return runPropertiesNode; const trackFormatMark = findTrackFormatMark(marks); @@ -68,7 +77,7 @@ export const appendTrackFormatChangeToRunProperties = (runPropertiesNode, marks const hasExistingChange = runPropertiesNode.elements.some((element) => element?.name === 'w:rPrChange'); if (hasExistingChange) return runPropertiesNode; - const changeElement = createRunPropertiesChangeElement(trackFormatMark); + const changeElement = createRunPropertiesChangeElement(trackFormatMark, options); if (changeElement) { runPropertiesNode.elements.push(changeElement); } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js index a12f9a9cbf..adc2baae43 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js @@ -2,6 +2,11 @@ import { NodeTranslator } from '@translator'; import { createAttributeHandler } from '@converter/v3/handlers/utils.js'; import { exportSchemaToJson } from '@converter/exporter.js'; +import { + resolveTrackedChangeImportIds, + stampImportTrackingAttrs, + withParentFrame, +} from '../../../../v2/importer/importTrackingContext.js'; /** @type {import('@translator').XmlNodeName} */ const XML_NODE_NAME = 'w:del'; @@ -19,33 +24,42 @@ const validXmlAttributes = [ /** * Encode the w:del element - * @param {import('@translator').SCEncoderConfig} params + * @param {import('@translator').SCEncoderConfig & { importTrackingContext?: import('@extensions/track-changes/review-model/import-context.js').ImportTrackingContext }} params + * @param {Record} [encodedAttrs] * @returns {import('@translator').SCEncoderResult} */ const encode = (params, encodedAttrs = {}) => { - const { nodeListHandler, extraParams = {}, converter, filename } = params; + const { nodeListHandler, extraParams = {} } = params; const { node } = extraParams; // Preserve the original OOXML w:id for round-trip export fidelity. // The internal id is remapped to a shared UUID for replacement pairing. - const originalWordId = encodedAttrs.id; - const partPath = typeof filename === 'string' && filename.length > 0 ? `word/${filename}` : 'word/document.xml'; - const trackedChangeIdMap = - converter?.trackedChangeIdMapsByPart?.get?.(partPath) ?? converter?.trackedChangeIdMap ?? null; - if (originalWordId && trackedChangeIdMap?.has(originalWordId)) { - encodedAttrs.id = trackedChangeIdMap.get(originalWordId); - } - encodedAttrs.sourceId = originalWordId || ''; + const { partPath, sourceId, logicalId } = resolveTrackedChangeImportIds(params, encodedAttrs.id); + encodedAttrs.id = logicalId; + encodedAttrs.sourceId = sourceId; + const { context, frame } = stampImportTrackingAttrs({ + params, + attrs: encodedAttrs, + side: 'deletion', + sourceId, + partPath, + }); - const subs = nodeListHandler.handler({ + const childParams = { ...params, insideTrackChange: true, + importTrackingContext: context ?? params.importTrackingContext, nodes: node.elements, path: [...(params.path || []), node], - }); + }; + const subs = + context && frame + ? withParentFrame(context, frame, () => nodeListHandler.handler(childParams)) + : nodeListHandler.handler(childParams); encodedAttrs.importedAuthor = `${encodedAttrs.author} (imported)`; + const converter = /** @type {{ documentOrigin?: string } | undefined} */ (params.converter); if (converter?.documentOrigin) { encodedAttrs.origin = converter.documentOrigin; } @@ -73,17 +87,16 @@ function decode(params) { const { node } = params; if (!node || !node.type) { - return null; + return /** @type {import('@translator').SCDecoderResult} */ (/** @type {unknown} */ (null)); } - const trackingMarks = ['trackInsert', 'trackDelete']; const marks = Array.isArray(node.marks) ? node.marks : []; const trackedMark = marks.find((m) => m.type === 'trackDelete'); if (!trackedMark) { - return null; + return /** @type {import('@translator').SCDecoderResult} */ (/** @type {unknown} */ (null)); } - node.marks = marks.filter((m) => !trackingMarks.includes(m.type)); + node.marks = marks.filter((m) => m.type !== 'trackDelete'); const translatedTextNode = exportSchemaToJson({ ...params, node }); // ECMA-376 renames w:t → w:delText inside . Other inline content — @@ -96,7 +109,7 @@ function decode(params) { return { name: 'w:del', attributes: { - 'w:id': trackedMark.attrs.sourceId || trackedMark.attrs.id, + 'w:id': resolveExportWordId(params, trackedMark.attrs), 'w:author': trackedMark.attrs.author, 'w:authorEmail': trackedMark.attrs.authorEmail, 'w:date': trackedMark.attrs.date, @@ -105,6 +118,45 @@ function decode(params) { }; } +/** + * Resolve the `w:id` to write on export. Uses the Word revision id allocator + * when one is installed on the converter; otherwise falls through to + * `sourceId || id`. + * + * @param {import('@translator').SCDecoderConfig} params + * @param {Record} attrs + * @returns {string} + */ +function resolveExportWordId(params, attrs) { + const sourceId = attrs?.sourceId; + /** @type {string | number | null | undefined} */ + let exportSourceId; + if (typeof sourceId === 'string' || typeof sourceId === 'number') { + exportSourceId = sourceId; + } else if (sourceId === null) { + exportSourceId = null; + } else if (sourceId === undefined) { + exportSourceId = undefined; + } else { + exportSourceId = String(sourceId); + } + const logicalId = typeof attrs?.id === 'string' ? attrs.id : ''; + const exportParams = + /** @type {import('@translator').SCDecoderConfig & { converter?: { wordIdAllocator?: import('@extensions/track-changes/review-model/word-id-allocator.js').WordIdAllocator | null }, currentPartPath?: string, filename?: string }} */ ( + params + ); + const allocator = exportParams?.converter?.wordIdAllocator; + const partPath = + exportParams?.currentPartPath || + (typeof exportParams?.filename === 'string' && exportParams.filename.length > 0 + ? `word/${exportParams.filename}` + : 'word/document.xml'); + if (allocator) { + return allocator.allocate({ partPath, sourceId: exportSourceId, logicalId }); + } + return /** @type {string} */ (sourceId || logicalId); +} + /** @type {import('@translator').NodeTranslatorConfig} */ export const config = { xmlName: XML_NODE_NAME, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js index 7d0a589833..a5e614a3dc 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { config, translator } from './del-translator.js'; import { NodeTranslator } from '@translator'; import { exportSchemaToJson } from '@converter/exporter.js'; +import { createImportTrackingContext } from '@extensions/track-changes/review-model/import-context.js'; // Mock external modules vi.mock('@converter/exporter.js', () => ({ @@ -103,6 +104,38 @@ describe('w:del translator', () => { expect(attrs.id).toBe('footnote-uuid'); expect(attrs.sourceId).toBe('123'); }); + + it('flows import tracking context through nested deletion content', () => { + const context = createImportTrackingContext({}); + const childFrames = []; + const mockSubNodes = [{ content: [{ type: 'text', text: 'deleted text' }] }]; + const mockNodeListHandler = { + handler: vi.fn((childParams) => { + childFrames.push(childParams.importTrackingContext.currentParent()); + return mockSubNodes; + }), + }; + + const result = config.encode( + { + nodeListHandler: mockNodeListHandler, + extraParams: { node: mockNode }, + importTrackingContext: context, + path: [], + }, + { + author: 'Test', + authorEmail: 'test@example.com', + id: '123', + date: '2025-10-09T12:00:00Z', + }, + ); + const attrs = getMarkAttrs(result); + + expect(childFrames[0]).toMatchObject({ logicalId: '123', side: 'deletion' }); + expect(context.currentParent()).toBeNull(); + expect(attrs).toEqual(expect.objectContaining({ id: '123', sourceId: '123' })); + }); }); describe('decode', () => { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js index 9ececf73e7..a5cc935776 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js @@ -2,6 +2,11 @@ import { NodeTranslator } from '@translator'; import { createAttributeHandler } from '@converter/v3/handlers/utils.js'; import { exportSchemaToJson } from '@converter/exporter.js'; +import { + resolveTrackedChangeImportIds, + stampImportTrackingAttrs, + withParentFrame, +} from '../../../../v2/importer/importTrackingContext.js'; /** @type {import('@translator').XmlNodeName} */ const XML_NODE_NAME = 'w:ins'; @@ -19,32 +24,41 @@ const validXmlAttributes = [ /** * Encode the w:ins element - * @param {import('@translator').SCEncoderConfig} params + * @param {import('@translator').SCEncoderConfig & { importTrackingContext?: import('@extensions/track-changes/review-model/import-context.js').ImportTrackingContext }} params + * @param {Record} [encodedAttrs] * @returns {import('@translator').SCEncoderResult} */ const encode = (params, encodedAttrs = {}) => { - const { nodeListHandler, extraParams = {}, converter, filename } = params; + const { nodeListHandler, extraParams = {} } = params; const { node } = extraParams; // Preserve the original OOXML w:id for round-trip export fidelity. // The internal id is remapped to a shared UUID for replacement pairing. - const originalWordId = encodedAttrs.id; - const partPath = typeof filename === 'string' && filename.length > 0 ? `word/${filename}` : 'word/document.xml'; - const trackedChangeIdMap = - converter?.trackedChangeIdMapsByPart?.get?.(partPath) ?? converter?.trackedChangeIdMap ?? null; - if (originalWordId && trackedChangeIdMap?.has(originalWordId)) { - encodedAttrs.id = trackedChangeIdMap.get(originalWordId); - } - encodedAttrs.sourceId = originalWordId || ''; + const { partPath, sourceId, logicalId } = resolveTrackedChangeImportIds(params, encodedAttrs.id); + encodedAttrs.id = logicalId; + encodedAttrs.sourceId = sourceId; + const { context, frame } = stampImportTrackingAttrs({ + params, + attrs: encodedAttrs, + side: 'insertion', + sourceId, + partPath, + }); - const subs = nodeListHandler.handler({ + const childParams = { ...params, insideTrackChange: true, + importTrackingContext: context ?? params.importTrackingContext, nodes: node.elements, path: [...(params.path || []), node], - }); + }; + const subs = + context && frame + ? withParentFrame(context, frame, () => nodeListHandler.handler(childParams)) + : nodeListHandler.handler(childParams); encodedAttrs.importedAuthor = `${encodedAttrs.author} (imported)`; + const converter = /** @type {{ documentOrigin?: string } | undefined} */ (params.converter); if (converter?.documentOrigin) { encodedAttrs.origin = converter.documentOrigin; } @@ -72,24 +86,23 @@ function decode(params) { const { node } = params; if (!node || !node.type) { - return null; + return /** @type {import('@translator').SCDecoderResult} */ (/** @type {unknown} */ (null)); } - const trackingMarks = ['trackInsert', 'trackDelete']; const marks = Array.isArray(node.marks) ? node.marks : []; const trackedMark = marks.find((m) => m.type === 'trackInsert'); if (!trackedMark) { - return null; + return /** @type {import('@translator').SCDecoderResult} */ (/** @type {unknown} */ (null)); } - node.marks = marks.filter((m) => !trackingMarks.includes(m.type)); + node.marks = marks.filter((m) => m.type !== 'trackInsert'); const translatedTextNode = exportSchemaToJson({ ...params, node }); return { name: 'w:ins', attributes: { - 'w:id': trackedMark.attrs.sourceId || trackedMark.attrs.id, + 'w:id': resolveExportWordId(params, trackedMark.attrs), 'w:author': trackedMark.attrs.author, 'w:authorEmail': trackedMark.attrs.authorEmail, 'w:date': trackedMark.attrs.date, @@ -98,6 +111,45 @@ function decode(params) { }; } +/** + * Resolve the `w:id` to write on export. Uses the Word revision id allocator + * when one is installed on the converter; otherwise falls through to + * `sourceId || id`. + * + * @param {import('@translator').SCDecoderConfig} params + * @param {Record} attrs + * @returns {string} + */ +function resolveExportWordId(params, attrs) { + const sourceId = attrs?.sourceId; + /** @type {string | number | null | undefined} */ + let exportSourceId; + if (typeof sourceId === 'string' || typeof sourceId === 'number') { + exportSourceId = sourceId; + } else if (sourceId === null) { + exportSourceId = null; + } else if (sourceId === undefined) { + exportSourceId = undefined; + } else { + exportSourceId = String(sourceId); + } + const logicalId = typeof attrs?.id === 'string' ? attrs.id : ''; + const exportParams = + /** @type {import('@translator').SCDecoderConfig & { converter?: { wordIdAllocator?: import('@extensions/track-changes/review-model/word-id-allocator.js').WordIdAllocator | null }, currentPartPath?: string, filename?: string }} */ ( + params + ); + const allocator = exportParams?.converter?.wordIdAllocator; + const partPath = + exportParams?.currentPartPath || + (typeof exportParams?.filename === 'string' && exportParams.filename.length > 0 + ? `word/${exportParams.filename}` + : 'word/document.xml'); + if (allocator) { + return allocator.allocate({ partPath, sourceId: exportSourceId, logicalId }); + } + return /** @type {string} */ (sourceId || logicalId); +} + /** @type {import('@translator').NodeTranslatorConfig} */ export const config = { xmlName: XML_NODE_NAME, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js index be99a7c505..445e99f8cf 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.test.js @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { config, translator } from './ins-translator.js'; import { NodeTranslator } from '@translator'; import { exportSchemaToJson } from '@converter/exporter.js'; +import { createImportTrackingContext } from '@extensions/track-changes/review-model/import-context.js'; vi.mock('@converter/exporter.js', () => ({ exportSchemaToJson: vi.fn(), @@ -111,6 +112,50 @@ describe('w:ins translator', () => { expect(attrs.id).toBe('header-uuid'); expect(attrs.sourceId).toBe('123'); }); + + it('flows import tracking context through nested insertion content', () => { + const context = createImportTrackingContext({}); + context.pushParent({ + logicalId: 'parent-delete', + side: 'deletion', + sourceId: '9', + author: 'Parent', + date: '2026-05-21T00:00:00Z', + }); + const childFrames = []; + const mockSubNodes = [{ content: [{ type: 'text', text: 'added text' }] }]; + const mockNodeListHandler = { + handler: vi.fn((childParams) => { + childFrames.push(childParams.importTrackingContext.currentParent()); + return mockSubNodes; + }), + }; + + const result = config.encode( + { + nodeListHandler: mockNodeListHandler, + extraParams: { node: mockNode }, + importTrackingContext: context, + path: [], + }, + { + author: 'Test', + authorEmail: 'test@example.com', + id: '123', + date: '2025-10-09T12:00:00Z', + }, + ); + + expect(childFrames[0]).toMatchObject({ logicalId: '123', side: 'insertion' }); + expect(context.currentParent()).toMatchObject({ logicalId: 'parent-delete' }); + expect(getMarkAttrs(result)).toEqual( + expect.objectContaining({ + id: '123', + sourceId: '123', + overlapParentId: 'parent-delete', + }), + ); + }); }); describe('decode', () => { @@ -170,6 +215,45 @@ describe('w:ins translator', () => { expect(result.attributes['w:id']).toBe('456'); }); + it('allocates Word ids in the current OOXML part namespace under overlap', () => { + const mockTrackedMark = { + type: 'trackInsert', + attrs: { + id: 'logical-header', + sourceId: '', + author: 'Test', + authorEmail: 'test@example.com', + date: '2025-10-09T12:00:00Z', + }, + }; + const calls = []; + const converter = { + wordIdAllocator: { + allocate: vi.fn((input) => { + calls.push(input); + return input.partPath === 'word/header1.xml' ? '1' : '99'; + }), + }, + }; + + exportSchemaToJson.mockReturnValue({ elements: [{ name: 'w:t' }] }); + + const result = config.decode({ + node: { type: 'text', marks: [mockTrackedMark] }, + converter, + currentPartPath: 'word/header1.xml', + }); + + expect(result.attributes['w:id']).toBe('1'); + expect(calls).toEqual([ + { + partPath: 'word/header1.xml', + sourceId: '', + logicalId: 'logical-header', + }, + ]); + }); + it('returns null if node is missing or invalid', () => { expect(config.decode({ node: null })).toBeNull(); expect(config.decode({ node: {} })).toBeNull(); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js index ba6a766082..ce5faec085 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js @@ -3,11 +3,45 @@ import { generateParagraphProperties } from './generate-paragraph-properties.js' const isTrackedChangeWrapper = (el) => el?.name === 'w:ins' || el?.name === 'w:del'; +const getCommentReferenceNode = (el) => { + if (el?.name !== 'w:r' || !Array.isArray(el.elements)) return null; + return el.elements.find((child) => child?.name === 'w:commentReference') ?? null; +}; + const isCommentMarker = (el) => { if (!el) return false; if (el.name === 'w:commentRangeStart' || el.name === 'w:commentRangeEnd') return true; - if (el.name === 'w:r' && el.elements?.length === 1 && el.elements[0]?.name === 'w:commentReference') return true; - return false; + return getCommentReferenceNode(el) != null; +}; + +const getCommentMarkerId = (el) => { + if (!el) return null; + if ((el.name === 'w:commentRangeStart' || el.name === 'w:commentRangeEnd') && el.attributes?.['w:id'] != null) { + return String(el.attributes['w:id']); + } + const referenceNode = getCommentReferenceNode(el); + if (referenceNode?.attributes?.['w:id'] != null) { + return String(referenceNode.attributes['w:id']); + } + return null; +}; + +const collectLeadingCommentStartIds = (elements = []) => { + const startedIds = new Set(); + for (const element of elements) { + if (element?.name !== 'w:commentRangeStart') break; + const id = getCommentMarkerId(element); + if (id) startedIds.add(id); + } + return startedIds; +}; + +const shouldAbsorbTrailingMarkersIntoWrapper = (pendingComments = [], startedInsideWrapper = new Set()) => { + if (!pendingComments.length || !startedInsideWrapper.size) return false; + return pendingComments.every((marker) => { + const markerId = getCommentMarkerId(marker); + return markerId != null && startedInsideWrapper.has(markerId); + }); }; // AIDEV-NOTE: SD-2528. The importer associates a comment with a tracked change @@ -45,8 +79,8 @@ function foldLeadingCommentStartsIntoTrackedChanges(elements) { * Merge consecutive tracked change elements (w:ins/w:del) with the same ID, * and fold any commentRangeStart that immediately precedes a tracked-change * wrapper INTO the wrapper as its first child(ren). Trailing commentRangeEnd - * and w:r→w:commentReference stay as siblings and are only absorbed when a - * same-id successor wrapper triggers an SD-1519 merge. + * / commentReference markers are absorbed too when they close comments that + * started inside that wrapper, preserving Word's replacement adjacency. * * @param {Array} elements The translated paragraph elements * @returns {Array} Elements with consecutive tracked changes merged @@ -91,11 +125,22 @@ function mergeConsecutiveTrackedChanges(elements) { break; } + const startedInsideWrapper = collectLeadingCommentStartIds(mergedElements); + const absorbTrailingMarkers = shouldAbsorbTrailingMarkersIntoWrapper(pendingComments, startedInsideWrapper); + if (absorbTrailingMarkers) { + mergedElements.push(...pendingComments); + pendingComments.length = 0; + } + if (didMerge) { result.push({ name: tcName, attributes: { ...current.attributes }, elements: mergedElements }); result.push(...pendingComments); } else { - result.push(current); + result.push( + absorbTrailingMarkers + ? { name: tcName, attributes: { ...current.attributes }, elements: mergedElements } + : current, + ); result.push(...pendingComments); } i = j; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.test.js index 7121fb383c..e62a351aae 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.test.js @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { translateChildNodes } from '@converter/v2/exporter/helpers/index.js'; +import { buildTrackedChangeIdMap } from '@converter/v2/importer/trackedChangeIdMapper.js'; import { generateParagraphProperties } from './generate-paragraph-properties.js'; vi.mock('@converter/v2/exporter/helpers/index.js', () => ({ @@ -98,8 +99,13 @@ describe('translateParagraphNode', () => { attributes: { 'w:id': id, 'w:author': 'a', 'w:date': 'd' }, elements: [{ name: 'w:r', elements: [{ name: 'w:delText', elements: [{ type: 'text', text: innerText }] }] }], }); + const buildInsWrapper = (id, innerText) => ({ + name: 'w:ins', + attributes: { 'w:id': id, 'w:author': 'a', 'w:date': 'd' }, + elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: innerText }] }] }], + }); - it('folds a leading commentRangeStart into the following tracked-change wrapper (SD-2528)', () => { + it('folds a leading commentRangeStart into the following tracked-change wrapper and keeps the closing markers there', () => { const params = baseParams(); generateParagraphProperties.mockReturnValue(null); translateChildNodes.mockReturnValue([ @@ -114,10 +120,10 @@ describe('translateParagraphNode', () => { const result = translateParagraphNode(params); const names = result.elements.map((e) => e.name); - expect(names).toEqual(['w:r', 'w:del', 'w:commentRangeEnd', 'w:r', 'w:r']); + expect(names).toEqual(['w:r', 'w:del', 'w:r']); const delNode = result.elements.find((e) => e.name === 'w:del'); - expect(delNode.elements.map((e) => e.name)).toEqual(['w:commentRangeStart', 'w:r']); + expect(delNode.elements.map((e) => e.name)).toEqual(['w:commentRangeStart', 'w:r', 'w:commentRangeEnd', 'w:r']); expect(delNode.elements[0].attributes['w:id']).toBe('0'); }); @@ -183,5 +189,38 @@ describe('translateParagraphNode', () => { expect(result.elements[0].elements).toHaveLength(1); expect(result.elements[2].elements).toHaveLength(1); }); + + it('absorbs trailing deleted-side comment markers so replacement siblings remain pairable', () => { + const params = baseParams(); + generateParagraphProperties.mockReturnValue(null); + translateChildNodes.mockReturnValue([ + commentRangeStart, + buildDelWrapper('1', 'old'), + commentRangeEnd, + commentReferenceRun, + buildInsWrapper('2', 'new'), + ]); + + const result = translateParagraphNode(params); + const names = result.elements.map((e) => e.name); + + expect(names).toEqual(['w:del', 'w:ins']); + + const delNode = result.elements[0]; + expect(delNode.elements.map((e) => e.name)).toEqual(['w:commentRangeStart', 'w:r', 'w:commentRangeEnd', 'w:r']); + + const idMap = buildTrackedChangeIdMap({ + 'word/document.xml': { + elements: [ + { + name: 'w:document', + elements: [{ name: 'w:body', elements: [result] }], + }, + ], + }, + }); + + expect(idMap.get('1')).toBe(idMap.get('2')); + }); }); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/r-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/r-translator.js index 4e1638016a..9f9e82a6a3 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/r-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/r-translator.js @@ -422,7 +422,10 @@ const decode = (params, decodedAttrs = {}) => { if (!existingRunPropertyChanges.length && runTrackFormatMark) { const runPropertiesNode = getRunPropertiesNode(runNode); - appendTrackFormatChangeToRunProperties(runPropertiesNode, [runTrackFormatMark]); + appendTrackFormatChangeToRunProperties(runPropertiesNode, [runTrackFormatMark], { + wordIdAllocator: params?.converter?.wordIdAllocator || null, + partPath: params?.currentPartPath || 'word/document.xml', + }); } }; diff --git a/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts index 0e73cb976f..4487329bfd 100644 --- a/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts +++ b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts @@ -122,6 +122,8 @@ export interface FontConfig { * User information for collaboration */ export interface User { + /** Stable actor id for authorship and ownership checks. */ + id?: string | null; /** The user's name */ name?: string; @@ -377,6 +379,15 @@ export interface EditorOptions { /** Comment highlight configuration */ comments?: CommentConfig; + /** + * Public SuperDoc module configuration accepted by direct/headless + * `Editor.open()` callers. SuperDoc app config normalizes this before it + * reaches the editor; CLI/SDK headless callers pass it directly. + */ + modules?: { + trackChanges?: EditorOptions['trackedChanges'] | null; + }; + /** * Track-changes runtime configuration forwarded from the SuperDoc-level * `modules.trackChanges` config. Read by the TrackChanges extension and diff --git a/packages/super-editor/src/editors/v1/core/types/EditorEvents.ts b/packages/super-editor/src/editors/v1/core/types/EditorEvents.ts index 147fab0fab..41161bf6ec 100644 --- a/packages/super-editor/src/editors/v1/core/types/EditorEvents.ts +++ b/packages/super-editor/src/editors/v1/core/types/EditorEvents.ts @@ -46,10 +46,14 @@ export interface Comment { commentId: string; /** Timestamp when the comment was created (ms since epoch) */ createdTime: number | null; + /** Stable actor id of the comment author */ + creatorId?: string | null; /** Display name of the comment author */ creatorName: string | null; /** Email address of the comment author */ creatorEmail: string | null; + /** Stable actor id of the resolver */ + resolvedById?: string | null; /** Avatar URL of the comment author */ creatorImage?: string | null; /** Structured body content of the comment */ diff --git a/packages/super-editor/src/editors/v1/core/types/EditorPublicSurfaces.ts b/packages/super-editor/src/editors/v1/core/types/EditorPublicSurfaces.ts index 75b326981f..5e0883b0dc 100644 --- a/packages/super-editor/src/editors/v1/core/types/EditorPublicSurfaces.ts +++ b/packages/super-editor/src/editors/v1/core/types/EditorPublicSurfaces.ts @@ -16,7 +16,7 @@ * cast at the call site (no `any`). */ import type { Plugin } from 'prosemirror-state'; -import type { Schema } from 'prosemirror-model'; +import type { Node as PmNode, Schema } from 'prosemirror-model'; import type { NodeViewConstructor } from 'prosemirror-view'; import type { EditorHelpers } from './EditorTypes.js'; import type { Comment } from './EditorEvents.js'; @@ -47,7 +47,7 @@ export type HeaderFooterIdMap = Record void } & Record; + editor?: { destroy?: () => void; state?: { doc?: PmNode } } & Record; [key: string]: unknown; } @@ -67,6 +67,8 @@ export interface EditorConverterSurface { footerEditors: HeaderFooterEditorEntry[]; footerIds: HeaderFooterIdMap; footers: Record; + footnotes: unknown; + endnotes: unknown; footnoteProperties: unknown; headerEditors: HeaderFooterEditorEntry[]; headerFooterModified: boolean; diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/assemble-adapters.ts b/packages/super-editor/src/editors/v1/document-api-adapters/assemble-adapters.ts index cd495d0287..26a7ca4090 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/assemble-adapters.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/assemble-adapters.ts @@ -49,6 +49,7 @@ import { trackChangesRejectWrapper, trackChangesAcceptAllWrapper, trackChangesRejectAllWrapper, + trackChangesDecideRangeWrapper, } from './plan-engine/track-changes-wrappers.js'; import { createParagraphWrapper, createHeadingWrapper } from './plan-engine/create-wrappers.js'; import { blocksListWrapper, blocksDeleteWrapper, blocksDeleteRangeWrapper } from './plan-engine/blocks-wrappers.js'; @@ -443,6 +444,7 @@ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters reject: (input, options) => trackChangesRejectWrapper(editor, input, options), acceptAll: (input, options) => trackChangesAcceptAllWrapper(editor, input, options), rejectAll: (input, options) => trackChangesRejectAllWrapper(editor, input, options), + decideRange: (input, options) => trackChangesDecideRangeWrapper(editor, input, options), }, blocks: { list: (input) => blocksListWrapper(editor, input), diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.consumer-simulation.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.consumer-simulation.test.ts index c42e6aa7a1..6e42edc6b3 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.consumer-simulation.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.consumer-simulation.test.ts @@ -13,9 +13,8 @@ * replacement. SuperDoc's importer (trackedChangeIdMapper.js) maps * adjacent w:del + w:ins with the same author/date to one internal * raw mark id, so both halves share a single entityId at the public - * API. Spans carry the per-half type. The aggregate `type` field on - * `trackedChanges[]` is best-effort (insert wins over delete); span - * type is the source of truth. + * API. Spans carry the per-half type, while the entity-level `type` + * on `trackedChanges[]` is `replacement`. * 2. "[del:Delete me]" — a paragraph that is entirely a deletion. */ @@ -63,7 +62,7 @@ type ChunkForEmbedding = | { kind: 'tracked-change'; entityId: string; - type: 'insert' | 'delete' | 'format'; + type: 'insert' | 'delete' | 'replacement' | 'format'; blockIds: string[]; content: string; }; @@ -148,12 +147,14 @@ describe('extract-adapter consumer simulation (SD-2766)', () => { expect(rendered).toContain('Here is a MS Word'); expect(rendered).toContain('sentence'); - // Pairing observation: in this fixture, w:del and w:ins authored at the - // same time map to one entityId on both halves. Confirm that here so - // any future regression in the importer's pairing surfaces immediately. + // Word-authored replacement halves keep distinct span-side markers, but + // in paired mode they resolve to one public tracked-change entity. const delEntity = deleteSpans[0].trackedChanges!.find((c) => c.type === 'delete')!.entityId; const insEntity = insertSpans[0].trackedChanges!.find((c) => c.type === 'insert')!.entityId; + const entitiesById = new Map(result.trackedChanges.map((tc) => [tc.entityId, tc])); expect(insEntity).toBe(delEntity); + expect(entitiesById.get(delEntity)?.wordRevisionIds?.delete).toBeTruthy(); + expect(entitiesById.get(delEntity)?.wordRevisionIds?.insert).toBeTruthy(); }); it('attaches every tracked change to the blocks it lives in via blockIds', async () => { @@ -208,21 +209,19 @@ describe('extract-adapter consumer simulation (SD-2766)', () => { // the consumer could render a marker but couldn't look up author/date or // pass the id to scrollToElement(). // - // We don't assert span.type === aggregate.type. For paired changes (one - // entity covering both a delete half and an insert half) the aggregate - // type field collapses to "insert" by pre-existing convention. Span type - // is the per-half source of truth; the aggregate is for navigation. + // We don't assert span.type === entity.type. For paired changes, the + // entity-level type is "replacement" while the spans still carry the + // per-half delete/insert truth needed for rendering. const indexByEntity = new Map(result.trackedChanges.map((tc) => [tc.entityId, tc])); for (const entityId of entityIdsInSpans) { expect(indexByEntity.get(entityId), `entityId ${entityId} should appear in trackedChanges[]`).toBeDefined(); } }); - it('lets a consumer derive per-segment view of a paired change from spans', async () => { - // The aggregate trackedChanges[] entry for a paired change has a single - // `type` and (now) no `excerpt` — the spans carry the per-half text. + it('lets a consumer derive per-segment view of a Word replacement from spans', async () => { + // Word-authored replacement halves expose separate tracked-change entities. // A reviewer UI ("show me what John changed") rebuilds the segment view - // from spans + blockIds. + // from spans + blockIds rather than assuming one aggregate replacement id. const ctx = (await initTestEditor({ content: docxFixture.docx, media: docxFixture.media, @@ -243,33 +242,35 @@ describe('extract-adapter consumer simulation (SD-2766)', () => { } } - // Find the paired replacement by its OOXML provenance: both insert and - // delete word-revision-ids are populated on a paired entity. - const pairedEntity = result.trackedChanges.find( - (tc) => !!tc.wordRevisionIds?.insert && !!tc.wordRevisionIds?.delete, - )!; - expect(pairedEntity).toBeDefined(); - - // The misleading concatenated excerpt is suppressed for paired entries. - expect(pairedEntity.excerpt).toBeUndefined(); - - // OOXML provenance is preserved so a spec-aware consumer can map back - // to the source document. - expect(pairedEntity.wordRevisionIds!.insert).toBeTruthy(); - expect(pairedEntity.wordRevisionIds!.delete).toBeTruthy(); - expect(pairedEntity.wordRevisionIds!.insert).not.toBe(pairedEntity.wordRevisionIds!.delete); - - const segments = entityToSegments.get(pairedEntity.entityId)!; - expect(segments).toBeDefined(); - - // Per-segment view: one delete of "basic " and one insert of "cool " on - // the same block. Independently addressable, independently renderable. - const deletes = segments.filter((s) => s.type === 'delete'); - const inserts = segments.filter((s) => s.type === 'insert'); - expect(deletes.map((s) => s.text)).toEqual(['basic ']); - expect(inserts.map((s) => s.text).join('')).toBe('cool '); - for (const seg of segments) { - expect(seg.blockId).toBe(pairedEntity.blockIds![0]); + const deleteEntry = Array.from(entityToSegments.entries()).find(([, segments]) => + segments.some((s) => s.type === 'delete' && s.text === 'basic '), + ); + const insertEntry = Array.from(entityToSegments.entries()).find( + ([, segments]) => + segments + .filter((s) => s.type === 'insert') + .map((s) => s.text) + .join('') === 'cool ', + ); + expect(deleteEntry).toBeDefined(); + expect(insertEntry).toBeDefined(); + const [deleteEntityId, deleteSegments] = deleteEntry!; + const [insertEntityId, insertSegments] = insertEntry!; + expect(deleteEntityId).toBe(insertEntityId); + + const deleteEntity = result.trackedChanges.find((tc) => tc.entityId === deleteEntityId)!; + expect(deleteEntity.wordRevisionIds?.delete).toBeTruthy(); + expect(deleteEntity.wordRevisionIds?.insert).toBeTruthy(); + + expect(deleteSegments.filter((s) => s.type === 'delete').map((s) => s.text)).toEqual(['basic ']); + expect( + insertSegments + .filter((s) => s.type === 'insert') + .map((s) => s.text) + .join(''), + ).toBe('cool '); + for (const seg of [...deleteSegments, ...insertSegments]) { + expect(seg.blockId).toBe(deleteEntity.blockIds![0]); } }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.test.ts index 79bcec9537..8bce5692cb 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.test.ts @@ -66,11 +66,12 @@ function trackDeleteMark( id: string, author = 'Author', date = '2026-01-01T00:00:00Z', + attrs: Record = {}, ): { type: string; attrs: Record; } { - return { type: 'trackDelete', attrs: { id, author, date } }; + return { type: 'trackDelete', attrs: { id, author, date, ...attrs } }; } function trackFormatMark( @@ -479,6 +480,52 @@ describe('extract-adapter tracked-change spans', () => { } }); + it('preserves overlapping insert + delete marks on a single span', async () => { + const parentInsertId = 'raw-overlap-ins'; + const childDeleteId = 'raw-overlap-del'; + const doc: SchemaDoc = { + type: 'doc', + content: [ + paragraphRuns([ + 'plain ', + { + text: 'review', + marks: [ + trackInsertMark(parentInsertId, 'Insert Author'), + trackDeleteMark(childDeleteId, 'Delete Author', '2026-01-01T00:00:00Z', { + overlapParentId: parentInsertId, + }), + ], + }, + ' tail', + ]), + ], + }; + + const ctx = await makeEditor(doc); + editor = ctx.editor; + + const result = extractAdapter(editor, {}); + const block = result.blocks[0]; + + expect(block.text).toBe('plain review tail'); + expect(block.textSpans).toBeDefined(); + expect(block.textSpans!.map((s) => s.text).join('')).toBe(block.text); + + const overlapSpan = block.textSpans!.find((s) => s.text === 'review'); + expect(overlapSpan).toBeDefined(); + expect(overlapSpan!.trackedChanges).toBeDefined(); + expect(overlapSpan!.trackedChanges).toHaveLength(2); + expect(overlapSpan!.trackedChanges!.map((tc) => tc.type).sort()).toEqual(['delete', 'insert']); + + const entityIds = new Set(overlapSpan!.trackedChanges!.map((tc) => tc.entityId)); + expect(entityIds.size).toBe(2); + expect(result.trackedChanges.map((tc) => tc.type).sort()).toEqual(['delete', 'insert']); + for (const tc of result.trackedChanges) { + expect(tc.blockIds).toEqual([block.nodeId]); + } + }); + it('attaches spans inside table cells without breaking tableContext', async () => { const doc: SchemaDoc = { type: 'doc', @@ -513,7 +560,7 @@ describe('extract-adapter tracked-change spans', () => { expect(result.trackedChanges[0].blockIds).toEqual([tagged.nodeId]); }); - it('suppresses the aggregate excerpt for in-app paired replacements with no OOXML sourceId', async () => { + it('suppresses the paired replacement excerpt for in-app tracked replacements with no OOXML sourceId', async () => { // Reproduces the codex-bot finding on PR #2973: paired replacements // created via in-app tracked editing have no `sourceId` on the marks, // so `wordRevisionIds` is empty. Paired detection must come from the @@ -538,6 +585,7 @@ describe('extract-adapter tracked-change spans', () => { expect(result.trackedChanges).toHaveLength(1); const entry = result.trackedChanges[0]; + expect(entry.type).toBe('replacement'); expect(entry.wordRevisionIds).toBeUndefined(); // The whole point: even without OOXML provenance, the concatenated // excerpt is suppressed because the spans showed both insert and delete. diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.ts b/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.ts index 92b63804dd..f33a9740c0 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/extract-adapter.ts @@ -36,7 +36,7 @@ import { getHeadingLevel, mapBlockNodeType, resolveBlockNodeId } from './helpers import { getRevision } from './plan-engine/revision-tracker.js'; import { createCommentsWrapper } from './plan-engine/comments-wrappers.js'; import { trackChangesListWrapper } from './plan-engine/track-changes-wrappers.js'; -import { buildTrackedChangeCanonicalIdMap } from './helpers/tracked-change-resolver.js'; +import { buildTrackedChangeCanonicalIdMap, getTrackedChangeMarkAlias } from './helpers/tracked-change-resolver.js'; import { TrackDeleteMarkName, TrackFormatMarkName, @@ -175,9 +175,9 @@ function readSpanTrackedChanges( for (const mark of node.marks) { const type = TRACK_MARK_TYPE_BY_NAME[mark.type.name]; if (!type) continue; - const rawId = (mark.attrs as Record | undefined)?.id; - if (typeof rawId !== 'string' || rawId.length === 0) continue; - const entityId = canonicalIdByAlias.get(rawId); + const alias = getTrackedChangeMarkAlias(mark); + if (!alias) continue; + const entityId = canonicalIdByAlias.get(alias); if (!entityId) continue; out.push({ entityId, type }); } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.test.ts index 144c24ec97..f96dc5baca 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.test.ts @@ -8,6 +8,8 @@ import { getCommentEntityStore, isCommentResolved, removeCommentEntityTree, + restoreStashedCommentEntityTree, + stashRemovedCommentEntities, syncCommentEntitiesFromCollaboration, toCommentInfo, upsertCommentEntity, @@ -125,6 +127,32 @@ describe('removeCommentEntityTree', () => { }); }); +describe('stashRemovedCommentEntities / restoreStashedCommentEntityTree', () => { + it('restores a removed root comment and its reply payloads', () => { + const editor = makeEditorWithConverter([ + { commentId: 'c1', commentText: 'Root' }, + { commentId: 'c2', parentCommentId: 'c1', commentText: 'Reply' }, + ]); + const store = getCommentEntityStore(editor); + + const removed = removeCommentEntityTree(store, 'c1'); + expect(store).toEqual([]); + + stashRemovedCommentEntities(editor, removed); + const restored = restoreStashedCommentEntityTree(editor, 'c1'); + + expect(restored.map((entry) => entry.commentId).sort()).toEqual(['c1', 'c2']); + expect(store).toHaveLength(2); + expect(findCommentEntity(store, 'c1')?.commentText).toBe('Root'); + expect(findCommentEntity(store, 'c2')?.commentText).toBe('Reply'); + }); + + it('returns empty when there is no stashed tree for the comment id', () => { + const editor = makeEditorWithConverter(); + expect(restoreStashedCommentEntityTree(editor, 'missing')).toEqual([]); + }); +}); + describe('extractCommentText', () => { it('returns commentText when available', () => { expect(extractCommentText({ commentText: 'Hello' })).toBe('Hello'); @@ -169,17 +197,21 @@ describe('buildCommentJsonFromText', () => { ]); }); - it('strips HTML tags from input', () => { + it('preserves literal markup-looking text as plain text', () => { const result = buildCommentJsonFromText('Bold text'); expect(result[0]).toMatchObject({ - content: [{ content: [{ text: 'Bold text' }] }], + content: [{ content: [{ text: 'Bold text' }] }], }); }); - it('replaces   with spaces', () => { - const result = buildCommentJsonFromText('Hello world'); + it('preserves paragraph boundaries from newline-delimited plain text', () => { + const result = buildCommentJsonFromText('Hello\nworld'); + expect(result).toHaveLength(2); expect(result[0]).toMatchObject({ - content: [{ content: [{ text: 'Hello world' }] }], + content: [{ content: [{ text: 'Hello' }] }], + }); + expect(result[1]).toMatchObject({ + content: [{ content: [{ text: 'world' }] }], }); }); }); @@ -287,7 +319,7 @@ describe('syncCommentEntitiesFromCollaboration (SD-3214)', () => { expect(store[0].commentText).toBe('short form'); }); - it('skips entries flagged trackedChange:true (those belong to a separate domain)', () => { + it('skips synthetic tracked-change projection entries without comment payload', () => { const editor = makeEditorWithConverter(); syncCommentEntitiesFromCollaboration(editor, [ { commentId: 'tc-1', trackedChange: true, trackedChangeText: 'inserted', creatorName: 'A' }, @@ -298,6 +330,31 @@ describe('syncCommentEntitiesFromCollaboration (SD-3214)', () => { expect(store[0].commentId).toBe('c-1'); }); + it('syncs linked tracked-content comments when they include comment payload', () => { + const editor = makeEditorWithConverter(); + syncCommentEntitiesFromCollaboration(editor, [ + { + commentId: 'tc-user-1', + trackedChange: true, + trackedChangeParentId: 'tc-root-1', + trackedChangeText: 'inserted text', + commentText: 'user-authored tracked comment', + creatorName: 'A', + }, + ]); + + const store = getCommentEntityStore(editor); + expect(store).toHaveLength(1); + expect(store[0]).toMatchObject({ + commentId: 'tc-user-1', + trackedChange: true, + trackedChangeParentId: 'tc-root-1', + trackedChangeText: 'inserted text', + commentText: 'user-authored tracked comment', + creatorName: 'A', + }); + }); + it('skips entries without a commentId', () => { const editor = makeEditorWithConverter(); syncCommentEntitiesFromCollaboration(editor, [{ creatorName: 'orphan' }, { commentId: 'c-ok', creatorName: 'X' }]); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.ts index cd6d1fdfdf..f7ef1425e3 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.ts @@ -1,7 +1,17 @@ import type { Editor } from '../../core/Editor.js'; -import type { CommentInfo, CommentStatus, TextTarget } from '@superdoc/document-api'; +import type { + CommentInfo, + CommentStatus, + CommentTrackedChangeLink, + StoryLocator, + TextTarget, + TrackChangeType, +} from '@superdoc/document-api'; +export { buildCommentJsonFromText } from '../../utils/comment-content.js'; +import { buildCommentJsonFromText } from '../../utils/comment-content.js'; const FALLBACK_STORE_KEY = '__documentApiComments'; +const DELETED_COMMENT_SNAPSHOT_KEY = '__documentApiDeletedCommentSnapshots'; export interface CommentEntityRecord { commentId?: string; @@ -19,6 +29,16 @@ export interface CommentEntityRecord { creatorEmail?: string; creatorImage?: string; createdTime?: number; + trackedChange?: boolean; + trackedChangeParentId?: string | null; + trackedChangeType?: TrackChangeType | null; + trackedChangeDisplayType?: string | null; + trackedChangeStory?: StoryLocator | null; + trackedChangeStoryKind?: string | null; + trackedChangeStoryLabel?: string | null; + trackedChangeAnchorKey?: string | null; + trackedChangeText?: string | null; + deletedText?: string | null; [key: string]: unknown; } @@ -32,10 +52,7 @@ type EditorWithCommentStorage = Editor & { }; function ensureFallbackStore(editor: EditorWithCommentStorage): CommentEntityRecord[] { - if (!editor.storage) { - (editor as unknown as Record).storage = {}; - } - const storage = editor.storage as Record; + const storage = ensureEditorStorage(editor); if (!Array.isArray(storage[FALLBACK_STORE_KEY])) { storage[FALLBACK_STORE_KEY] = []; @@ -44,6 +61,24 @@ function ensureFallbackStore(editor: EditorWithCommentStorage): CommentEntityRec return storage[FALLBACK_STORE_KEY] as CommentEntityRecord[]; } +function ensureEditorStorage(editor: EditorWithCommentStorage): Record { + if (!editor.storage) { + (editor as unknown as Record).storage = {}; + } + return editor.storage as Record; +} + +function getDeletedCommentSnapshotStore(editor: Editor): Map { + const storage = ensureEditorStorage(editor as EditorWithCommentStorage); + const existing = storage[DELETED_COMMENT_SNAPSHOT_KEY]; + if (existing instanceof Map) { + return existing as Map; + } + const created = new Map(); + storage[DELETED_COMMENT_SNAPSHOT_KEY] = created; + return created; +} + export function getCommentEntityStore(editor: Editor): CommentEntityRecord[] { const mutableEditor = editor as EditorWithCommentStorage; const converter = mutableEditor.converter as ConverterWithComments | undefined; @@ -109,19 +144,57 @@ export function removeCommentEntityTree(store: CommentEntityRecord[], commentId: return removed; } -/** - * Strips HTML tags from a comment text string using simple regex replacement. - * - * This is only intended for normalizing comment content that was already authored - * within the editor. It is NOT a security sanitizer and must not be used to - * neutralize untrusted or user-supplied HTML. - */ -function stripHtmlToText(value: string): string { - return value - .replace(/<[^>]+>/g, ' ') - .replace(/ /gi, ' ') - .replace(/\s+/g, ' ') - .trim(); +function commentEntityIdentity(entry: CommentEntityRecord | undefined): string | undefined { + return toNonEmptyString(entry?.commentId) ?? toNonEmptyString(entry?.importedId); +} + +export function stashRemovedCommentEntities(editor: Editor, removed: ReadonlyArray): void { + if (removed.length === 0) return; + const snapshots = getDeletedCommentSnapshotStore(editor); + for (const entry of removed) { + const identity = commentEntityIdentity(entry); + if (!identity) continue; + snapshots.set(identity, { ...entry }); + } +} + +export function restoreStashedCommentEntityTree(editor: Editor, rootCommentId: string): CommentEntityRecord[] { + const snapshots = getDeletedCommentSnapshotStore(editor); + const store = getCommentEntityStore(editor); + const root = Array.from(snapshots.values()).find( + (entry) => + toNonEmptyString(entry.commentId) === rootCommentId || toNonEmptyString(entry.importedId) === rootCommentId, + ); + if (!root) return []; + + const restoreIds = new Set(); + const rootIdentity = commentEntityIdentity(root); + if (!rootIdentity) return []; + restoreIds.add(rootIdentity); + + let changed = true; + while (changed) { + changed = false; + for (const entry of snapshots.values()) { + const identity = commentEntityIdentity(entry); + if (!identity || restoreIds.has(identity)) continue; + const parentId = toNonEmptyString(entry.parentCommentId); + if (!parentId || !restoreIds.has(parentId)) continue; + restoreIds.add(identity); + changed = true; + } + } + + const restored: CommentEntityRecord[] = []; + for (const identity of restoreIds) { + const snapshot = snapshots.get(identity); + if (!snapshot) continue; + upsertCommentEntity(store, identity, { ...snapshot }); + snapshots.delete(identity); + restored.push(snapshot); + } + + return restored; } function collectTextFragments(value: unknown, sink: string[]): void { @@ -146,6 +219,29 @@ function collectTextFragments(value: unknown, sink: string[]): void { if (record.nodes) collectTextFragments(record.nodes, sink); } +function hasOwnProperty(record: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(record, key); +} + +function hasCommentBodyPayload(raw: Record): boolean { + return ( + hasOwnProperty(raw, 'commentText') || + hasOwnProperty(raw, 'text') || + hasOwnProperty(raw, 'commentJSON') || + hasOwnProperty(raw, 'elements') + ); +} + +function isSyntheticTrackedChangeProjection(raw: Record): boolean { + if (raw.trackedChange !== true) return false; + + // Tracked-change projection rows share the comments Y.Array, but they are + // not user-authored comment entities. Linked user comments on tracked + // content also carry `trackedChange: true`, so only skip rows that lack any + // actual comment body payload or thread metadata. + return !hasCommentBodyPayload(raw) && !hasOwnProperty(raw, 'parentCommentId'); +} + export function extractCommentText(entry: CommentEntityRecord): string | undefined { if (typeof entry.commentText === 'string') return entry.commentText; @@ -157,27 +253,6 @@ export function extractCommentText(entry: CommentEntityRecord): string | undefin return fragments.join('').trim(); } -export function buildCommentJsonFromText(text: string): unknown[] { - const normalized = stripHtmlToText(text); - - return [ - { - type: 'paragraph', - content: [ - { - type: 'run', - content: [ - { - type: 'text', - text: normalized, - }, - ], - }, - ], - }, - ]; -} - export function isCommentResolved(entry: CommentEntityRecord): boolean { return Boolean(entry.isDone || entry.resolvedTime); } @@ -196,8 +271,9 @@ export function isCommentResolved(entry: CommentEntityRecord): boolean { * - Each entry with a `commentId` is upserted into the store. Existing * entries are merged (collaborator-authored fields override locally * captured ones; missing fields are left alone). - * - Entries flagged `trackedChange: true` are skipped — those belong to - * the tracked-changes domain, not the comments store. + * - Synthetic tracked-change projection rows are skipped — those belong to + * the tracked-changes domain, not the comments store. Linked user + * comments on tracked content are still synced. * - When `options.previouslySynced` is provided, any id present in the * prior set but absent from the current entries is treated as a remote * deletion and pruned via `removeCommentEntityTree`. Locally-authored @@ -228,7 +304,7 @@ export function syncCommentEntitiesFromCollaboration( const validEntries: Array> = []; for (const raw of entries) { if (!raw || typeof raw !== 'object') continue; - if (raw.trackedChange === true) continue; + if (isSyntheticTrackedChangeProjection(raw)) continue; const cid = toNonEmptyString(raw.commentId); const iid = toNonEmptyString(raw.importedId); if (cid) upstreamIds.add(cid); @@ -270,6 +346,8 @@ export function syncCommentEntitiesFromCollaboration( // Identity fields if (typeof raw.importedId === 'string') patch.importedId = raw.importedId; if (typeof raw.parentCommentId === 'string') patch.parentCommentId = raw.parentCommentId; + if (raw.trackedChangeParentId === null) patch.trackedChangeParentId = null; + if (typeof raw.trackedChangeParentId === 'string') patch.trackedChangeParentId = raw.trackedChangeParentId; // Body const commentText = typeof raw.commentText === 'string' ? raw.commentText : typeof raw.text === 'string' ? raw.text : undefined; @@ -281,6 +359,26 @@ export function syncCommentEntitiesFromCollaboration( if (typeof raw.creatorEmail === 'string') patch.creatorEmail = raw.creatorEmail; if (typeof raw.creatorImage === 'string') patch.creatorImage = raw.creatorImage; if (typeof raw.createdTime === 'number') patch.createdTime = raw.createdTime; + // Tracked-change linkage + if (typeof raw.trackedChange === 'boolean') patch.trackedChange = raw.trackedChange; + if (typeof raw.trackedChangeType === 'string') patch.trackedChangeType = raw.trackedChangeType as TrackChangeType; + if (raw.trackedChangeType === null) patch.trackedChangeType = null; + if (typeof raw.trackedChangeDisplayType === 'string') patch.trackedChangeDisplayType = raw.trackedChangeDisplayType; + if (raw.trackedChangeDisplayType === null) patch.trackedChangeDisplayType = null; + if (raw.trackedChangeStory === null) patch.trackedChangeStory = null; + if (raw.trackedChangeStory && typeof raw.trackedChangeStory === 'object') { + patch.trackedChangeStory = raw.trackedChangeStory as StoryLocator; + } + if (typeof raw.trackedChangeStoryKind === 'string') patch.trackedChangeStoryKind = raw.trackedChangeStoryKind; + if (raw.trackedChangeStoryKind === null) patch.trackedChangeStoryKind = null; + if (typeof raw.trackedChangeStoryLabel === 'string') patch.trackedChangeStoryLabel = raw.trackedChangeStoryLabel; + if (raw.trackedChangeStoryLabel === null) patch.trackedChangeStoryLabel = null; + if (typeof raw.trackedChangeAnchorKey === 'string') patch.trackedChangeAnchorKey = raw.trackedChangeAnchorKey; + if (raw.trackedChangeAnchorKey === null) patch.trackedChangeAnchorKey = null; + if (typeof raw.trackedChangeText === 'string') patch.trackedChangeText = raw.trackedChangeText; + if (raw.trackedChangeText === null) patch.trackedChangeText = null; + if (typeof raw.deletedText === 'string') patch.deletedText = raw.deletedText; + if (raw.deletedText === null) patch.deletedText = null; // Status if (typeof raw.isInternal === 'boolean') patch.isInternal = raw.isInternal; if (typeof raw.isDone === 'boolean') patch.isDone = raw.isDone; @@ -319,10 +417,29 @@ export function toCommentInfo( target?: TextTarget; status?: CommentStatus; anchoredText?: string; + trackedChangeLink?: CommentTrackedChangeLink | null; } = {}, ): CommentInfo { const resolvedId = typeof entry.commentId === 'string' ? entry.commentId : String(entry.importedId ?? ''); const status = options.status ?? (isCommentResolved(entry) ? 'resolved' : 'open'); + const trackedChangeLink = + options.trackedChangeLink !== undefined + ? options.trackedChangeLink + : entry.trackedChange === true || + entry.trackedChangeType != null || + entry.trackedChangeAnchorKey != null || + entry.trackedChangeText != null || + entry.deletedText != null + ? { + trackedChange: true, + trackedChangeType: entry.trackedChangeType ?? undefined, + trackedChangeDisplayType: entry.trackedChangeDisplayType ?? null, + trackedChangeStory: entry.trackedChangeStory ?? null, + trackedChangeAnchorKey: entry.trackedChangeAnchorKey ?? null, + trackedChangeText: entry.trackedChangeText ?? null, + deletedText: entry.deletedText ?? null, + } + : null; return { address: { @@ -341,5 +458,13 @@ export function toCommentInfo( createdTime: typeof entry.createdTime === 'number' ? entry.createdTime : undefined, creatorName: typeof entry.creatorName === 'string' ? entry.creatorName : undefined, creatorEmail: typeof entry.creatorEmail === 'string' ? entry.creatorEmail : undefined, + trackedChange: trackedChangeLink?.trackedChange === true ? true : undefined, + trackedChangeType: trackedChangeLink?.trackedChangeType, + trackedChangeDisplayType: trackedChangeLink?.trackedChangeDisplayType ?? undefined, + trackedChangeStory: trackedChangeLink?.trackedChangeStory ?? undefined, + trackedChangeAnchorKey: trackedChangeLink?.trackedChangeAnchorKey ?? undefined, + trackedChangeText: trackedChangeLink?.trackedChangeText ?? undefined, + deletedText: trackedChangeLink?.deletedText ?? undefined, + trackedChangeLink, }; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.test.ts index 1a22fcdfba..bf372bea9a 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.test.ts @@ -68,6 +68,26 @@ describe('resolveTextRangeInBlock', () => { expect(result).toEqual({ from: 2, to: 4 }); }); + it('resolves a non-collapsed range starting at an inline-wrapper boundary to the next text node', () => { + const prefix = createNode('run', [createNode('text', [], { text: 'The quick brown fox jumps over the ' })], { + isInline: true, + isLeaf: false, + }); + const insertedRun = createNode('run', [createNode('text', [], { text: 'lazy ' })], { + isInline: true, + isLeaf: false, + }); + const suffix = createNode('run', [createNode('text', [], { text: 'dog.' })], { + isInline: true, + isLeaf: false, + }); + const paragraph = createNode('paragraph', [prefix, insertedRun, suffix], { isBlock: true, inlineContent: true }); + + const result = resolveTextRangeInBlock(paragraph, 0, { start: 35, end: 39 }); + + expect(result).toEqual({ from: 39, to: 43 }); + }); + it('returns null for out-of-range offsets', () => { const textNode = createNode('text', [], { text: 'Hi' }); const paragraph = createNode('paragraph', [textNode], { isBlock: true, inlineContent: true }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.ts index 0272a9c28d..6cbfbe942a 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-offset-resolver.ts @@ -160,7 +160,8 @@ export function resolveTextRangeInBlock( const segmentStart = offset; const segmentEnd = offset + segmentLength; - if (fromPos == null && range.start <= segmentEnd) { + const collapsed = range.start === range.end; + if (fromPos == null && (range.start < segmentEnd || (collapsed && range.start <= segmentEnd))) { fromPos = resolveSegmentPosition(range.start, segmentStart, segmentLength, docFrom, docTo); } if (toPos == null && range.end <= segmentEnd) { diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-refs.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-refs.ts index 2d0cf1a8ea..b229fecc1d 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-refs.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-refs.ts @@ -1,7 +1,6 @@ import type { Editor } from '../../core/Editor.js'; import { TrackInsertMarkName } from '../../extensions/track-changes/constants.js'; -import { buildTrackedChangeCanonicalIdMap } from './tracked-change-resolver.js'; -import { toNonEmptyString } from './value-utils.js'; +import { buildTrackedChangeCanonicalIdMap, getTrackedChangeMarkAlias } from './tracked-change-resolver.js'; type ReceiptInsert = { kind: 'entity'; entityType: 'trackedChange'; entityId: string }; @@ -30,9 +29,9 @@ export function collectTrackInsertRefsInRange(editor: Editor, from: number, to: const marks = node.marks ?? []; for (const mark of marks) { if (mark.type.name !== TrackInsertMarkName) continue; - const id = toNonEmptyString(mark.attrs?.id); - if (!id) continue; - ids.add(canonicalIdByAlias.get(id) ?? id); + const alias = getTrackedChangeMarkAlias(mark); + if (!alias) continue; + ids.add(canonicalIdByAlias.get(alias) ?? alias); } }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.test.ts index fd170d76a4..e4ede58fee 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.test.ts @@ -18,8 +18,9 @@ vi.mock('../../extensions/track-changes/trackChangesHelpers/getTrackChanges.js', getTrackChanges: vi.fn(), })); -function makeEditor(): Editor { +function makeEditor(options: Record = { trackedChanges: {} }): Editor { return { + options, state: { doc: { content: { size: 100 }, @@ -55,8 +56,8 @@ describe('resolveTrackedChangeType', () => { expect(resolveTrackedChangeType({ hasInsert: true, hasDelete: true, hasFormat: true })).toBe('format'); }); - it('returns insert when both hasInsert and hasDelete are true (no format)', () => { - expect(resolveTrackedChangeType({ hasInsert: true, hasDelete: true, hasFormat: false })).toBe('insert'); + it('returns replacement when both hasInsert and hasDelete are true (no format)', () => { + expect(resolveTrackedChangeType({ hasInsert: true, hasDelete: true, hasFormat: false })).toBe('replacement'); }); }); @@ -65,7 +66,7 @@ describe('groupTrackedChanges', () => { vi.clearAllMocks(); }); - it('groups marks by raw id', () => { + it('groups imported Word marks by source wrapper', () => { vi.mocked(getTrackChanges).mockReturnValue([ { ...makeTrackMark(TrackInsertMarkName, 'tc-1', { sourceId: '11' }), from: 1, to: 5 }, { ...makeTrackMark(TrackDeleteMarkName, 'tc-1', { sourceId: '10' }), from: 5, to: 10 }, @@ -74,13 +75,33 @@ describe('groupTrackedChanges', () => { const editor = makeEditor(); const grouped = groupTrackedChanges(editor); + expect(grouped).toHaveLength(2); + expect(grouped[0]?.rawId).toBe(`word:${TrackInsertMarkName}:11`); + expect(grouped[0]?.commandRawId).toBe('tc-1'); + expect(grouped[0]?.hasInsert).toBe(true); + expect(grouped[0]?.hasDelete).toBe(false); + expect(grouped[0]?.wordRevisionIds).toEqual({ insert: '11' }); + expect(grouped[1]?.rawId).toBe(`word:${TrackDeleteMarkName}:10`); + expect(grouped[1]?.commandRawId).toBe('tc-1'); + expect(grouped[1]?.hasInsert).toBe(false); + expect(grouped[1]?.hasDelete).toBe(true); + expect(grouped[1]?.wordRevisionIds).toEqual({ delete: '10' }); + }); + + it('groups native marks by raw id', () => { + vi.mocked(getTrackChanges).mockReturnValue([ + { ...makeTrackMark(TrackInsertMarkName, 'tc-1'), from: 1, to: 5 }, + { ...makeTrackMark(TrackDeleteMarkName, 'tc-1'), from: 5, to: 10 }, + ] as never); + + const grouped = groupTrackedChanges(makeEditor()); + expect(grouped).toHaveLength(1); expect(grouped[0]?.rawId).toBe('tc-1'); expect(grouped[0]?.from).toBe(1); expect(grouped[0]?.to).toBe(10); expect(grouped[0]?.hasInsert).toBe(true); expect(grouped[0]?.hasDelete).toBe(true); - expect(grouped[0]?.wordRevisionIds).toEqual({ insert: '11', delete: '10' }); }); it('keeps separate entries for different raw ids', () => { @@ -148,6 +169,102 @@ describe('groupTrackedChanges', () => { expect(grouped[0]?.wordRevisionIds).toEqual({ format: '22' }); }); + it('preserves empty parent Word wrappers when the only text belongs to a child deletion', () => { + const parent = makeTrackMark(TrackInsertMarkName, 'parent', { sourceId: '2', author: 'Missy Fox' }); + const child = makeTrackMark(TrackDeleteMarkName, 'child', { + sourceId: '3', + author: 'Vivienne Salisbury', + overlapParentId: 'parent', + }); + const node = { text: 'XYZ', marks: [parent.mark, child.mark] }; + vi.mocked(getTrackChanges).mockReturnValue([ + { ...parent, node, from: 1, to: 4 }, + { ...child, node, from: 1, to: 4 }, + ] as never); + + const grouped = groupTrackedChanges(makeEditor()); + const parentChange = grouped.find((change) => change.wordRevisionIds?.insert === '2'); + const childChange = grouped.find((change) => change.wordRevisionIds?.delete === '3'); + + expect(parentChange?.excerpt).toBe(''); + expect(childChange?.excerpt).toBe('XYZ'); + }); + + it('keeps live parent insertion text when a child deletion overlaps it', () => { + const parent = makeTrackMark(TrackInsertMarkName, 'parent', { author: 'Live Author' }); + const child = makeTrackMark(TrackDeleteMarkName, 'child', { + author: 'Second Author', + overlapParentId: 'parent', + }); + const node = { text: 'review', marks: [parent.mark, child.mark] }; + vi.mocked(getTrackChanges).mockReturnValue([ + { ...parent, node, from: 1, to: 7 }, + { ...child, node, from: 1, to: 7 }, + ] as never); + + const grouped = groupTrackedChanges(makeEditor()); + const parentChange = grouped.find((change) => change.rawId === 'parent'); + const childChange = grouped.find((change) => change.rawId === 'child'); + + expect(parentChange?.excerpt).toBe('review'); + expect(childChange?.excerpt).toBe('review'); + }); + + it('attaches overlap visual layers to the parent insertion with child deletion as the context target', () => { + const parent = makeTrackMark(TrackInsertMarkName, 'parent-overlap', { author: 'Insert Author' }); + const child = makeTrackMark(TrackDeleteMarkName, 'child-overlap', { + author: 'Delete Author', + overlapParentId: 'parent-overlap', + }); + const node = { text: 'review', marks: [parent.mark, child.mark] }; + vi.mocked(getTrackChanges).mockReturnValue([ + { ...child, node, from: 1, to: 7 }, + { ...parent, node, from: 1, to: 7 }, + ] as never); + + const grouped = groupTrackedChanges(makeEditor()); + const parentChange = grouped.find((change) => change.rawId === 'parent-overlap'); + const childChange = grouped.find((change) => change.rawId === 'child-overlap'); + + expect(parentChange).toBeDefined(); + expect(childChange).toBeDefined(); + expect(parentChange!.overlap?.visualLayers).toEqual([ + { + id: parentChange!.id, + rawId: 'parent-overlap', + commandRawId: 'parent-overlap', + type: 'insert', + relationship: 'parent', + }, + { + id: childChange!.id, + rawId: 'child-overlap', + commandRawId: 'child-overlap', + type: 'delete', + relationship: 'child', + }, + ]); + expect(parentChange!.overlap?.preferredContextTargetId).toBe(childChange!.id); + expect(parentChange!.overlap?.preferredContextTarget).toEqual({ + id: childChange!.id, + rawId: 'child-overlap', + commandRawId: 'child-overlap', + type: 'delete', + relationship: 'child', + }); + expect(childChange!.overlap).toBeUndefined(); + }); + + it('preserves significant Word revision whitespace in explicit excerpts', () => { + const mark = makeTrackMark(TrackDeleteMarkName, 'delete-with-space', { sourceId: '4' }); + vi.mocked(getTrackChanges).mockReturnValue([ + { ...mark, node: { text: 'O ', marks: [mark.mark] }, from: 1, to: 3 }, + ] as never); + + const grouped = groupTrackedChanges(makeEditor()); + expect(grouped[0]?.excerpt).toBe('O '); + }); + it('sorts results by from position', () => { vi.mocked(getTrackChanges).mockReturnValue([ { ...makeTrackMark(TrackInsertMarkName, 'tc-2'), from: 10, to: 15 }, @@ -225,6 +342,44 @@ describe('buildTrackedChangeCanonicalIdMap', () => { expect(map.get(canonicalId!)).toBe(canonicalId); }); + it('does not map shared Word command ids as unique span aliases', () => { + vi.mocked(getTrackChanges).mockReturnValue([ + { ...makeTrackMark(TrackInsertMarkName, 'tc-1', { sourceId: '11' }), from: 1, to: 5 }, + { ...makeTrackMark(TrackDeleteMarkName, 'tc-1', { sourceId: '10' }), from: 5, to: 10 }, + ] as never); + + const editor = makeEditor(); + const map = buildTrackedChangeCanonicalIdMap(editor); + const grouped = groupTrackedChanges(editor); + const insertChange = grouped.find((change) => change.rawId === `word:${TrackInsertMarkName}:11`); + const deleteChange = grouped.find((change) => change.rawId === `word:${TrackDeleteMarkName}:10`); + + expect(insertChange).toBeDefined(); + expect(deleteChange).toBeDefined(); + expect(map.get(`word:${TrackInsertMarkName}:11`)).toBe(insertChange!.id); + expect(map.get(`word:${TrackDeleteMarkName}:10`)).toBe(insertChange!.id); + expect(map.get('tc-1')).toBe(insertChange!.id); + }); + + it('keeps split replacement aliases separate in independent mode', () => { + vi.mocked(getTrackChanges).mockReturnValue([ + { ...makeTrackMark(TrackInsertMarkName, 'tc-1', { sourceId: '11' }), from: 1, to: 5 }, + { ...makeTrackMark(TrackDeleteMarkName, 'tc-1', { sourceId: '10' }), from: 5, to: 10 }, + ] as never); + + const editor = makeEditor({ trackedChanges: { replacements: 'independent' } }); + const map = buildTrackedChangeCanonicalIdMap(editor); + const grouped = groupTrackedChanges(editor); + const insertChange = grouped.find((change) => change.rawId === `word:${TrackInsertMarkName}:11`); + const deleteChange = grouped.find((change) => change.rawId === `word:${TrackDeleteMarkName}:10`); + + expect(insertChange).toBeDefined(); + expect(deleteChange).toBeDefined(); + expect(map.get(`word:${TrackInsertMarkName}:11`)).toBe(insertChange!.id); + expect(map.get(`word:${TrackDeleteMarkName}:10`)).toBe(deleteChange!.id); + expect(map.get('tc-1')).toBe(deleteChange!.id); + }); + it('returns empty map when no tracked changes exist', () => { vi.mocked(getTrackChanges).mockReturnValue([] as never); expect(buildTrackedChangeCanonicalIdMap(makeEditor()).size).toBe(0); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts index f21bf2bdb2..734a76ae12 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts @@ -2,6 +2,8 @@ import type { Node as ProseMirrorNode } from 'prosemirror-model'; import type { Editor } from '../../core/Editor.js'; import type { StoryLocator, + TrackChangeOverlapInfo, + TrackChangeOverlapLayer, TrackChangeType, TrackChangeWordRevisionIds, TrackedChangeAddress, @@ -24,12 +26,14 @@ type RawTrackedMark = { type: { name: string }; attrs?: Record; }; + node?: ProseMirrorNode; from: number; to: number; }; export type GroupedTrackedChange = { rawId: string; + commandRawId?: string; id: string; from: number; to: number; @@ -37,10 +41,19 @@ export type GroupedTrackedChange = { hasDelete: boolean; hasFormat: boolean; attrs: Record; + excerpt?: string; wordRevisionIds?: TrackChangeWordRevisionIds; + overlap?: TrackChangeOverlapInfo; }; +export type TrackedChangeProjectedSide = 'inserted' | 'deleted'; + type ChangeTypeInput = Pick; +type GroupedTrackedChangeDraft = Omit & { excerptParts: string[] }; +type InternalTrackChangeOverlapLayer = TrackChangeOverlapLayer & { + rawId?: string; + commandRawId?: string; +}; function getRawTrackedMarks(editor: Editor): RawTrackedMark[] { try { @@ -92,7 +105,10 @@ function portableHash(input: string): string { */ function deriveTrackedChangeId(editor: Editor, change: Omit): string { const type = resolveTrackedChangeType(change); - const excerpt = normalizeExcerpt(editor.state.doc.textBetween(change.from, change.to, ' ', '\ufffc')) ?? ''; + const excerpt = + (change.excerpt !== undefined ? change.excerpt : undefined) ?? + normalizeExcerpt(editor.state.doc.textBetween(change.from, change.to, ' ', '\ufffc')) ?? + ''; const author = toNonEmptyString(change.attrs.author) ?? ''; const authorEmail = toNonEmptyString(change.attrs.authorEmail) ?? ''; const date = toNonEmptyString(change.attrs.date) ?? ''; @@ -103,11 +119,17 @@ function deriveTrackedChangeId(editor: Editor, change: Omit(); +type ReplacementsMode = 'paired' | 'independent'; + +function readReplacementsMode(editor: Editor): ReplacementsMode { + return editor?.options?.trackedChanges?.replacements === 'independent' ? 'independent' : 'paired'; +} function mergeWordRevisionId( target: TrackChangeWordRevisionIds | undefined, @@ -134,36 +156,204 @@ function getWordRevisionIdKey(markType: string): keyof TrackChangeWordRevisionId return null; } +function getTrackedChangeGroupKey( + attrs: Readonly>, + markType: string, + fallbackId: string, +): string { + const sourceId = toNonEmptyString(attrs.sourceId); + return sourceId ? `word:${markType}:${sourceId}` : fallbackId; +} + +function isTrackedMarkName(markType: string | undefined): boolean { + return markType === TrackInsertMarkName || markType === TrackDeleteMarkName || markType === TrackFormatMarkName; +} + +function getTrackedChangeAliasCandidates(change: GroupedTrackedChange): string[] { + const candidates = [ + change.rawId, + change.commandRawId, + change.id, + toNonEmptyString(change.attrs.id), + toNonEmptyString(change.attrs.replacementGroupId), + toNonEmptyString(change.attrs.sourceId), + ]; + return Array.from(new Set(candidates.filter((value): value is string => Boolean(value)))); +} + +function replacementPairKey(change: GroupedTrackedChange): string | null { + if (change.hasInsert === change.hasDelete) return null; + const replacementGroupId = toNonEmptyString(change.attrs.replacementGroupId); + if (replacementGroupId) return `group:${replacementGroupId}`; + if (change.commandRawId) return `command:${change.commandRawId}`; + return null; +} + +function buildPublicTrackedChangeIdMap( + grouped: ReadonlyArray, + replacements: ReplacementsMode, +): Map { + const publicIdByChange = new Map(); + + if (replacements === 'paired') { + const byPairKey = new Map(); + for (const change of grouped) { + if (change.hasInsert && change.hasDelete) continue; + const key = replacementPairKey(change); + if (!key) continue; + const bucket = byPairKey.get(key) ?? []; + bucket.push(change); + byPairKey.set(key, bucket); + } + + for (const group of byPairKey.values()) { + const inserted = group.find((change) => change.hasInsert && !change.hasDelete); + const deleted = group.find((change) => change.hasDelete && !change.hasInsert); + if (!inserted || !deleted) continue; + publicIdByChange.set(inserted, inserted.id); + publicIdByChange.set(deleted, inserted.id); + } + } + + for (const change of grouped) { + if (!publicIdByChange.has(change)) { + publicIdByChange.set(change, change.id); + } + } + + return publicIdByChange; +} + +function layerFromChange( + change: GroupedTrackedChange, + relationship: TrackChangeOverlapLayer['relationship'], +): InternalTrackChangeOverlapLayer { + return { + id: change.id, + rawId: change.rawId, + commandRawId: change.commandRawId, + type: resolveTrackedChangeType(change), + relationship, + }; +} + +function compareOverlapChildren(a: GroupedTrackedChange, b: GroupedTrackedChange): number { + const aType = resolveTrackedChangeType(a); + const bType = resolveTrackedChangeType(b); + if (aType !== bType) { + if (aType === 'delete') return -1; + if (bType === 'delete') return 1; + } + if (a.from !== b.from) return a.from - b.from; + return a.id.localeCompare(b.id); +} + +function attachOverlapMetadata(grouped: GroupedTrackedChange[]): void { + if (grouped.length < 2) return; + + const byAlias = new Map(); + for (const change of grouped) { + for (const alias of getTrackedChangeAliasCandidates(change)) { + if (!byAlias.has(alias)) byAlias.set(alias, change); + } + } + + const childrenByParent = new Map(); + for (const child of grouped) { + const parentRef = toNonEmptyString(child.attrs.overlapParentId); + if (!parentRef) continue; + const parent = byAlias.get(parentRef); + if (!parent || parent === child) continue; + const children = childrenByParent.get(parent) ?? []; + children.push(child); + childrenByParent.set(parent, children); + } + + for (const [parent, children] of childrenByParent.entries()) { + const orderedChildren = children.slice().sort(compareOverlapChildren); + const visualLayers = [ + layerFromChange(parent, 'parent'), + ...orderedChildren.map((child) => layerFromChange(child, 'child')), + ]; + const preferredContextTarget = + visualLayers.find((layer) => layer.relationship === 'child' && layer.type === 'delete') ?? + visualLayers.find((layer) => layer.relationship === 'child'); + + parent.overlap = { + visualLayers, + ...(preferredContextTarget + ? { + preferredContextTargetId: preferredContextTarget.id, + preferredContextTarget, + } + : {}), + }; + } +} + +export function getTrackedChangeMarkAlias(mark: { + readonly type: { readonly name: string }; + readonly attrs?: Readonly>; +}): string | null { + const markType = mark.type.name; + if (!isTrackedMarkName(markType)) return null; + const attrs = mark.attrs ?? {}; + const id = toNonEmptyString(attrs.id); + if (!id) return null; + return getTrackedChangeGroupKey(attrs, markType, id); +} + +function hasChildTrackedMarkOnNode(item: RawTrackedMark, parentId: string): boolean { + if (!parentId) return false; + const marks = Array.isArray(item.node?.marks) ? item.node.marks : []; + return marks.some((mark) => { + const markType = mark?.type?.name; + if (!isTrackedMarkName(markType)) return false; + return toNonEmptyString(mark?.attrs?.overlapParentId) === parentId; + }); +} + +function getTrackedMarkText(editor: Editor, item: RawTrackedMark): string { + const nodeText = item.node?.text; + if (typeof nodeText === 'string') return nodeText; + return editor.state.doc.textBetween(item.from, item.to, ' ', '\ufffc'); +} + export function groupTrackedChanges(editor: Editor): GroupedTrackedChange[] { const currentDoc = editor.state.doc; const cached = groupedCache.get(editor); if (cached && cached.doc === currentDoc) return cached.grouped; const marks = getRawTrackedMarks(editor); - const byRawId = new Map>(); + const byRawId = new Map(); for (const item of marks) { const attrs = item.mark?.attrs ?? {}; const id = toNonEmptyString(attrs.id); if (!id) continue; - const existing = byRawId.get(id); const markType = item.mark.type.name; + const groupKey = getTrackedChangeGroupKey(attrs, markType, id); + const existing = byRawId.get(groupKey); const nextHasInsert = markType === TrackInsertMarkName; const nextHasDelete = markType === TrackDeleteMarkName; const nextHasFormat = markType === TrackFormatMarkName; const wordRevisionId = toNonEmptyString(attrs.sourceId); const wordRevisionIdKey = getWordRevisionIdKey(markType); + const contributesToExcerpt = !wordRevisionId || !hasChildTrackedMarkOnNode(item, id); + const excerptText = contributesToExcerpt ? getTrackedMarkText(editor, item) : ''; if (!existing) { - byRawId.set(id, { - rawId: id, + byRawId.set(groupKey, { + rawId: groupKey, + commandRawId: id, from: item.from, to: item.to, hasInsert: nextHasInsert, hasDelete: nextHasDelete, hasFormat: nextHasFormat, attrs: { ...attrs }, + excerptParts: excerptText ? [excerptText] : [], wordRevisionIds: wordRevisionIdKey ? mergeWordRevisionId(undefined, wordRevisionIdKey, wordRevisionId ?? undefined) : undefined, @@ -179,6 +369,9 @@ export function groupTrackedChanges(editor: Editor): GroupedTrackedChange[] { if (Object.keys(existing.attrs).length === 0 && Object.keys(attrs).length > 0) { existing.attrs = { ...attrs }; } + if (excerptText) { + existing.excerptParts.push(excerptText); + } if (wordRevisionIdKey) { existing.wordRevisionIds = mergeWordRevisionId( existing.wordRevisionIds, @@ -189,39 +382,72 @@ export function groupTrackedChanges(editor: Editor): GroupedTrackedChange[] { } const grouped = Array.from(byRawId.values()) - .map((change) => ({ - ...change, - id: deriveTrackedChangeId(editor, change), - })) + .map(({ excerptParts, ...change }) => { + const hasWordSourceId = Boolean(toNonEmptyString(change.attrs.sourceId)); + const rawExcerpt = excerptParts.join(''); + const withExcerpt = { + ...change, + excerpt: excerptParts.length > 0 ? rawExcerpt : hasWordSourceId ? '' : undefined, + }; + return { + ...withExcerpt, + id: deriveTrackedChangeId(editor, withExcerpt), + }; + }) .sort((a, b) => { if (a.from !== b.from) return a.from - b.from; return a.id.localeCompare(b.id); }); + attachOverlapMetadata(grouped); groupedCache.set(editor, { doc: currentDoc, grouped }); return grouped; } export function resolveTrackedChange(editor: Editor, id: string): GroupedTrackedChange | null { + const { baseId } = splitProjectedTrackedChangeId(id); const grouped = groupTrackedChanges(editor); - return grouped.find((item) => item.id === id) ?? null; + return grouped.find((item) => item.id === baseId) ?? null; } export function toCanonicalTrackedChangeId(editor: Editor, rawId: string): string | null { - const grouped = groupTrackedChanges(editor); - return grouped.find((item) => item.rawId === rawId)?.id ?? null; + const { baseId, side } = splitProjectedTrackedChangeId(rawId); + const canonical = buildTrackedChangeCanonicalIdMap(editor).get(baseId) ?? null; + if (!canonical) return null; + return side ? `${canonical}#${side}` : canonical; } export function buildTrackedChangeCanonicalIdMap(editor: Editor): Map { const grouped = groupTrackedChanges(editor); + const publicIdByChange = buildPublicTrackedChangeIdMap(grouped, readReplacementsMode(editor)); const map = new Map(); for (const change of grouped) { - map.set(change.rawId, change.id); - map.set(change.id, change.id); + const publicId = publicIdByChange.get(change) ?? change.id; + map.set(change.rawId, publicId); + map.set(change.id, publicId); + if (change.commandRawId) map.set(change.commandRawId, publicId); + const replacementGroupId = toNonEmptyString(change.attrs.replacementGroupId); + if (replacementGroupId) map.set(replacementGroupId, publicId); + map.set(publicId, publicId); + map.set(`${publicId}#inserted`, publicId); + map.set(`${publicId}#deleted`, publicId); } return map; } +export function splitProjectedTrackedChangeId(value: string): { + baseId: string; + side: TrackedChangeProjectedSide | null; +} { + if (value.endsWith('#inserted')) { + return { baseId: value.slice(0, -'#inserted'.length), side: 'inserted' }; + } + if (value.endsWith('#deleted')) { + return { baseId: value.slice(0, -'#deleted'.length), side: 'deleted' }; + } + return { baseId: value, side: null }; +} + // --------------------------------------------------------------------------- // Story-aware resolution // --------------------------------------------------------------------------- @@ -315,6 +541,7 @@ export function resolveTrackedChangeInStory( * tolerate callers that stored whichever was convenient at the time. */ function findMatchingChange(editor: Editor, id: string): GroupedTrackedChange | null { + const { baseId } = splitProjectedTrackedChangeId(id); const grouped = groupTrackedChanges(editor); - return grouped.find((item) => item.id === id || item.rawId === id) ?? null; + return grouped.find((item) => item.id === baseId || item.rawId === baseId || item.commandRawId === baseId) ?? null; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.test.ts index 3a44dce750..8e0b5d911d 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.test.ts @@ -18,21 +18,33 @@ vi.mock('./revision-tracker.js', () => ({ getRevision: vi.fn(() => 'rev-1'), })); +vi.mock('../tracked-changes/tracked-change-index.js', () => ({ + getTrackedChangeIndex: vi.fn(() => ({ getAll: () => [] })), +})); + vi.mock('./plan-wrappers.js', () => ({ executeDomainCommand: vi.fn(), })); -vi.mock('../helpers/adapter-utils.js', async () => { - const actual = await vi.importActual('../helpers/adapter-utils.js'); - return { - ...actual, - resolveTextTarget: vi.fn(), - }; -}); +vi.mock('../helpers/adapter-utils.js', () => ({ + resolveTextTarget: vi.fn(), + validatePaginationInput: vi.fn(), + paginate: vi.fn((items: T[], offset = 0, limit?: number) => { + const total = items.length; + const effectiveLimit = limit ?? total; + return { total, items: items.slice(offset, offset + effectiveLimit) }; + }), +})); import { listCommentAnchors } from '../helpers/comment-target-resolver.js'; import { resolveTextTarget } from '../helpers/adapter-utils.js'; import { executeDomainCommand } from './plan-wrappers.js'; +import { getTrackedChangeIndex } from '../tracked-changes/tracked-change-index.js'; + +const listCommentAnchorsMock = listCommentAnchors as unknown as ReturnType; +const resolveTextTargetMock = resolveTextTarget as unknown as ReturnType; +const executeDomainCommandMock = executeDomainCommand as unknown as ReturnType; +const getTrackedChangeIndexMock = getTrackedChangeIndex as unknown as ReturnType; function makeAnchor( overrides: Partial & { commentId: string; pos: number; end: number }, @@ -69,6 +81,11 @@ function mockTextBetweenSequence(editor: Editor, ...values: string[]): void { (editor.state!.doc as { textBetween: ReturnType }).textBetween = vi.fn(() => values[i++] ?? ''); } +beforeEach(() => { + listCommentAnchorsMock.mockReturnValue([]); + getTrackedChangeIndexMock.mockReturnValue({ getAll: () => [] } as never); +}); + describe('comments-wrappers: anchoredText', () => { beforeEach(() => { vi.clearAllMocks(); @@ -76,7 +93,7 @@ describe('comments-wrappers: anchoredText', () => { it('populates anchoredText for a root comment with an anchor', () => { const editor = makeEditor([{ commentId: 'c1', commentText: 'My comment' }], 'selected text'); - vi.mocked(listCommentAnchors).mockReturnValue([makeAnchor({ commentId: 'c1', pos: 10, end: 23 })]); + listCommentAnchorsMock.mockReturnValue([makeAnchor({ commentId: 'c1', pos: 10, end: 23 })]); const wrapper = createCommentsWrapper(editor); const result = wrapper.list(); @@ -86,7 +103,7 @@ describe('comments-wrappers: anchoredText', () => { it('returns anchoredText as undefined when comment has no anchor', () => { const editor = makeEditor([{ commentId: 'c1', commentText: 'My comment' }]); - vi.mocked(listCommentAnchors).mockReturnValue([]); + listCommentAnchorsMock.mockReturnValue([]); const wrapper = createCommentsWrapper(editor); const result = wrapper.list(); @@ -102,7 +119,7 @@ describe('comments-wrappers: anchoredText', () => { ], 'anchored excerpt', ); - vi.mocked(listCommentAnchors).mockReturnValue([makeAnchor({ commentId: 'c1', pos: 5, end: 20 })]); + listCommentAnchorsMock.mockReturnValue([makeAnchor({ commentId: 'c1', pos: 5, end: 20 })]); const wrapper = createCommentsWrapper(editor); const result = wrapper.list(); @@ -115,7 +132,7 @@ describe('comments-wrappers: anchoredText', () => { it('returns anchoredText on comments.get as well', () => { const editor = makeEditor([{ commentId: 'c1', commentText: 'My comment' }], 'get excerpt'); - vi.mocked(listCommentAnchors).mockReturnValue([makeAnchor({ commentId: 'c1', pos: 0, end: 11 })]); + listCommentAnchorsMock.mockReturnValue([makeAnchor({ commentId: 'c1', pos: 0, end: 11 })]); const wrapper = createCommentsWrapper(editor); const info = wrapper.get({ commentId: 'c1' }); @@ -127,7 +144,7 @@ describe('comments-wrappers: anchoredText', () => { (editor.state!.doc as { textBetween: ReturnType }).textBetween = vi.fn(() => { throw new Error('out of range'); }); - vi.mocked(listCommentAnchors).mockReturnValue([makeAnchor({ commentId: 'c1', pos: 999, end: 1000 })]); + listCommentAnchorsMock.mockReturnValue([makeAnchor({ commentId: 'c1', pos: 999, end: 1000 })]); const wrapper = createCommentsWrapper(editor); const result = wrapper.list(); @@ -143,7 +160,7 @@ describe('comments-wrappers: anchoredText', () => { ], 'deep excerpt', ); - vi.mocked(listCommentAnchors).mockReturnValue([makeAnchor({ commentId: 'c1', pos: 0, end: 12 })]); + listCommentAnchorsMock.mockReturnValue([makeAnchor({ commentId: 'c1', pos: 0, end: 12 })]); const wrapper = createCommentsWrapper(editor); const result = wrapper.list(); @@ -158,7 +175,7 @@ describe('comments-wrappers: anchoredText', () => { it('strips object-replacement characters from range-node atoms', () => { const editor = makeEditor([{ commentId: 'c1', commentText: 'My comment' }], '\ufffchello world\ufffc'); - vi.mocked(listCommentAnchors).mockReturnValue([makeAnchor({ commentId: 'c1', pos: 0, end: 15 })]); + listCommentAnchorsMock.mockReturnValue([makeAnchor({ commentId: 'c1', pos: 0, end: 15 })]); const wrapper = createCommentsWrapper(editor); const result = wrapper.list(); @@ -167,14 +184,77 @@ describe('comments-wrappers: anchoredText', () => { it('populates anchoredText for resolved comments', () => { const editor = makeEditor([{ commentId: 'c1', commentText: 'Resolved note', isDone: true }], 'resolved text'); - vi.mocked(listCommentAnchors).mockReturnValue([ - makeAnchor({ commentId: 'c1', pos: 0, end: 13, status: 'resolved' }), - ]); + listCommentAnchorsMock.mockReturnValue([makeAnchor({ commentId: 'c1', pos: 0, end: 13, status: 'resolved' })]); const wrapper = createCommentsWrapper(editor); const result = wrapper.list({ includeResolved: true }); expect(result.items[0]!.anchoredText).toBe('resolved text'); }); + + it('projects live tracked changes as tracked-change comments', () => { + const editor = makeEditor([]); + getTrackedChangeIndexMock.mockReturnValue({ + getAll: () => [ + { + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-live' }, + runtimeRef: { storyKey: 'body', rawId: 'tc-live' }, + story: { kind: 'story', storyType: 'body' }, + type: 'insert', + author: 'Alice', + authorEmail: 'alice@example.com', + date: '2026-05-22T04:00:00.000Z', + excerpt: 'live-review-comment', + storyLabel: 'Body', + storyKind: 'body', + anchorKey: 'tc::body::tc-live', + range: { from: 1, to: 20 }, + }, + ], + } as never); + + const wrapper = createCommentsWrapper(editor); + const result = wrapper.list(); + + expect(result.items).toHaveLength(1); + expect(result.items[0]).toEqual( + expect.objectContaining({ + id: 'tc-live', + trackedChange: true, + trackedChangeType: 'insert', + trackedChangeText: 'live-review-comment', + trackedChangeAnchorKey: 'tc::body::tc-live', + creatorName: 'Alice', + creatorEmail: 'alice@example.com', + }), + ); + }); + + it('does not add synthetic comments for imported Word tracked changes', () => { + const editor = makeEditor([]); + getTrackedChangeIndexMock.mockReturnValue({ + getAll: () => [ + { + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-imported' }, + runtimeRef: { storyKey: 'body', rawId: 'word:trackInsert:1' }, + story: { kind: 'story', storyType: 'body' }, + type: 'insert', + author: 'Alice', + date: '2026-05-22T04:00:00.000Z', + excerpt: 'imported', + wordRevisionIds: { insert: '1' }, + storyLabel: 'Body', + storyKind: 'body', + anchorKey: 'tc::body::word:trackInsert:1', + range: { from: 1, to: 9 }, + }, + ], + } as never); + + const wrapper = createCommentsWrapper(editor); + const result = wrapper.list(); + + expect(result.items).toHaveLength(0); + }); }); describe('comments-wrappers: multi-segment TextTarget', () => { @@ -184,7 +264,7 @@ describe('comments-wrappers: multi-segment TextTarget', () => { it('returns a single-segment target for a comment with one anchor', () => { const editor = makeEditor([{ commentId: 'c1', commentText: 'Comment' }], 'abc'); - vi.mocked(listCommentAnchors).mockReturnValue([ + listCommentAnchorsMock.mockReturnValue([ makeAnchor({ commentId: 'c1', pos: 0, @@ -205,7 +285,7 @@ describe('comments-wrappers: multi-segment TextTarget', () => { const editor = makeEditor([{ commentId: 'c1', commentText: 'Comment' }]); mockTextBetweenSequence(editor, 'abc', 'def'); - vi.mocked(listCommentAnchors).mockReturnValue([ + listCommentAnchorsMock.mockReturnValue([ makeAnchor({ commentId: 'c1', pos: 0, @@ -239,7 +319,7 @@ describe('comments-wrappers: multi-segment TextTarget', () => { mockTextBetweenSequence(editor, 'first', 'second'); // Anchors provided out of document order — sorted internally by pos - vi.mocked(listCommentAnchors).mockReturnValue([ + listCommentAnchorsMock.mockReturnValue([ makeAnchor({ commentId: 'c1', pos: 50, @@ -265,7 +345,7 @@ describe('comments-wrappers: multi-segment TextTarget', () => { it('returns target as undefined when comment has no anchors', () => { const editor = makeEditor([{ commentId: 'c1', commentText: 'Comment' }]); - vi.mocked(listCommentAnchors).mockReturnValue([]); + listCommentAnchorsMock.mockReturnValue([]); const wrapper = createCommentsWrapper(editor); const result = wrapper.list(); @@ -276,7 +356,7 @@ describe('comments-wrappers: multi-segment TextTarget', () => { const editor = makeEditor([{ commentId: 'c1', commentText: 'Comment' }]); mockTextBetweenSequence(editor, 'hello', 'world'); - vi.mocked(listCommentAnchors).mockReturnValue([ + listCommentAnchorsMock.mockReturnValue([ makeAnchor({ commentId: 'c1', pos: 0, @@ -304,7 +384,7 @@ describe('comments-wrappers: multi-segment TextTarget', () => { ]); mockTextBetweenSequence(editor, 'abc', 'def'); - vi.mocked(listCommentAnchors).mockReturnValue([ + listCommentAnchorsMock.mockReturnValue([ makeAnchor({ commentId: 'c1', pos: 0, @@ -342,7 +422,7 @@ describe('comments-wrappers: multi-segment TextTarget', () => { return 'second segment'; }); - vi.mocked(listCommentAnchors).mockReturnValue([ + listCommentAnchorsMock.mockReturnValue([ makeAnchor({ commentId: 'c1', pos: 999, @@ -369,7 +449,7 @@ describe('comments-wrappers: multi-segment TextTarget', () => { const editor = makeEditor([{ commentId: 'c1', commentText: 'Comment' }]); mockTextBetweenSequence(editor, '\ufffcabc\ufffc', '\ufffcdef\ufffc'); - vi.mocked(listCommentAnchors).mockReturnValue([ + listCommentAnchorsMock.mockReturnValue([ makeAnchor({ commentId: 'c1', pos: 0, @@ -398,7 +478,7 @@ describe('comments-wrappers: same-block segment canonicalization', () => { it('merges adjacent same-block anchors into one segment', () => { // Two adjacent ranges in block p1: [0,5] and [5,10] → merged [0,10] const editor = makeEditor([{ commentId: 'c1', commentText: 'Comment' }], 'abcdefghij'); - vi.mocked(listCommentAnchors).mockReturnValue([ + listCommentAnchorsMock.mockReturnValue([ makeAnchor({ commentId: 'c1', pos: 1, @@ -427,7 +507,7 @@ describe('comments-wrappers: same-block segment canonicalization', () => { it('merges overlapping same-block anchors into one segment', () => { // Two overlapping ranges in block p1: [0,5] and [3,8] → merged [0,8] const editor = makeEditor([{ commentId: 'c1', commentText: 'Comment' }], 'abcdefgh'); - vi.mocked(listCommentAnchors).mockReturnValue([ + listCommentAnchorsMock.mockReturnValue([ makeAnchor({ commentId: 'c1', pos: 1, @@ -457,7 +537,7 @@ describe('comments-wrappers: same-block segment canonicalization', () => { const editor = makeEditor([{ commentId: 'c1', commentText: 'Comment' }]); mockTextBetweenSequence(editor, 'abc', 'ghij'); - vi.mocked(listCommentAnchors).mockReturnValue([ + listCommentAnchorsMock.mockReturnValue([ makeAnchor({ commentId: 'c1', pos: 1, @@ -491,7 +571,7 @@ describe('comments-wrappers: same-block segment canonicalization', () => { const editor = makeEditor([{ commentId: 'c1', commentText: 'Comment' }]); mockTextBetweenSequence(editor, 'abcdefghij', 'xyz'); - vi.mocked(listCommentAnchors).mockReturnValue([ + listCommentAnchorsMock.mockReturnValue([ makeAnchor({ commentId: 'c1', pos: 1, @@ -553,7 +633,7 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { // segments[0] resolves to a later PM range than segments[1] — the // caller built an out-of-order TextTarget (e.g. stitched two // selections together backwards). - vi.mocked(resolveTextTarget).mockImplementation((_editor, target) => { + resolveTextTargetMock.mockImplementation((_editor, target) => { if (target.blockId === 'pA') return { from: 50, to: 60 }; if (target.blockId === 'pB') return { from: 10, to: 20 }; return null; @@ -583,7 +663,7 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { // segments[0] and segments[1] are in order, but there is text // between them (pm positions 10..20 are selected, 30..40 selected, // positions 20..30 have real text the caller did not select). - vi.mocked(resolveTextTarget).mockImplementation((_editor, target) => { + resolveTextTargetMock.mockImplementation((_editor, target) => { if (target.blockId === 'p1') return { from: 10, to: 20 }; if (target.blockId === 'p3') return { from: 30, to: 40 }; return null; @@ -613,7 +693,7 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { // Two adjacent textblocks — the flattened PM gap between them is // just block-boundary tokens, which textBetween(prev.to, curr.from, '') // renders as an empty string. - vi.mocked(resolveTextTarget).mockImplementation((_editor, target) => { + resolveTextTargetMock.mockImplementation((_editor, target) => { if (target.blockId === 'pA') return { from: 10, to: 20 }; if (target.blockId === 'pB') return { from: 22, to: 30 }; return null; @@ -621,7 +701,7 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { (editor.state!.doc as { textBetween: ReturnType }).textBetween = vi.fn(() => ''); // Simulate a successful plan execution so the handler reaches the // success branch after validation + applyTextSelection. - vi.mocked(executeDomainCommand).mockReturnValue({ + executeDomainCommandMock.mockReturnValue({ steps: [{ effect: 'changed' }], } as unknown as ReturnType); @@ -643,14 +723,82 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { expect(receipt.success).toBe(true); }); + it('accepts trackedChangeId targets from the tracked-change index when live resolution misses a body deletion', () => { + const editor = makeWriteEditor(); + const bodyDeletionSnapshot = { + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-del-1' }, + runtimeRef: { storyKey: 'body', rawId: 'tc-del-1' }, + story: { kind: 'story', storyType: 'body' }, + type: 'delete', + excerpt: 'deleted text', + storyLabel: 'Body', + storyKind: 'body', + anchorKey: 'tc::body::tc-del-1', + hasInsert: false, + hasDelete: true, + hasFormat: false, + range: { from: 19, to: 27 }, + }; + getTrackedChangeIndexMock.mockReturnValue({ + get: () => [bodyDeletionSnapshot], + getAll: () => [], + } as never); + executeDomainCommandMock.mockReturnValue({ + steps: [{ effect: 'changed' }], + } as unknown as ReturnType); + + const wrapper = createCommentsWrapper(editor); + const receipt = wrapper.add({ + text: 'comment on deletion', + target: { + trackedChangeId: 'tc-del-1', + } as Parameters[0]['target'], + }); + + expect(receipt.success).toBe(true); + expect(editor.commands!.setTextSelection).toHaveBeenCalledWith({ from: 19, to: 27 }); + }); + + it('fails closed when a tracked-change target cannot be resolved live', () => { + const editor = { + ...makeWriteEditor(), + emit: vi.fn(), + options: { + documentId: 'doc-1', + user: { + name: 'Actor B', + email: 'actor-b@labs.requirements', + }, + }, + } as unknown as Editor; + + (editor.commands!.addComment as ReturnType).mockImplementation(() => { + throw new Error('addComment should not run for detached tracked deletions'); + }); + + const wrapper = createCommentsWrapper(editor); + expect(() => + wrapper.add({ + text: 'comment on deletion', + target: { + trackedChangeId: 'tc-del-1#deleted', + } as Parameters[0]['target'], + }), + ).toThrowError(/Comment target could not be resolved/); + expect(editor.commands!.setTextSelection as ReturnType).not.toHaveBeenCalled(); + expect(editor.commands!.addComment as ReturnType).not.toHaveBeenCalled(); + expect(editor.converter!.comments).toEqual([]); + expect((editor as { emit: ReturnType }).emit).not.toHaveBeenCalled(); + }); + it('treats a TextAddress with an undefined `segments` field as TextAddress, not TextTarget', () => { // Regression: a plain structural `'segments' in target` check misclassifies // a TextAddress carrying an extra undefined `segments` field (e.g. from // object spread) as a TextTarget, then crashes on `segments[0]`. The // runtime guard must reject a non-array `segments` before the spread. const editor = makeWriteEditor(); - vi.mocked(resolveTextTarget).mockReturnValue({ from: 5, to: 12 }); - vi.mocked(executeDomainCommand).mockReturnValue({ + resolveTextTargetMock.mockReturnValue({ from: 5, to: 12 }); + executeDomainCommandMock.mockReturnValue({ steps: [{ effect: 'changed' }], } as unknown as ReturnType); @@ -687,8 +835,8 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { // requires the absence of TextAddress fields, so a hybrid falls // through to the explicit-block branch. const editor = makeWriteEditor(); - vi.mocked(resolveTextTarget).mockReturnValue({ from: 11, to: 17 }); - vi.mocked(executeDomainCommand).mockReturnValue({ + resolveTextTargetMock.mockReturnValue({ from: 11, to: 17 }); + executeDomainCommandMock.mockReturnValue({ steps: [{ effect: 'changed' }], } as unknown as ReturnType); @@ -722,8 +870,8 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { // a TextAddress, so the adapter must not fall through to the // single-block path and dereference target.range. const editor = makeWriteEditor(); - vi.mocked(resolveTextTarget).mockReturnValue({ from: 11, to: 17 }); - vi.mocked(executeDomainCommand).mockReturnValue({ + resolveTextTargetMock.mockReturnValue({ from: 11, to: 17 }); + executeDomainCommandMock.mockReturnValue({ steps: [{ effect: 'changed' }], } as unknown as ReturnType); @@ -748,8 +896,8 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { // but not as a TextAddress. The adapter should resolve the real // segment instead of manufacturing a segment from the stray range. const editor = makeWriteEditor(); - vi.mocked(resolveTextTarget).mockReturnValue({ from: 11, to: 17 }); - vi.mocked(executeDomainCommand).mockReturnValue({ + resolveTextTargetMock.mockReturnValue({ from: 11, to: 17 }); + executeDomainCommandMock.mockReturnValue({ steps: [{ effect: 'changed' }], } as unknown as ReturnType); @@ -777,7 +925,7 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { // firstResolved.from < lastResolved.to across the block boundary), // silently anchoring a comment over content the caller never selected. const editor = makeWriteEditor(); - vi.mocked(resolveTextTarget).mockImplementation((_editor, target) => { + resolveTextTargetMock.mockImplementation((_editor, target) => { if (target.blockId === 'pA') return { from: 10, to: 10 }; if (target.blockId === 'pB') return { from: 20, to: 20 }; return null; @@ -807,7 +955,7 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { // because PM omits leaves from textBetween by default. The contiguity // check must use a leafText callback so atom-only gaps still reject. const editor = makeWriteEditor(); - vi.mocked(resolveTextTarget).mockImplementation((_editor, target) => { + resolveTextTargetMock.mockImplementation((_editor, target) => { if (target.blockId === 'p1') return { from: 5, to: 10 }; if (target.blockId === 'p1-after-image') return { from: 12, to: 17 }; return null; diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts index b5f4ec71e0..36b7dd1196 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts @@ -10,8 +10,11 @@ import type { Editor } from '../../core/Editor.js'; import type { AddCommentInput, + CommentTarget, CommentInfo, + CommentTrackedChangeLink, CommentsAdapter, + CommentsCreateReceipt, CommentsListQuery, CommentsListResult, EditCommentInput, @@ -24,10 +27,13 @@ import type { ReplyToCommentInput, ResolveCommentInput, RevisionGuardOptions, + SelectionTarget, + StoryLocator, SetCommentActiveInput, SetCommentInternalInput, TextSegment, TextTarget, + TrackChangeType, } from '@superdoc/document-api'; import { buildResolvedHandle, buildDiscoveryItem, buildDiscoveryResult } from '@superdoc/document-api'; import { TextSelection } from 'prosemirror-state'; @@ -35,21 +41,29 @@ import { v4 as uuidv4 } from 'uuid'; import { DocumentApiAdapterError } from '../errors.js'; import { requireEditorCommand } from '../helpers/mutation-helpers.js'; import { clearIndexCache } from '../helpers/index-cache.js'; -import { getRevision } from './revision-tracker.js'; +import { checkRevision, getRevision } from './revision-tracker.js'; import { resolveTextTarget, paginate, validatePaginationInput } from '../helpers/adapter-utils.js'; import { executeDomainCommand } from './plan-wrappers.js'; +import { getCachedProjectedTrackedChangeSnapshot, projectSnapshots } from './track-changes-wrappers.js'; import { buildCommentJsonFromText, extractCommentText, + type CommentEntityRecord, findCommentEntity, getCommentEntityStore, isCommentResolved, removeCommentEntityTree, + restoreStashedCommentEntityTree, toCommentInfo, upsertCommentEntity, } from '../helpers/comment-entity-store.js'; import { listCommentAnchors, resolveCommentAnchorsById } from '../helpers/comment-target-resolver.js'; import { normalizeExcerpt, toNonEmptyString } from '../helpers/value-utils.js'; +import { getTrackedChangeIndex } from '../tracked-changes/tracked-change-index.js'; +import type { TrackedChangeSnapshot } from '../tracked-changes/tracked-change-snapshot.js'; +import { resolveSelectionTarget } from '../helpers/selection-target-resolver.js'; +import { resolveTrackedChangeInStory } from '../helpers/tracked-change-resolver.js'; +import { BODY_STORY_KEY, buildStoryKey } from '../story-runtime/story-key.js'; // --------------------------------------------------------------------------- // Internal helpers @@ -61,6 +75,18 @@ type EditorUserIdentity = { image?: string; }; +type TrackedChangeCommentInfo = CommentInfo & { + story?: StoryLocator; + trackedChange?: boolean; + trackedChangeType?: TrackChangeType; + trackedChangeDisplayType?: string | null; + trackedChangeStory?: StoryLocator | null; + trackedChangeAnchorKey?: string; + trackedChangeText?: string; + deletedText?: string | null; + trackedChangeLink?: CommentTrackedChangeLink | null; +}; + function toCommentAddress(commentId: string): { kind: 'entity'; entityType: 'comment'; entityId: string } { return { kind: 'entity', @@ -75,13 +101,6 @@ function toNotFoundError(input: unknown): DocumentApiAdapterError { }); } -function isSameTarget( - left: { blockId: string; range: { start: number; end: number } }, - right: { blockId: string; range: { start: number; end: number } }, -): boolean { - return left.blockId === right.blockId && left.range.start === right.range.start && left.range.end === right.range.end; -} - /** * Check whether a payload carries a complete TextAddress. The * document-api input validator accepts a payload if it satisfies either @@ -128,17 +147,141 @@ function isTextTargetShape(target: unknown): target is TextTarget { } /** - * Normalize a TextAddress | TextTarget comment target into an array of + * Normalize the text-addressable comment target shapes into an array of * segments. For TextAddress, the result is a single-entry array. */ -function targetToSegments( - target: { kind: 'text'; blockId: string; range: { start: number; end: number } } | TextTarget, -): TextSegment[] | null { +function targetToSegments(target: CommentTarget): TextSegment[] | null { if (isTextAddressShape(target)) return [{ blockId: target.blockId, range: target.range }]; if (isTextTargetShape(target)) return [...target.segments]; return null; } +function isTrackedChangeCommentTargetShape( + target: unknown, +): target is { trackedChangeId: string; story?: StoryLocator } { + if (!target || typeof target !== 'object') return false; + const value = target as { kind?: unknown; trackedChangeId?: unknown }; + if (value.kind !== undefined && value.kind !== 'trackedChange') return false; + return typeof value.trackedChangeId === 'string' && value.trackedChangeId.length > 0; +} + +function buildTrackedChangeLink(snapshot: TrackedChangeSnapshot): CommentTrackedChangeLink { + const { trackedChangeText, deletedText } = trackedChangeTextFields(snapshot); + return { + trackedChange: true, + trackedChangeType: snapshot.type, + trackedChangeDisplayType: null, + trackedChangeStory: snapshot.story, + trackedChangeAnchorKey: snapshot.anchorKey, + trackedChangeText, + deletedText, + }; +} + +function getTrackedChangeThreadingId(snapshot: TrackedChangeSnapshot): string | null { + return snapshot.commandRawId ?? snapshot.runtimeRef.rawId ?? snapshot.address.entityId ?? null; +} + +function trackedChangeSnapshotAliases(snapshot: TrackedChangeSnapshot): string[] { + return Array.from( + new Set( + [ + snapshot.address.entityId, + snapshot.runtimeRef.rawId, + snapshot.commandRawId, + snapshot.replacementGroupId, + snapshot.replacementSideId, + snapshot.anchorKey, + ].filter((value): value is string => typeof value === 'string' && value.length > 0), + ), + ); +} + +function overlapsTrackedChangeRange(snapshot: TrackedChangeSnapshot, from: number, to: number): boolean { + if (from === to) { + return snapshot.range.from <= from && snapshot.range.to >= to; + } + return snapshot.range.from < to && snapshot.range.to > from; +} + +function trackedChangeTypePriority(type: TrackChangeType): number { + if (type === 'delete') return 0; + if (type === 'format') return 1; + return 2; +} + +function choosePreferredTrackedChangeSnapshot( + snapshots: ReadonlyArray, + preferredId?: string, +): TrackedChangeSnapshot | null { + if (snapshots.length === 0) return null; + + const preferred = preferredId ? String(preferredId) : null; + const ordered = [...snapshots].sort((left, right) => { + const leftMatchesPreferred = preferred + ? trackedChangeSnapshotAliases(left).some((alias) => alias === preferred) + : false; + const rightMatchesPreferred = preferred + ? trackedChangeSnapshotAliases(right).some((alias) => alias === preferred) + : false; + if (leftMatchesPreferred !== rightMatchesPreferred) { + return leftMatchesPreferred ? -1 : 1; + } + + const leftLength = Math.max(0, left.range.to - left.range.from); + const rightLength = Math.max(0, right.range.to - right.range.from); + if (leftLength !== rightLength) return leftLength - rightLength; + + const typeDelta = trackedChangeTypePriority(left.type) - trackedChangeTypePriority(right.type); + if (typeDelta !== 0) return typeDelta; + + if (left.range.from !== right.range.from) return left.range.from - right.range.from; + return left.address.entityId.localeCompare(right.address.entityId); + }); + + return ordered[0] ?? null; +} + +function inferTrackedChangeSnapshotForRange( + editor: Editor, + from: number, + to: number, + preferredId?: string, +): TrackedChangeSnapshot | null { + let snapshots: ReadonlyArray; + try { + snapshots = getTrackedChangeIndex(editor).getAll(); + } catch { + return null; + } + + const overlapping = snapshots.filter((snapshot) => overlapsTrackedChangeRange(snapshot, from, to)); + return choosePreferredTrackedChangeSnapshot(overlapping, preferredId); +} + +function assignTrackedChangeLink(info: TrackedChangeCommentInfo, link: CommentTrackedChangeLink | null): void { + if (!link) { + delete info.trackedChange; + delete info.trackedChangeType; + delete info.trackedChangeDisplayType; + delete info.trackedChangeStory; + delete info.trackedChangeAnchorKey; + delete info.trackedChangeText; + delete info.deletedText; + info.trackedChangeLink = null; + return; + } + + info.trackedChange = true; + info.trackedChangeType = link.trackedChangeType; + info.trackedChangeDisplayType = link.trackedChangeDisplayType ?? undefined; + info.trackedChangeStory = link.trackedChangeStory ?? undefined; + info.trackedChangeAnchorKey = link.trackedChangeAnchorKey ?? undefined; + info.trackedChangeText = link.trackedChangeText ?? undefined; + info.deletedText = link.deletedText ?? undefined; + info.trackedChangeLink = link; +} + function listCommentAnchorsSafe(editor: Editor): ReturnType { try { return listCommentAnchors(editor); @@ -170,6 +313,16 @@ function emitCommentLifecycleUpdate( emitter.call(editor, 'commentsUpdate', { type, comment }); } +function emitCommentAdd(editor: Editor, comment: Record, activeCommentId?: string): void { + const emitter = (editor as unknown as { emit?: (event: string, payload: unknown) => void }).emit; + if (typeof emitter !== 'function') return; + emitter.call(editor, 'commentsUpdate', { + type: 'add', + comment, + ...(activeCommentId ? { activeCommentId } : {}), + }); +} + function applyTextSelection(editor: Editor, from: number, to: number): boolean { const setTextSelection = editor.commands?.setTextSelection; if (typeof setTextSelection === 'function') { @@ -251,6 +404,8 @@ type CanonicalAnchor = { end: number; }; +type CanonicalAnchorMap = Map; + /** * Merges same-block adjacent/overlapping anchors into canonical segments. * @@ -348,8 +503,9 @@ function mergeAnchorData( editor: Editor, infosById: Map, anchors: ReturnType, -): void { +): CanonicalAnchorMap { const grouped = new Map(); + const canonicalByCommentId: CanonicalAnchorMap = new Map(); for (const anchor of anchors) { const group = grouped.get(anchor.commentId) ?? []; group.push(anchor); @@ -361,6 +517,7 @@ function mergeAnchorData( const firstAnchor = sorted[0]; const status = sorted.every((anchor) => anchor.status === 'resolved') ? 'resolved' : 'open'; const canonical = canonicalizeAnchors(sorted); + canonicalByCommentId.set(commentId, canonical); const target = buildTextTarget(canonical); const anchoredText = buildAnchoredText(editor, canonical); const existing = infosById.get(commentId); @@ -391,11 +548,98 @@ function mergeAnchorData( ), ); } + + return canonicalByCommentId; +} + +function parseCreatedTime(value: string | undefined): number | undefined { + if (!value) return undefined; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function trackedChangeTextFields( + snapshot: TrackedChangeSnapshot, +): Pick { + const excerpt = snapshot.excerpt ?? ''; + if (snapshot.type === 'delete') { + return { trackedChangeText: '', deletedText: excerpt }; + } + return { trackedChangeText: excerpt, deletedText: null }; } -function buildCommentInfos(editor: Editor): CommentInfo[] { +function toTrackedChangeCommentInfo(snapshot: TrackedChangeSnapshot): TrackedChangeCommentInfo | null { + const commentId = toNonEmptyString(snapshot.address.entityId); + if (!commentId) return null; + + const { trackedChangeText, deletedText } = trackedChangeTextFields(snapshot); + const trackedChangeLink = buildTrackedChangeLink(snapshot); + + return { + address: toCommentAddress(commentId), + commentId, + text: trackedChangeText || deletedText || undefined, + status: 'open', + creatorName: snapshot.author, + creatorEmail: snapshot.authorEmail, + createdTime: parseCreatedTime(snapshot.date), + anchoredText: snapshot.excerpt, + story: snapshot.story, + trackedChange: true, + trackedChangeType: snapshot.type, + trackedChangeStory: snapshot.story, + trackedChangeAnchorKey: snapshot.anchorKey, + trackedChangeText, + deletedText, + trackedChangeLink, + }; +} + +function mergeTrackedChangeCommentInfos(editor: Editor, infosById: Map): void { + let trackedChanges: ReadonlyArray; + try { + trackedChanges = getTrackedChangeIndex(editor).getAll(); + } catch { + return; + } + + for (const snapshot of trackedChanges) { + const trackedChangeComment = toTrackedChangeCommentInfo(snapshot); + if (!trackedChangeComment) continue; + + const existing = infosById.get(trackedChangeComment.commentId); + if (!existing) { + if (snapshot.wordRevisionIds) continue; + infosById.set(trackedChangeComment.commentId, trackedChangeComment); + continue; + } + + Object.assign(existing, { + story: trackedChangeComment.story, + trackedChange: true, + trackedChangeType: trackedChangeComment.trackedChangeType, + trackedChangeStory: trackedChangeComment.trackedChangeStory, + trackedChangeAnchorKey: trackedChangeComment.trackedChangeAnchorKey, + trackedChangeText: trackedChangeComment.trackedChangeText, + deletedText: trackedChangeComment.deletedText, + anchoredText: existing.anchoredText ?? trackedChangeComment.anchoredText, + creatorName: existing.creatorName ?? trackedChangeComment.creatorName, + creatorEmail: existing.creatorEmail ?? trackedChangeComment.creatorEmail, + createdTime: existing.createdTime ?? trackedChangeComment.createdTime, + trackedChangeLink: trackedChangeComment.trackedChangeLink, + }); + } +} + +function buildCommentInfos(editor: Editor): TrackedChangeCommentInfo[] { + const anchors = listCommentAnchorsSafe(editor); + const anchoredCommentIds = Array.from(new Set(anchors.map((anchor) => anchor.commentId))); + for (const commentId of anchoredCommentIds) { + restoreStashedCommentEntityTree(editor, commentId); + } + const store = getCommentEntityStore(editor); - const infosById = new Map(); + const infosById = new Map(); for (const entry of store) { const commentId = toNonEmptyString(entry.commentId) ?? toNonEmptyString(entry.importedId) ?? null; @@ -403,12 +647,25 @@ function buildCommentInfos(editor: Editor): CommentInfo[] { infosById.set(commentId, toCommentInfo({ ...entry, commentId })); } - mergeAnchorData(editor, infosById, listCommentAnchorsSafe(editor)); + const canonicalByCommentId = mergeAnchorData(editor, infosById, anchors); + mergeTrackedChangeCommentInfos(editor, infosById); + + for (const [commentId, canonical] of canonicalByCommentId.entries()) { + const info = infosById.get(commentId); + if (!info || canonical.length === 0) continue; + + const from = canonical[0]?.pos ?? 0; + const to = canonical[canonical.length - 1]?.end ?? from; + const preferredTrackedChangeId = toNonEmptyString(findCommentEntity(store, commentId)?.trackedChangeParentId); + const snapshot = inferTrackedChangeSnapshotForRange(editor, from, to, preferredTrackedChangeId); + assignTrackedChangeLink(info, snapshot ? buildTrackedChangeLink(snapshot) : null); + } // Inherit target + anchoredText from nearest anchored ancestor for replies. // Walks up the parent chain so deep threads resolve regardless of iteration order. for (const info of infosById.values()) { - if ((info.target != null && info.anchoredText != null) || !info.parentCommentId) continue; + if ((info.target != null && info.anchoredText != null && info.trackedChangeLink != null) || !info.parentCommentId) + continue; const visited = new Set(); let cursor: CommentInfo | undefined = info; while (cursor?.parentCommentId && !visited.has(cursor.parentCommentId)) { @@ -417,6 +674,9 @@ function buildCommentInfos(editor: Editor): CommentInfo[] { if (ancestor?.target != null) { if (info.target == null) info.target = ancestor.target; if (info.anchoredText == null && ancestor.anchoredText != null) info.anchoredText = ancestor.anchoredText; + if (info.trackedChangeLink == null && ancestor.trackedChangeLink != null) { + assignTrackedChangeLink(info, ancestor.trackedChangeLink); + } break; } cursor = ancestor; @@ -439,50 +699,254 @@ function buildCommentInfos(editor: Editor): CommentInfo[] { return infos; } -// --------------------------------------------------------------------------- -// Mutation handlers -// --------------------------------------------------------------------------- +type ResolvedCommentTarget = { + from: number; + to: number; + trackedChangeSnapshot: TrackedChangeSnapshot | null; +}; -function addCommentHandler(editor: Editor, input: AddCommentInput, options?: RevisionGuardOptions): Receipt { - requireEditorCommand(editor.commands?.addComment, 'comments.create (addComment)'); +type CommentTargetResolution = + | { ok: true; value: ResolvedCommentTarget } + | { ok: false; failure: Extract['failure'] }; - // The target can be either a single-block TextAddress or a multi-segment - // TextTarget. For a TextTarget, resolve each segment and require they - // cover a contiguous PM range in document order — out-of-order or - // disjoint segments would otherwise silently anchor the comment over - // intervening text the caller never selected. - const target = input.target; - if (!target) { +function buildTrackedChangeEntityFields(snapshot: TrackedChangeSnapshot | null): Record { + if (!snapshot) { return { - success: false, - failure: { - code: 'INVALID_TARGET', - message: 'Comment target is required.', + trackedChange: false, + trackedChangeParentId: null, + trackedChangeType: null, + trackedChangeDisplayType: null, + trackedChangeStory: null, + trackedChangeStoryKind: null, + trackedChangeStoryLabel: null, + trackedChangeAnchorKey: null, + trackedChangeText: null, + deletedText: null, + }; + } + + const link = buildTrackedChangeLink(snapshot); + return { + trackedChange: true, + trackedChangeParentId: getTrackedChangeThreadingId(snapshot), + trackedChangeType: link.trackedChangeType ?? null, + trackedChangeDisplayType: link.trackedChangeDisplayType ?? null, + trackedChangeStory: link.trackedChangeStory ?? null, + trackedChangeStoryKind: snapshot.storyKind, + trackedChangeStoryLabel: snapshot.storyLabel, + trackedChangeAnchorKey: link.trackedChangeAnchorKey ?? null, + trackedChangeText: link.trackedChangeText ?? null, + deletedText: link.deletedText ?? null, + }; +} + +function hasTrackedChangeEntityFields(record: CommentEntityRecord | undefined): boolean { + return Boolean( + record && + (record.trackedChange === true || + record.trackedChangeParentId != null || + record.trackedChangeType != null || + record.trackedChangeAnchorKey != null || + record.trackedChangeText != null || + record.deletedText != null), + ); +} + +function buildTrackedChangeEntityFieldsFromRecord( + record: CommentEntityRecord | undefined, +): Record | null { + if (!hasTrackedChangeEntityFields(record)) return null; + return { + trackedChange: record?.trackedChange === true, + trackedChangeParentId: record?.trackedChangeParentId ?? null, + trackedChangeType: record?.trackedChangeType ?? null, + trackedChangeDisplayType: record?.trackedChangeDisplayType ?? null, + trackedChangeStory: record?.trackedChangeStory ?? null, + trackedChangeStoryKind: record?.trackedChangeStoryKind ?? null, + trackedChangeStoryLabel: record?.trackedChangeStoryLabel ?? null, + trackedChangeAnchorKey: record?.trackedChangeAnchorKey ?? null, + trackedChangeText: record?.trackedChangeText ?? null, + deletedText: record?.deletedText ?? null, + }; +} + +function buildCommentLifecyclePayload(record: CommentEntityRecord): Record { + const payload: Record = { + commentId: record.commentId, + }; + + if (record.importedId !== undefined) payload.importedId = record.importedId; + if (record.parentCommentId !== undefined) payload.parentCommentId = record.parentCommentId; + if (record.commentText !== undefined) { + payload.commentText = record.commentText; + payload.text = record.commentText; + } + if (record.commentJSON !== undefined) payload.commentJSON = record.commentJSON; + if (record.creatorName !== undefined) payload.creatorName = record.creatorName; + if (record.creatorEmail !== undefined) payload.creatorEmail = record.creatorEmail; + if (record.creatorImage !== undefined) payload.creatorImage = record.creatorImage; + if (record.createdTime !== undefined) payload.createdTime = record.createdTime; + if (record.isInternal !== undefined) payload.isInternal = record.isInternal; + if (record.isDone !== undefined) payload.isDone = record.isDone; + if (record.resolvedTime !== undefined) payload.resolvedTime = record.resolvedTime; + if (record.fileId !== undefined) payload.fileId = record.fileId; + if (record.documentId !== undefined) payload.documentId = record.documentId; + if (record.trackedChange !== undefined) payload.trackedChange = record.trackedChange; + if (record.trackedChangeParentId !== undefined) payload.trackedChangeParentId = record.trackedChangeParentId; + if (record.trackedChangeType !== undefined) payload.trackedChangeType = record.trackedChangeType; + if (record.trackedChangeDisplayType !== undefined) payload.trackedChangeDisplayType = record.trackedChangeDisplayType; + if (record.trackedChangeStory !== undefined) payload.trackedChangeStory = record.trackedChangeStory; + if (record.trackedChangeStoryKind !== undefined) payload.trackedChangeStoryKind = record.trackedChangeStoryKind; + if (record.trackedChangeStoryLabel !== undefined) payload.trackedChangeStoryLabel = record.trackedChangeStoryLabel; + if (record.trackedChangeAnchorKey !== undefined) payload.trackedChangeAnchorKey = record.trackedChangeAnchorKey; + if (record.trackedChangeText !== undefined) payload.trackedChangeText = record.trackedChangeText; + if (record.deletedText !== undefined) payload.deletedText = record.deletedText; + + return payload; +} + +function findTrackedChangeSnapshotByAlias(editor: Editor, alias: string): TrackedChangeSnapshot | null { + try { + const index = getTrackedChangeIndex(editor); + const bodyStory: StoryLocator = { kind: 'story', storyType: 'body' }; + const bodySnapshots = + typeof index.get === 'function' ? Array.from(index.get(bodyStory)) : ([] as TrackedChangeSnapshot[]); + const candidateSets = + bodySnapshots.length > 0 ? [bodySnapshots, Array.from(index.getAll())] : [Array.from(index.getAll())]; + + for (const snapshots of candidateSets) { + const matching = snapshots.filter((snapshot) => + trackedChangeSnapshotAliases(snapshot).some((value) => value === alias), + ); + const directMatch = choosePreferredTrackedChangeSnapshot(matching, alias); + if (directMatch) return directMatch; + + const projectedMatch = projectSnapshots(snapshots).find((row) => row.info.id === alias); + if (projectedMatch) return projectedMatch.snapshot; + } + + return getCachedProjectedTrackedChangeSnapshot(editor, alias); + } catch { + return null; + } +} + +function resolveCommentTrackedChangeSnapshot(editor: Editor, commentId: string): TrackedChangeSnapshot | null { + const store = getCommentEntityStore(editor); + const record = findCommentEntity(store, commentId); + const preferredId = toNonEmptyString(record?.trackedChangeParentId); + if (preferredId) { + const direct = findTrackedChangeSnapshotByAlias(editor, preferredId); + if (direct) return direct; + } + + const info = buildCommentInfos(editor).find( + (candidate) => candidate.commentId === commentId || candidate.importedId === commentId, + ); + if (!info?.target) return null; + + const resolved = resolveCommentTarget(editor, info.target); + if (!resolved.ok) return null; + return resolved.value.trackedChangeSnapshot; +} + +function resolveCommentTarget(editor: Editor, target: CommentTarget): CommentTargetResolution { + if (isTrackedChangeCommentTargetShape(target)) { + const resolved = resolveTrackedChangeInStory(editor, { + kind: 'entity', + entityType: 'trackedChange', + entityId: target.trackedChangeId, + ...(target.story ? { story: target.story } : {}), + }); + const indexedSnapshot = resolved ? null : findTrackedChangeSnapshotByAlias(editor, target.trackedChangeId); + if (!resolved && !indexedSnapshot) { + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', 'Comment target could not be resolved.', { + target, + }); + } + if (resolved && resolved.editor !== editor) { + return { + ok: false, + failure: { + code: 'INVALID_TARGET', + message: 'Comment tracked-change targets outside the active story are not supported on this editor handle.', + details: { target }, + }, + }; + } + if (indexedSnapshot && buildStoryKey(indexedSnapshot.story) !== BODY_STORY_KEY) { + return { + ok: false, + failure: { + code: 'INVALID_TARGET', + message: 'Comment tracked-change targets outside the active story are not supported on this editor handle.', + details: { target }, + }, + }; + } + + const from = resolved?.change.from ?? indexedSnapshot!.range.from; + const to = resolved?.change.to ?? indexedSnapshot!.range.to; + if (from === to) { + return { + ok: false, + failure: { + code: 'INVALID_TARGET', + message: 'Comment target range must be non-collapsed.', + details: { target }, + }, + }; + } + const trackedChangeSnapshot = + indexedSnapshot ?? inferTrackedChangeSnapshotForRange(editor, from, to, target.trackedChangeId); + return { + ok: true, + value: { + from, + to, + trackedChangeSnapshot, }, }; } + + if ((target as SelectionTarget | undefined)?.kind === 'selection') { + const resolved = resolveSelectionTarget(editor, target as SelectionTarget); + if (resolved.absFrom === resolved.absTo) { + return { + ok: false, + failure: { + code: 'INVALID_TARGET', + message: 'Comment target range must be non-collapsed.', + details: { target }, + }, + }; + } + return { + ok: true, + value: { + from: resolved.absFrom, + to: resolved.absTo, + trackedChangeSnapshot: inferTrackedChangeSnapshotForRange(editor, resolved.absFrom, resolved.absTo), + }, + }; + } + const segments = targetToSegments(target); if (!segments) { return { - success: false, + ok: false, failure: { code: 'INVALID_TARGET', - message: 'Comment target must be a TextAddress or TextTarget.', + message: 'Comment target must be a TextAddress, TextTarget, SelectionTarget, or tracked-change target.', details: { target }, }, }; } - // Per-segment collapse check. Without this, two collapsed segments in - // different blocks (e.g. caret at end of p1 and caret at start of p2) - // pass the order + contiguity checks AND the spanning-range collapse - // check (because firstResolved.from < lastResolved.to across the block - // boundary), then silently anchor a comment over intervening content. - // Each individual segment must represent a non-empty range. for (const seg of segments) { if (seg.range.start === seg.range.end) { return { - success: false, + ok: false, failure: { code: 'INVALID_TARGET', message: 'Comment target range must be non-collapsed.', @@ -507,7 +971,7 @@ function addCommentHandler(editor: Editor, input: AddCommentInput, options?: Rev const curr = resolvedSegments[i]!; if (prev.to > curr.from) { return { - success: false, + ok: false, failure: { code: 'INVALID_TARGET', message: 'Comment target segments must be in document order.', @@ -515,19 +979,10 @@ function addCommentHandler(editor: Editor, input: AddCommentInput, options?: Rev }, }; } - // Detect content the caller didn't select sitting between segments. - // `textBetween(prev.to, curr.from, '')` returns: - // - '' for true adjacency (same block) or pure block boundaries - // (a legitimate multi-block selection between adjacent blocks); - // - '' if any text node sits in the gap. - // The `leafText` 4th argument lets us also surface inline atoms - // (images, math, etc) that PM otherwise omits from `textBetween`. - // We pass a sentinel for atoms only — keeping `blockSeparator: ''` - // so legitimate cross-block adjacency still produces an empty gap. const gap = docForGap ? docForGap.textBetween(prev.to, curr.from, '', () => '\u0001') : ''; if (gap.length > 0) { return { - success: false, + ok: false, failure: { code: 'INVALID_TARGET', message: @@ -540,18 +995,55 @@ function addCommentHandler(editor: Editor, input: AddCommentInput, options?: Rev const firstResolved = resolvedSegments[0]!; const lastResolved = resolvedSegments[resolvedSegments.length - 1]!; - const resolved = { from: firstResolved.from, to: lastResolved.to }; - if (resolved.from === resolved.to) { + if (firstResolved.from === lastResolved.to) { + return { + ok: false, + failure: { code: 'INVALID_TARGET', message: 'Comment target range must be non-collapsed.', details: { target } }, + }; + } + + return { + ok: true, + value: { + from: firstResolved.from, + to: lastResolved.to, + trackedChangeSnapshot: inferTrackedChangeSnapshotForRange(editor, firstResolved.from, lastResolved.to), + }, + }; +} + +// --------------------------------------------------------------------------- +// Mutation handlers +// --------------------------------------------------------------------------- + +function addCommentHandler( + editor: Editor, + input: AddCommentInput, + options?: RevisionGuardOptions, +): CommentsCreateReceipt { + // The target can be either a single-block TextAddress or a multi-segment + // TextTarget. For a TextTarget, resolve each segment and require they + // cover a contiguous PM range in document order — out-of-order or + // disjoint segments would otherwise silently anchor the comment over + // intervening text the caller never selected. + const target = input.target; + if (!target) { return { success: false, failure: { code: 'INVALID_TARGET', - message: 'Comment target range must be non-collapsed.', + message: 'Comment target is required.', }, }; } + const resolvedTarget = resolveCommentTarget(editor, target); + if (resolvedTarget.ok === false) { + return { success: false, failure: resolvedTarget.failure }; + } - if (!applyTextSelection(editor, resolved.from, resolved.to)) { + requireEditorCommand(editor.commands?.addComment, 'comments.create (addComment)'); + + if (!applyTextSelection(editor, resolvedTarget.value.from, resolvedTarget.value.to)) { return { success: false, failure: { @@ -563,6 +1055,7 @@ function addCommentHandler(editor: Editor, input: AddCommentInput, options?: Rev } const commentId = uuidv4(); + let trackedPayload: Record | null = null; const receipt = executeDomainCommand( editor, @@ -587,7 +1080,12 @@ function addCommentHandler(editor: Editor, input: AddCommentInput, options?: Rev isInternal: false, fileId: editor.options?.documentId, documentId: editor.options?.documentId, + ...buildTrackedChangeEntityFields(resolvedTarget.value.trackedChangeSnapshot), }); + const stored = findCommentEntity(store, commentId); + if (stored) { + trackedPayload = buildCommentLifecyclePayload(stored); + } } return didInsert; }, @@ -601,7 +1099,11 @@ function addCommentHandler(editor: Editor, input: AddCommentInput, options?: Rev }; } - return { success: true, inserted: [toCommentAddress(commentId)] }; + if (trackedPayload && resolvedTarget.value.trackedChangeSnapshot) { + emitCommentLifecycleUpdate(editor, 'update', trackedPayload); + } + + return { success: true, id: commentId, inserted: [toCommentAddress(commentId)] }; } function editCommentHandler(editor: Editor, input: EditCommentInput, options?: RevisionGuardOptions): Receipt { @@ -648,7 +1150,11 @@ function editCommentHandler(editor: Editor, input: EditCommentInput, options?: R return { success: true, updated: [toCommentAddress(identity.commentId)] }; } -function replyToCommentHandler(editor: Editor, input: ReplyToCommentInput, options?: RevisionGuardOptions): Receipt { +function replyToCommentHandler( + editor: Editor, + input: ReplyToCommentInput, + options?: RevisionGuardOptions, +): CommentsCreateReceipt { const addCommentReply = requireEditorCommand(editor.commands?.addCommentReply, 'comments.create (addCommentReply)'); if (!input.parentCommentId) { @@ -662,7 +1168,13 @@ function replyToCommentHandler(editor: Editor, input: ReplyToCommentInput, optio } const parentIdentity = resolveCommentIdentity(editor, input.parentCommentId); + const store = getCommentEntityStore(editor); + const parentRecord = findCommentEntity(store, parentIdentity.commentId); + const inheritedTrackedFields = + buildTrackedChangeEntityFieldsFromRecord(parentRecord) ?? + buildTrackedChangeEntityFields(resolveCommentTrackedChangeSnapshot(editor, parentIdentity.commentId)); const replyId = uuidv4(); + let trackedPayload: Record | null = null; const receipt = executeDomainCommand( editor, @@ -675,7 +1187,6 @@ function replyToCommentHandler(editor: Editor, input: ReplyToCommentInput, optio if (didReply) { const now = Date.now(); const user = (editor.options?.user ?? {}) as EditorUserIdentity; - const store = getCommentEntityStore(editor); upsertCommentEntity(store, replyId, { commentId: replyId, parentCommentId: parentIdentity.commentId, @@ -689,7 +1200,12 @@ function replyToCommentHandler(editor: Editor, input: ReplyToCommentInput, optio isInternal: false, fileId: editor.options?.documentId, documentId: editor.options?.documentId, + ...(inheritedTrackedFields ?? {}), }); + const stored = findCommentEntity(store, replyId); + if (stored) { + trackedPayload = buildCommentLifecyclePayload(stored); + } } return Boolean(didReply); }, @@ -703,30 +1219,22 @@ function replyToCommentHandler(editor: Editor, input: ReplyToCommentInput, optio }; } - return { success: true, inserted: [toCommentAddress(replyId)] }; + if (trackedPayload && inheritedTrackedFields) { + emitCommentLifecycleUpdate(editor, 'update', trackedPayload); + } + + return { success: true, id: replyId, inserted: [toCommentAddress(replyId)] }; } function moveCommentHandler(editor: Editor, input: MoveCommentInput, options?: RevisionGuardOptions): Receipt { const moveComment = requireEditorCommand(editor.commands?.moveComment, 'comments.patch (moveComment)'); - if (input.target.range.start === input.target.range.end) { - return { - success: false, - failure: { code: 'INVALID_TARGET', message: 'Comment target range must be non-collapsed.' }, - }; - } - - const resolved = resolveTextTarget(editor, input.target); - if (!resolved) { - throw toNotFoundError(input.target); - } - if (resolved.from === resolved.to) { - return { - success: false, - failure: { code: 'INVALID_TARGET', message: 'Comment target range must be non-collapsed.' }, - }; + const resolvedTarget = resolveCommentTarget(editor, input.target); + if (resolvedTarget.ok === false) { + return { success: false, failure: resolvedTarget.failure }; } + const store = getCommentEntityStore(editor); const identity = resolveCommentIdentity(editor, input.commentId); if (!identity.anchors.length) { return { @@ -745,17 +1253,37 @@ function moveCommentHandler(editor: Editor, input: MoveCommentInput, options?: R }; } - const currentTarget = identity.anchors[0]?.target; - if (currentTarget && isSameTarget(currentTarget, input.target)) { + const currentAnchor = identity.anchors[0]; + if ( + currentAnchor && + currentAnchor.pos === resolvedTarget.value.from && + currentAnchor.end === resolvedTarget.value.to + ) { return { success: false, failure: { code: 'NO_OP', message: 'Comment move produced no change.' }, }; } + let trackedPayload: Record | null = null; const receipt = executeDomainCommand( editor, - () => Boolean(moveComment({ commentId: identity.commentId, from: resolved.from, to: resolved.to })), + () => { + const didMove = Boolean( + moveComment({ commentId: identity.commentId, from: resolvedTarget.value.from, to: resolvedTarget.value.to }), + ); + if (didMove) { + upsertCommentEntity(store, identity.commentId, { + importedId: identity.importedId, + ...buildTrackedChangeEntityFields(resolvedTarget.value.trackedChangeSnapshot), + }); + const stored = findCommentEntity(store, identity.commentId); + if (stored) { + trackedPayload = buildCommentLifecyclePayload(stored); + } + } + return didMove; + }, { expectedRevision: options?.expectedRevision }, ); @@ -766,6 +1294,10 @@ function moveCommentHandler(editor: Editor, input: MoveCommentInput, options?: R }; } + if (trackedPayload) { + emitCommentLifecycleUpdate(editor, 'update', trackedPayload); + } + return { success: true, updated: [toCommentAddress(identity.commentId)] }; } @@ -1105,6 +1637,15 @@ function listCommentsHandler(editor: Editor, query?: CommentsListQuery): Comment creatorName, creatorEmail, address, + story, + trackedChange, + trackedChangeType, + trackedChangeDisplayType, + trackedChangeStory, + trackedChangeAnchorKey, + trackedChangeText, + deletedText, + trackedChangeLink, } = comment; return buildDiscoveryItem(comment.commentId, handle, { address, @@ -1118,6 +1659,15 @@ function listCommentsHandler(editor: Editor, query?: CommentsListQuery): Comment createdTime, creatorName, creatorEmail, + story, + trackedChange, + trackedChangeType, + trackedChangeDisplayType, + trackedChangeStory, + trackedChangeAnchorKey, + trackedChangeText, + deletedText, + trackedChangeLink, }); }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts index 31118612e3..4eaee5b0d0 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts @@ -60,6 +60,11 @@ import type { Transaction } from 'prosemirror-state'; import type { Mapping } from 'prosemirror-transform'; import { buildTextWithTabs, parentAllowsNodeAt, textBetweenWithTabs } from '../helpers/text-with-tabs.js'; import { getFormattingStateAtPos } from '../../core/helpers/getMarksFromSelection.js'; +import { + TrackDeleteMarkName, + TrackFormatMarkName, + TrackInsertMarkName, +} from '../../extensions/track-changes/constants.js'; // --------------------------------------------------------------------------- // Character-offset → document-position mapping @@ -108,6 +113,7 @@ export function charOffsetToDocPos( const textStart = Math.max(pos, rangeFrom); const textEnd = Math.min(pos + node.nodeSize, rangeTo); const textLen = textEnd - textStart; + if (textLen <= 0) return false; if (count + textLen >= charOffset) { foundPos = textStart + (charOffset - count); } @@ -178,6 +184,33 @@ function asProseMirrorMarks(marks: readonly unknown[]): readonly ProseMirrorMark return marks as readonly ProseMirrorMark[]; } +const TRACKED_REVIEW_MARK_NAMES = new Set([TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName]); + +function hasTrackedReviewMark(marks: readonly ProseMirrorMark[] | undefined): boolean { + return Boolean(marks?.some((mark) => TRACKED_REVIEW_MARK_NAMES.has(mark.type.name))); +} + +function rangeTouchesTrackedReviewState(doc: ProseMirrorNode, from: number, to: number): boolean { + let found = false; + + doc.nodesBetween(from, to, (node, pos) => { + if (found) return false; + + if (node.isText) { + const textStart = Math.max(from, pos); + const textEnd = Math.min(to, pos + node.nodeSize); + if (textStart >= textEnd) return false; + } + + if ((node.isText || node.isInline) && hasTrackedReviewMark(node.marks as readonly ProseMirrorMark[] | undefined)) { + found = true; + return false; + } + }); + + return found; +} + function resolveMarksForRange(editor: Editor, target: CompiledRangeTarget, step: MutationStep): readonly unknown[] { if (step.op !== 'text.rewrite') return []; const rewriteStep = step as TextRewriteStep; @@ -897,6 +930,16 @@ export function executeTextRewrite( const origLen = originalText.length; const replLen = replacementText.length; + if (rangeTouchesTrackedReviewState(tr.doc, absFrom, absTo)) { + if (replacementText.length === 0) { + tr.delete(absFrom, absTo); + return { changed: target.text.length > 0 }; + } + const content = buildTextWithTabs(editor.state.schema, replacementText, asProseMirrorMarks(marks)); + tr.replaceWith(absFrom, absTo, content); + return { changed: replacementText !== target.text }; + } + let prefix = 0; while (prefix < origLen && prefix < replLen && originalText[prefix] === replacementText[prefix]) { prefix++; diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts index 7e684e500f..7c4fc2a917 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Editor } from '../../core/Editor.js'; -import type { StoryLocator } from '@superdoc/document-api'; +import { COMMAND_CATALOG, type StoryLocator } from '@superdoc/document-api'; const mocks = vi.hoisted(() => ({ checkRevision: vi.fn(), @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ resolveTrackedChangeInStory: vi.fn(), getTrackedChangeIndex: vi.fn(), resolveStoryRuntime: vi.fn(), + resolveCommentAnchorsById: vi.fn(), })); vi.mock('./revision-tracker.js', () => ({ @@ -33,17 +34,103 @@ vi.mock('../story-runtime/resolve-story-runtime.js', () => ({ resolveStoryRuntime: mocks.resolveStoryRuntime, })); -import { trackChangesAcceptAllWrapper, trackChangesAcceptWrapper } from './track-changes-wrappers.js'; +vi.mock('../helpers/comment-target-resolver.js', () => ({ + resolveCommentAnchorsById: mocks.resolveCommentAnchorsById, +})); + +import { + trackChangesAcceptAllWrapper, + trackChangesAcceptWrapper, + trackChangesDecideRangeWrapper, + trackChangesGetWrapper, + trackChangesListWrapper, + trackChangesRejectWrapper, + getCachedProjectedTrackedChangeSnapshot, +} from './track-changes-wrappers.js'; const footnoteStory: StoryLocator = { kind: 'story', storyType: 'footnote', noteId: '5' }; -function makeEditor(commands: Record = {}): Editor { +function expectTrackChangesDecideReceiptCodeDeclared(code: string): void { + expect(COMMAND_CATALOG['trackChanges.decide'].possibleFailureCodes).toContain(code); +} + +function makeEditor( + commands: Record = {}, + options: Record = { trackedChanges: {} }, +): Editor { return { commands, + options, state: { doc: { textBetween: vi.fn(() => '') } }, } as unknown as Editor; } +function makeTextNode(text: string) { + return { + type: { name: 'text' }, + attrs: {}, + text, + nodeSize: text.length, + isText: true, + isLeaf: false, + isBlock: false, + childCount: 0, + child: () => { + throw new Error('text nodes do not have children'); + }, + }; +} + +function makeInlineWrapper(child: any) { + return { + type: { name: 'run' }, + attrs: {}, + nodeSize: child.nodeSize + 2, + isText: false, + isLeaf: false, + isBlock: false, + childCount: 1, + child: (index: number) => { + if (index !== 0) throw new Error('run child out of range'); + return child; + }, + }; +} + +function makeParagraphNode(attrs: Record, child: any = makeTextNode('abcdef')) { + return { + type: { name: 'paragraph' }, + attrs, + nodeSize: child.nodeSize + 2, + isText: false, + isLeaf: false, + isBlock: true, + childCount: 1, + child: (index: number) => { + if (index !== 0) throw new Error('paragraph child out of range'); + return child; + }, + }; +} + +function makeRangeDecisionEditor( + commands: Record, + block = makeParagraphNode({ sdBlockId: 'p1' }), + blockPos = 5, +): Editor { + return { + options: { trackedChanges: {} }, + commands, + state: { + doc: { + descendants: (fn: (node: unknown, pos: number) => void | boolean) => { + fn(block, blockPos); + }, + }, + }, + } as unknown as Editor; +} + beforeEach(() => { vi.clearAllMocks(); mocks.getRevision.mockReturnValue('0'); @@ -56,9 +143,47 @@ beforeEach(() => { subscribe: vi.fn(), dispose: vi.fn(), }); + mocks.resolveCommentAnchorsById.mockReturnValue([{ commentId: 'comment-1' }]); }); describe('track-changes-wrappers revision guard', () => { + it('surfaces tracked-change provenance fields on list results', () => { + const hostEditor = makeEditor(); + mocks.getTrackedChangeIndex.mockReturnValue({ + get: vi.fn(() => [ + { + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'canon-1' }, + runtimeRef: { storyKey: 'body', rawId: 'raw-1' }, + story: { kind: 'story', storyType: 'body' }, + type: 'insert', + excerpt: 'new text', + origin: 'google-docs', + imported: true, + storyLabel: 'Body', + storyKind: 'body', + anchorKey: 'tc::body::raw-1', + hasInsert: true, + hasDelete: false, + hasFormat: false, + range: { from: 1, to: 9 }, + }, + ]), + getAll: vi.fn(() => []), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(), + dispose: vi.fn(), + }); + + const result = trackChangesListWrapper(hostEditor, {}); + + expect(result.items[0]).toMatchObject({ + id: 'canon-1', + origin: 'google-docs', + imported: true, + }); + }); + it('checks expectedRevision on the host editor before accepting a non-body tracked change', () => { const hostEditor = makeEditor(); const storyEditor = makeEditor({ acceptTrackedChangeById: vi.fn(() => true) }); @@ -100,6 +225,194 @@ describe('track-changes-wrappers revision guard', () => { expect(index.invalidate).toHaveBeenCalledWith(footnoteStory); }); + it('preserves typed overlap decision failures for by-id document-api calls', () => { + const hostEditor = makeEditor(); + const storyEditor = { + ...makeEditor({ acceptTrackedChangeById: vi.fn(() => false) }), + storage: { + trackChanges: { + lastDecisionFailure: { + code: 'PERMISSION_DENIED', + message: 'permission denied for accept of change "canon-1".', + details: { changeId: 'canon-1' }, + }, + }, + }, + } as unknown as Editor; + + mocks.resolveTrackedChangeInStory.mockReturnValue({ + editor: storyEditor, + story: footnoteStory, + runtimeRef: { storyKey: 'fn:5', rawId: 'raw-1' }, + change: { + id: 'canon-1', + rawId: 'raw-1', + from: 1, + to: 2, + attrs: {}, + }, + }); + mocks.executeDomainCommand.mockReturnValue({ steps: [{ effect: 'unchanged' }] }); + + const receipt = trackChangesAcceptWrapper(hostEditor, { id: 'canon-1', story: footnoteStory }); + + expect(receipt).toEqual({ + success: false, + failure: { + code: 'PERMISSION_DENIED', + message: 'permission denied for accept of change "canon-1".', + details: { changeId: 'canon-1' }, + }, + }); + }); + + it('removes deleted tracked-change-linked comments from the host store after a successful decision', () => { + const hostEditor = { + ...makeEditor(), + converter: { + comments: [ + { commentId: 'comment-1', trackedChange: true, trackedChangeParentId: 'canon-1' }, + { commentId: 'reply-1', parentCommentId: 'comment-1' }, + ], + }, + } as unknown as Editor; + const storyEditor = { + ...makeEditor({ rejectTrackedChangeById: vi.fn(() => true) }), + storage: { + trackChanges: { + lastDecisionFailure: null, + lastDecisionReceipt: { + deletedComments: [{ id: 'comment-1' }], + }, + }, + }, + } as unknown as Editor; + + mocks.resolveTrackedChangeInStory.mockReturnValue({ + editor: storyEditor, + story: footnoteStory, + runtimeRef: { storyKey: 'fn:5', rawId: 'raw-1' }, + change: { + id: 'canon-1', + rawId: 'raw-1', + from: 1, + to: 2, + attrs: {}, + }, + }); + + const receipt = trackChangesRejectWrapper(hostEditor, { id: 'canon-1', story: footnoteStory }); + + expect(receipt).toEqual({ success: true }); + expect(hostEditor.converter!.comments).toEqual([]); + }); + + it('detaches surviving comments from tracked-change threading when the decision receipt says to detach them', () => { + const hostEditor = { + ...makeEditor(), + converter: { + comments: [ + { + commentId: 'comment-2', + trackedChange: true, + trackedChangeParentId: 'canon-1', + trackedChangeType: 'delete', + trackedChangeAnchorKey: 'tc::body::canon-1', + trackedChangeText: 'deleted text', + deletedText: 'deleted text', + }, + ], + }, + } as unknown as Editor; + const storyEditor = { + ...makeEditor({ acceptTrackedChangeById: vi.fn(() => true) }), + storage: { + trackChanges: { + lastDecisionFailure: null, + lastDecisionReceipt: { + detachedComments: [{ id: 'comment-2' }], + }, + }, + }, + } as unknown as Editor; + + mocks.resolveTrackedChangeInStory.mockReturnValue({ + editor: storyEditor, + story: footnoteStory, + runtimeRef: { storyKey: 'fn:5', rawId: 'raw-1' }, + change: { + id: 'canon-1', + rawId: 'raw-1', + from: 1, + to: 2, + attrs: {}, + }, + }); + + const receipt = trackChangesAcceptWrapper(hostEditor, { id: 'canon-1', story: footnoteStory }); + + expect(receipt).toEqual({ success: true }); + expect(hostEditor.converter!.comments[0]).toMatchObject({ + commentId: 'comment-2', + trackedChange: false, + trackedChangeParentId: null, + trackedChangeType: null, + trackedChangeAnchorKey: null, + trackedChangeText: null, + deletedText: null, + }); + }); + + it('prunes tracked-change comment roots whose anchors disappear even when the decision receipt does not enumerate them', () => { + const hostEditor = { + ...makeEditor(), + options: { trackedChanges: {}, documentId: 'doc-1' }, + emit: vi.fn(), + converter: { + comments: [ + { commentId: 'comment-3', trackedChange: true, trackedChangeParentId: 'canon-1' }, + { commentId: 'reply-3', parentCommentId: 'comment-3' }, + ], + }, + } as unknown as Editor; + const storyEditor = { + ...makeEditor({ acceptTrackedChangeById: vi.fn(() => true) }), + storage: { + trackChanges: { + lastDecisionFailure: null, + lastDecisionReceipt: null, + }, + }, + } as unknown as Editor; + + mocks.resolveCommentAnchorsById.mockReturnValue([]); + mocks.resolveTrackedChangeInStory.mockReturnValue({ + editor: storyEditor, + story: footnoteStory, + runtimeRef: { storyKey: 'fn:5', rawId: 'raw-1' }, + change: { + id: 'canon-1', + rawId: 'raw-1', + from: 1, + to: 2, + attrs: {}, + }, + }); + + const receipt = trackChangesAcceptWrapper(hostEditor, { id: 'canon-1', story: footnoteStory }); + + expect(receipt).toEqual({ success: true }); + expect(hostEditor.converter!.comments).toEqual([]); + expect(hostEditor.emit).toHaveBeenCalledWith('commentsUpdate', { + type: 'deleted', + comment: { + commentId: 'comment-3', + documentId: 'doc-1', + fileId: 'doc-1', + }, + }); + }); + it('checks expectedRevision once on the host editor for accept-all across multiple stories', () => { const hostEditor = makeEditor(); const bodyEditor = makeEditor({ acceptAllTrackedChanges: vi.fn(() => true) }); @@ -148,4 +461,550 @@ describe('track-changes-wrappers revision guard', () => { expect(index.invalidate).toHaveBeenCalledWith(bodyStory); expect(index.invalidate).toHaveBeenCalledWith(footnoteStory); }); + + it('scopes accept-all to the requested story when a bulk story filter is provided', () => { + const hostEditor = makeEditor(); + const bodyEditor = makeEditor({ acceptAllTrackedChanges: vi.fn(() => true) }); + const footnoteEditor = makeEditor({ acceptAllTrackedChanges: vi.fn(() => true) }); + const bodyCommit = vi.fn(); + const footnoteCommit = vi.fn(); + + const bodyStory = { kind: 'story', storyType: 'body' } as const; + const snapshots = [ + { + story: bodyStory, + runtimeRef: { storyKey: 'body', rawId: 'raw-body' }, + }, + { + story: footnoteStory, + runtimeRef: { storyKey: 'fn:5', rawId: 'raw-fn' }, + }, + ]; + const index = { + get: vi.fn(() => []), + getAll: vi.fn(() => snapshots), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(), + dispose: vi.fn(), + }; + + mocks.getTrackedChangeIndex.mockReturnValue(index); + mocks.resolveStoryRuntime.mockImplementation((_host: Editor, story: StoryLocator) => { + if (story.storyType === 'body') { + return { editor: bodyEditor, storyKey: 'body', locator: story, kind: 'body', commit: bodyCommit }; + } + + return { editor: footnoteEditor, storyKey: 'fn:5', locator: story, kind: 'note', commit: footnoteCommit }; + }); + + const receipt = trackChangesAcceptAllWrapper(hostEditor, { story: footnoteStory }); + + expect(receipt).toEqual({ success: true }); + expect(mocks.executeDomainCommand).toHaveBeenCalledTimes(1); + expect(mocks.executeDomainCommand).toHaveBeenCalledWith(footnoteEditor, expect.any(Function)); + expect(bodyCommit).not.toHaveBeenCalled(); + expect(footnoteCommit).toHaveBeenCalledWith(hostEditor); + expect(index.invalidate).toHaveBeenCalledTimes(1); + expect(index.invalidate).toHaveBeenCalledWith(footnoteStory); + }); + + it('resolves range targets against v1 sdBlockId attributes', () => { + const acceptTrackedChangesBetween = vi.fn(() => true); + const invalidate = vi.fn(); + const hostEditor = makeRangeDecisionEditor({ acceptTrackedChangesBetween }); + mocks.getTrackedChangeIndex.mockReturnValue({ + get: vi.fn(() => []), + getAll: vi.fn(() => []), + invalidate, + invalidateAll: vi.fn(), + subscribe: vi.fn(), + dispose: vi.fn(), + }); + + const receipt = trackChangesDecideRangeWrapper(hostEditor, { + decision: 'accept', + range: { kind: 'text', segments: [{ blockId: 'p1', range: { start: 2, end: 4 } }] }, + }); + + expect(receipt).toEqual({ success: true }); + expect(acceptTrackedChangesBetween).toHaveBeenCalledWith(8, 10); + expect(invalidate).toHaveBeenCalledWith({ kind: 'story', storyType: 'body' }); + }); + + it('resolves range targets through flattened text offsets for inline wrappers', () => { + const acceptTrackedChangesBetween = vi.fn(() => true); + const hostEditor = makeRangeDecisionEditor( + { acceptTrackedChangesBetween }, + makeParagraphNode({ sdBlockId: 'p1' }, makeInlineWrapper(makeTextNode('Hi'))), + 5, + ); + + const receipt = trackChangesDecideRangeWrapper(hostEditor, { + decision: 'accept', + range: { kind: 'text', segments: [{ blockId: 'p1', range: { start: 0, end: 2 } }] }, + }); + + expect(receipt).toEqual({ success: true }); + expect(acceptTrackedChangesBetween).toHaveBeenCalledWith(7, 9); + }); + + it('preserves typed overlap decision failures for range document-api calls', () => { + const acceptTrackedChangesBetween = vi.fn(() => false); + const hostEditor = { + options: { trackedChanges: {} }, + commands: { acceptTrackedChangesBetween }, + storage: { + trackChanges: { + lastDecisionFailure: { + code: 'TARGET_NOT_FOUND', + message: 'no tracked changes match the requested decision target.', + }, + }, + }, + state: makeRangeDecisionEditor({}).state, + } as unknown as Editor; + + const receipt = trackChangesDecideRangeWrapper(hostEditor, { + decision: 'accept', + range: { kind: 'text', segments: [{ blockId: 'p1', range: { start: 2, end: 4 } }] }, + }); + + expect(receipt).toEqual({ + success: false, + failure: { + code: 'TARGET_NOT_FOUND', + message: 'no tracked changes match the requested decision target.', + details: { + range: { kind: 'text', segments: [{ blockId: 'p1', range: { start: 2, end: 4 } }] }, + story: undefined, + }, + }, + }); + expectTrackChangesDecideReceiptCodeDeclared('TARGET_NOT_FOUND'); + }); + + it('keeps precondition range decision receipt failures declared in document-api metadata', () => { + const acceptTrackedChangesBetween = vi.fn(() => false); + const hostEditor = { + options: { trackedChanges: {} }, + commands: { acceptTrackedChangesBetween }, + storage: { + trackChanges: { + lastDecisionFailure: { + code: 'PRECONDITION_FAILED', + message: 'tracked review graph has invariant errors before decision.', + details: { diagnostics: [{ code: 'INV_REPLACEMENT_MISSING_SIDE' }] }, + }, + }, + }, + state: makeRangeDecisionEditor({}).state, + } as unknown as Editor; + + const receipt = trackChangesDecideRangeWrapper(hostEditor, { + decision: 'accept', + range: { kind: 'text', segments: [{ blockId: 'p1', range: { start: 2, end: 4 } }] }, + }); + + expect(receipt).toEqual({ + success: false, + failure: { + code: 'PRECONDITION_FAILED', + message: 'tracked review graph has invariant errors before decision.', + details: { diagnostics: [{ code: 'INV_REPLACEMENT_MISSING_SIDE' }] }, + }, + }); + expectTrackChangesDecideReceiptCodeDeclared('PRECONDITION_FAILED'); + }); + + it('keeps unresolved range target receipt failures declared in document-api metadata', () => { + const hostEditor = makeRangeDecisionEditor({ acceptTrackedChangesBetween: vi.fn(() => true) }); + + const receipt = trackChangesDecideRangeWrapper(hostEditor, { + decision: 'accept', + range: { kind: 'text', segments: [{ blockId: 'missing', range: { start: 0, end: 1 } }] }, + }); + + expect(receipt).toEqual({ + success: false, + failure: { + code: 'INVALID_TARGET', + message: 'trackChanges.decide range could not be resolved to a contiguous PM coordinate.', + details: { range: { kind: 'text', segments: [{ blockId: 'missing', range: { start: 0, end: 1 } }] } }, + }, + }); + expectTrackChangesDecideReceiptCodeDeclared('INVALID_TARGET'); + }); +}); + +describe('track-changes-wrappers projected id cache', () => { + it('keeps default paired replacements as one replacement public list item', () => { + const editor = makeEditor(); + const replacementSnapshot = { + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-replacement-1' }, + runtimeRef: { storyKey: 'body', rawId: 'tc-replacement-1' }, + story: { kind: 'story', storyType: 'body' }, + type: 'replacement', + excerpt: 'oldnew', + wordRevisionIds: { insert: '11', delete: '10' }, + storyLabel: 'Body', + storyKind: 'body', + anchorKey: 'tc::body::tc-replacement-1', + hasInsert: true, + hasDelete: true, + hasFormat: false, + range: { from: 4, to: 10 }, + }; + + mocks.getTrackedChangeIndex.mockReturnValue({ + get: vi.fn(() => [replacementSnapshot]), + getAll: vi.fn(() => [replacementSnapshot]), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(), + dispose: vi.fn(), + }); + + const result = trackChangesListWrapper(editor); + + expect(result.total).toBe(1); + expect(result.items).toHaveLength(1); + expect(result.items[0]).toMatchObject({ + id: 'tc-replacement-1', + type: 'replacement', + grouping: 'replacement-pair', + wordRevisionIds: { insert: '11', delete: '10' }, + }); + expect(result.items[0]?.id).not.toContain('#'); + + const replacementFiltered = trackChangesListWrapper(editor, { type: 'replacement' }); + expect(replacementFiltered.total).toBe(1); + expect(replacementFiltered.items).toHaveLength(1); + + const insertFiltered = trackChangesListWrapper(editor, { type: 'insert' }); + expect(insertFiltered.total).toBe(0); + expect(insertFiltered.items).toEqual([]); + + const deleteFiltered = trackChangesListWrapper(editor, { type: 'delete' }); + expect(deleteFiltered.total).toBe(0); + expect(deleteFiltered.items).toEqual([]); + }); + + it('returns replacement type for paired replacement get lookups', () => { + const editor = makeEditor(); + const replacementSnapshot = { + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-replacement-1' }, + runtimeRef: { storyKey: 'body', rawId: 'tc-replacement-1' }, + story: { kind: 'story', storyType: 'body' }, + type: 'replacement', + excerpt: 'oldnew', + wordRevisionIds: { insert: '11', delete: '10' }, + storyLabel: 'Body', + storyKind: 'body', + anchorKey: 'tc::body::tc-replacement-1', + hasInsert: true, + hasDelete: true, + hasFormat: false, + range: { from: 4, to: 10 }, + }; + + mocks.resolveTrackedChangeInStory.mockReturnValue({ + editor, + story: { kind: 'story', storyType: 'body' }, + runtimeRef: replacementSnapshot.runtimeRef, + change: { + id: 'tc-replacement-1', + rawId: 'tc-replacement-1', + from: 4, + to: 10, + hasInsert: true, + hasDelete: true, + hasFormat: false, + attrs: {}, + }, + }); + mocks.getTrackedChangeIndex.mockReturnValue({ + get: vi.fn(() => [replacementSnapshot]), + getAll: vi.fn(() => [replacementSnapshot]), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(), + dispose: vi.fn(), + }); + + expect(trackChangesGetWrapper(editor, { id: 'tc-replacement-1' })).toMatchObject({ + id: 'tc-replacement-1', + type: 'replacement', + grouping: 'replacement-pair', + wordRevisionIds: { insert: '11', delete: '10' }, + }); + }); + + it('preserves overlap metadata in trackChanges.list and trackChanges.get output', () => { + const editor = makeEditor(); + const overlap = { + visualLayers: [ + { id: 'tc-parent', type: 'insert', relationship: 'parent' }, + { id: 'tc-child', type: 'delete', relationship: 'child' }, + ], + preferredContextTargetId: 'tc-child', + preferredContextTarget: { id: 'tc-child', type: 'delete', relationship: 'child' }, + }; + const parentSnapshot = { + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-parent' }, + runtimeRef: { storyKey: 'body', rawId: 'parent-raw' }, + story: { kind: 'story', storyType: 'body' }, + type: 'insert', + excerpt: 'review', + overlap, + storyLabel: 'Body', + storyKind: 'body', + anchorKey: 'tc::body::parent-raw', + hasInsert: true, + hasDelete: false, + hasFormat: false, + range: { from: 4, to: 10 }, + }; + const childSnapshot = { + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-child' }, + runtimeRef: { storyKey: 'body', rawId: 'child-raw' }, + story: { kind: 'story', storyType: 'body' }, + type: 'delete', + excerpt: 'review', + storyLabel: 'Body', + storyKind: 'body', + anchorKey: 'tc::body::child-raw', + hasInsert: false, + hasDelete: true, + hasFormat: false, + range: { from: 4, to: 10 }, + }; + + mocks.resolveTrackedChangeInStory.mockReturnValue({ + editor, + story: { kind: 'story', storyType: 'body' }, + runtimeRef: parentSnapshot.runtimeRef, + change: { + id: 'tc-parent', + rawId: 'parent-raw', + from: 4, + to: 10, + hasInsert: true, + hasDelete: false, + hasFormat: false, + attrs: {}, + overlap, + }, + }); + mocks.getTrackedChangeIndex.mockReturnValue({ + get: vi.fn(() => [parentSnapshot, childSnapshot]), + getAll: vi.fn(() => [parentSnapshot, childSnapshot]), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(), + dispose: vi.fn(), + }); + + const listResult = trackChangesListWrapper(editor); + + expect(listResult.items).toHaveLength(2); + expect(listResult.items[0]).toMatchObject({ + id: 'tc-parent', + type: 'insert', + grouping: 'standalone', + overlap, + }); + + expect(trackChangesGetWrapper(editor, { id: 'tc-parent' })).toMatchObject({ + id: 'tc-parent', + type: 'insert', + grouping: 'standalone', + overlap, + }); + }); + + it('collapses split paired replacements into one public replacement item by default', () => { + const editor = makeEditor(); + const insertedSnapshot = { + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-insert' }, + runtimeRef: { storyKey: 'body', rawId: 'tc-insert' }, + story: { kind: 'story', storyType: 'body' }, + type: 'insert', + excerpt: 'new', + storyLabel: 'Body', + storyKind: 'body', + anchorKey: 'tc::body::tc-insert', + commandRawId: 'replacement-command-1', + replacementGroupId: 'replacement-1', + replacementSideId: 'replacement-1#inserted', + hasInsert: true, + hasDelete: false, + hasFormat: false, + range: { from: 4, to: 7 }, + }; + const deletedSnapshot = { + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-delete' }, + runtimeRef: { storyKey: 'body', rawId: 'tc-delete' }, + story: { kind: 'story', storyType: 'body' }, + type: 'delete', + excerpt: 'old', + storyLabel: 'Body', + storyKind: 'body', + anchorKey: 'tc::body::tc-delete', + commandRawId: 'replacement-command-1', + replacementGroupId: 'replacement-1', + replacementSideId: 'replacement-1#deleted', + hasInsert: false, + hasDelete: true, + hasFormat: false, + range: { from: 7, to: 10 }, + }; + + mocks.getTrackedChangeIndex.mockReturnValue({ + get: vi.fn(() => [insertedSnapshot, deletedSnapshot]), + getAll: vi.fn(() => [insertedSnapshot, deletedSnapshot]), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(), + dispose: vi.fn(), + }); + + const result = trackChangesListWrapper(editor); + + expect(result.total).toBe(1); + expect(result.items[0]).toMatchObject({ + id: 'tc-insert', + type: 'replacement', + grouping: 'replacement-pair', + pairedWithChangeId: undefined, + insertedText: 'new', + deletedText: 'old', + }); + }); + + it('keeps split replacement sides separate in independent mode', () => { + const editor = makeEditor({}, { trackedChanges: { replacements: 'independent' } }); + const insertedSnapshot = { + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-insert' }, + runtimeRef: { storyKey: 'body', rawId: 'tc-insert' }, + story: { kind: 'story', storyType: 'body' }, + type: 'insert', + excerpt: 'new', + storyLabel: 'Body', + storyKind: 'body', + anchorKey: 'tc::body::tc-insert', + commandRawId: 'replacement-command-1', + replacementGroupId: 'replacement-1', + replacementSideId: 'replacement-1#inserted', + hasInsert: true, + hasDelete: false, + hasFormat: false, + range: { from: 4, to: 7 }, + }; + const deletedSnapshot = { + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-delete' }, + runtimeRef: { storyKey: 'body', rawId: 'tc-delete' }, + story: { kind: 'story', storyType: 'body' }, + type: 'delete', + excerpt: 'old', + storyLabel: 'Body', + storyKind: 'body', + anchorKey: 'tc::body::tc-delete', + commandRawId: 'replacement-command-1', + replacementGroupId: 'replacement-1', + replacementSideId: 'replacement-1#deleted', + hasInsert: false, + hasDelete: true, + hasFormat: false, + range: { from: 7, to: 10 }, + }; + + mocks.getTrackedChangeIndex.mockReturnValue({ + get: vi.fn(() => [insertedSnapshot, deletedSnapshot]), + getAll: vi.fn(() => [insertedSnapshot, deletedSnapshot]), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(), + dispose: vi.fn(), + }); + + const result = trackChangesListWrapper(editor); + + expect(result.total).toBe(2); + expect(result.items.map((item) => [item.id, item.type, item.grouping, item.pairedWithChangeId])).toEqual([ + ['tc-insert', 'insert', 'standalone', undefined], + ['tc-delete', 'delete', 'standalone', undefined], + ]); + }); + + it('projects combined replacement snapshots as replacement even when raw type is format', () => { + const editor = makeEditor(); + const snapshot = { + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-replacement-2' }, + runtimeRef: { storyKey: 'body', rawId: 'tc-replacement-2' }, + story: { kind: 'story', storyType: 'body' }, + type: 'format', + excerpt: 'native replacement', + storyLabel: 'Body', + storyKind: 'body', + anchorKey: 'tc::body::tc-replacement-2', + hasInsert: true, + hasDelete: true, + hasFormat: true, + range: { from: 10, to: 30 }, + }; + + mocks.getTrackedChangeIndex.mockReturnValue({ + get: vi.fn(() => [snapshot]), + getAll: vi.fn(() => [snapshot]), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(), + dispose: vi.fn(), + }); + + const result = trackChangesListWrapper(editor); + + expect(result.items[0]).toMatchObject({ + id: 'tc-replacement-2', + type: 'replacement', + grouping: 'replacement-pair', + }); + }); + + it('caches list ids for reuse on the same editor revision', () => { + const editor = makeEditor(); + const snapshot = { + address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-del-1' }, + runtimeRef: { storyKey: 'body', rawId: 'tc-del-1' }, + story: { kind: 'story', storyType: 'body' }, + type: 'delete', + excerpt: 'deleted text', + storyLabel: 'Body', + storyKind: 'body', + anchorKey: 'tc::body::tc-del-1', + hasInsert: false, + hasDelete: true, + hasFormat: false, + range: { from: 19, to: 27 }, + }; + + mocks.getRevision.mockReturnValue('4'); + mocks.getTrackedChangeIndex.mockReturnValue({ + get: vi.fn(() => [snapshot]), + getAll: vi.fn(() => [snapshot]), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + subscribe: vi.fn(), + dispose: vi.fn(), + }); + + const result = trackChangesListWrapper(editor); + + expect(result.items[0]?.id).toBe('tc-del-1'); + expect(getCachedProjectedTrackedChangeSnapshot(editor, 'tc-del-1')).toBe(snapshot); + + mocks.getRevision.mockReturnValue('5'); + expect(getCachedProjectedTrackedChangeSnapshot(editor, 'tc-del-1')).toBeNull(); + }); }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts index acd5b7d278..c1bce814cc 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts @@ -12,9 +12,11 @@ */ import type { Editor } from '../../core/Editor.js'; +import type { Node as ProseMirrorNode } from 'prosemirror-model'; import type { Receipt, RevisionGuardOptions, + TextTarget, TrackChangeInfo, TrackChangeWordRevisionIds, TrackChangesAcceptAllInput, @@ -29,10 +31,23 @@ import type { } from '@superdoc/document-api'; import { buildResolvedHandle, buildDiscoveryItem, buildDiscoveryResult } from '@superdoc/document-api'; import { DocumentApiAdapterError } from '../errors.js'; +import { + type CommentEntityRecord, + findCommentEntity, + getCommentEntityStore, + removeCommentEntityTree, + stashRemovedCommentEntities, +} from '../helpers/comment-entity-store.js'; +import { resolveCommentAnchorsById } from '../helpers/comment-target-resolver.js'; import { executeDomainCommand } from './plan-wrappers.js'; import { paginate, validatePaginationInput } from '../helpers/adapter-utils.js'; +import { resolveTextRangeInBlock } from '../helpers/text-offset-resolver.js'; import { checkRevision, getRevision } from './revision-tracker.js'; -import { resolveTrackedChangeInStory, resolveTrackedChangeType } from '../helpers/tracked-change-resolver.js'; +import { + resolveTrackedChangeInStory, + resolveTrackedChangeType, + splitProjectedTrackedChangeId, +} from '../helpers/tracked-change-resolver.js'; import { getTrackedChangeIndex } from '../tracked-changes/tracked-change-index.js'; import type { TrackedChangeSnapshot } from '../tracked-changes/tracked-change-snapshot.js'; import { resolveStoryRuntime } from '../story-runtime/resolve-story-runtime.js'; @@ -53,26 +68,211 @@ function normalizeWordRevisionIds( return Object.keys(normalized).length > 0 ? normalized : undefined; } -function snapshotToInfo(snapshot: TrackedChangeSnapshot): TrackChangeInfo { +type ProjectedTrackChange = { + info: TrackChangeInfo; + handleKey: string; + snapshot: TrackedChangeSnapshot; +}; + +type ProjectedTrackedChangeCacheEntry = { + revision: string; + byProjectedId: Map; +}; + +const projectedTrackedChangeCache = new WeakMap(); +type ReplacementsMode = 'paired' | 'independent'; + +function readReplacementsMode(editor: Editor): ReplacementsMode { + return editor?.options?.trackedChanges?.replacements === 'independent' ? 'independent' : 'paired'; +} + +function buildChangedTextFields( + type: TrackChangeType, + excerpt: string | undefined, +): Pick | Record { + if (!excerpt) return {}; + if (type === 'insert') return { insertedText: excerpt }; + if (type === 'delete') return { deletedText: excerpt }; + return {}; +} + +function buildProjectedInfo( + snapshot: TrackedChangeSnapshot, + options: { + id?: string; + type?: TrackChangeType; + grouping?: TrackChangeInfo['grouping']; + pairedWithChangeId?: string | null; + handleSuffix?: string; + } = {}, +): ProjectedTrackChange { + const id = options.id ?? snapshot.address.entityId; + const type = options.type ?? snapshot.type; return { - address: snapshot.address, - id: snapshot.address.entityId, - type: snapshot.type, - wordRevisionIds: normalizeWordRevisionIds(snapshot.wordRevisionIds), - author: snapshot.author, - authorEmail: snapshot.authorEmail, - authorImage: snapshot.authorImage, - date: snapshot.date, - excerpt: snapshot.excerpt, + info: { + address: { + ...snapshot.address, + entityId: id, + }, + id, + type, + grouping: options.grouping, + pairedWithChangeId: options.pairedWithChangeId ?? undefined, + wordRevisionIds: normalizeWordRevisionIds(snapshot.wordRevisionIds), + overlap: snapshot.overlap, + author: snapshot.author, + authorEmail: snapshot.authorEmail, + authorImage: snapshot.authorImage, + date: snapshot.date, + excerpt: snapshot.excerpt, + ...buildChangedTextFields(type, snapshot.excerpt), + origin: snapshot.origin, + imported: snapshot.imported, + }, + handleKey: `${snapshot.anchorKey}${options.handleSuffix ?? ''}`, + snapshot, }; } -function filterByType( +function isCombinedReplacementSnapshot(snapshot: TrackedChangeSnapshot): boolean { + return snapshot.hasInsert && snapshot.hasDelete; +} + +function replacementPairKey(snapshot: TrackedChangeSnapshot): string | null { + if (snapshot.type !== 'insert' && snapshot.type !== 'delete') return null; + if (snapshot.replacementGroupId) { + return `group:${snapshot.runtimeRef.storyKey}:${snapshot.replacementGroupId}`; + } + if (snapshot.commandRawId) { + return `command:${snapshot.runtimeRef.storyKey}:${snapshot.commandRawId}`; + } + return null; +} + +function projectedSnapshotType(snapshot: TrackedChangeSnapshot): TrackChangeType { + return isCombinedReplacementSnapshot(snapshot) ? 'replacement' : snapshot.type; +} + +function snapshotGrouping(snapshot: TrackedChangeSnapshot): TrackChangeInfo['grouping'] { + return isCombinedReplacementSnapshot(snapshot) ? 'replacement-pair' : 'standalone'; +} + +function snapshotToProjected(snapshot: TrackedChangeSnapshot): ProjectedTrackChange { + return buildProjectedInfo(snapshot, { + type: projectedSnapshotType(snapshot), + grouping: snapshotGrouping(snapshot), + pairedWithChangeId: null, + }); +} + +function snapshotToInfo(snapshot: TrackedChangeSnapshot): TrackChangeInfo { + return snapshotToProjected(snapshot).info; +} + +function mergeWordRevisionIdsFromPair( + inserted: TrackedChangeSnapshot, + deleted: TrackedChangeSnapshot, +): TrackChangeWordRevisionIds | undefined { + return normalizeWordRevisionIds({ + insert: inserted.wordRevisionIds?.insert, + delete: deleted.wordRevisionIds?.delete, + format: inserted.wordRevisionIds?.format ?? deleted.wordRevisionIds?.format, + }); +} + +function projectSplitReplacementPair(group: ReadonlyArray): ProjectedTrackChange | null { + const inserted = group.find((snapshot) => snapshot.type === 'insert'); + const deleted = group.find((snapshot) => snapshot.type === 'delete'); + if (!inserted || !deleted) return null; + + const projected = buildProjectedInfo(inserted, { + type: 'replacement', + grouping: 'replacement-pair', + pairedWithChangeId: null, + }); + + projected.info.wordRevisionIds = mergeWordRevisionIdsFromPair(inserted, deleted); + projected.info.insertedText = inserted.excerpt; + projected.info.deletedText = deleted.excerpt; + + return projected; +} + +export function projectSnapshots( snapshots: ReadonlyArray, + replacements: ReplacementsMode = 'paired', +): ProjectedTrackChange[] { + const byPairKey = new Map(); + for (const snapshot of snapshots) { + if (isCombinedReplacementSnapshot(snapshot)) continue; + const key = replacementPairKey(snapshot); + if (!key) continue; + const group = byPairKey.get(key) ?? []; + group.push(snapshot); + byPairKey.set(key, group); + } + + const collapsedByPairKey = new Map(); + if (replacements === 'paired') { + for (const [key, group] of byPairKey.entries()) { + const collapsed = projectSplitReplacementPair(group); + if (collapsed) collapsedByPairKey.set(key, collapsed); + } + } + + const projected: ProjectedTrackChange[] = []; + const emittedPairKeys = new Set(); + for (const snapshot of snapshots) { + if (isCombinedReplacementSnapshot(snapshot)) { + projected.push(snapshotToProjected(snapshot)); + continue; + } + + const pairKey = replacementPairKey(snapshot); + if (pairKey && replacements === 'paired') { + const collapsed = collapsedByPairKey.get(pairKey); + if (collapsed) { + if (!emittedPairKeys.has(pairKey)) { + emittedPairKeys.add(pairKey); + projected.push(collapsed); + } + continue; + } + } + + projected.push(buildProjectedInfo(snapshot, { grouping: 'standalone', pairedWithChangeId: null })); + } + + return projected; +} + +function cacheProjectedTrackedChanges( + editor: Editor, + projected: ReadonlyArray, + revision = getRevision(editor), +): void { + projectedTrackedChangeCache.set(editor, { + revision, + byProjectedId: new Map(projected.map((row) => [row.info.id, row.snapshot])), + }); +} + +export function getCachedProjectedTrackedChangeSnapshot( + editor: Editor, + projectedId: string, +): TrackedChangeSnapshot | null { + const cache = projectedTrackedChangeCache.get(editor); + if (!cache) return null; + if (cache.revision !== getRevision(editor)) return null; + return cache.byProjectedId.get(projectedId) ?? null; +} + +function filterProjectedByType( + rows: ReadonlyArray, requestedType?: TrackChangeType, -): TrackedChangeSnapshot[] { - if (!requestedType) return [...snapshots]; - return snapshots.filter((snapshot) => snapshot.type === requestedType); +): ProjectedTrackChange[] { + if (!requestedType) return [...rows]; + return rows.filter((row) => row.info.type === requestedType); } function toNoOpReceipt(message: string, details?: unknown): Receipt { @@ -86,6 +286,146 @@ function toNoOpReceipt(message: string, details?: unknown): Receipt { }; } +function decisionFailureReceipt(editor: Editor, fallbackMessage: string, fallbackDetails?: unknown): Receipt { + const storage = ( + editor as { + storage?: { + trackChanges?: { + lastDecisionFailure?: { code?: string; message?: string; details?: unknown } | null; + }; + }; + } + ).storage; + const failureInfo = storage?.trackChanges?.lastDecisionFailure ?? null; + if (!failureInfo?.code) { + return toNoOpReceipt(fallbackMessage, fallbackDetails); + } + return { + success: false, + failure: { + code: failureInfo.code as Extract['failure']['code'], + message: failureInfo.message ?? fallbackMessage, + details: failureInfo.details ?? fallbackDetails, + }, + }; +} + +type DecisionCommentEffectReceipt = { + deletedComments?: Array<{ id?: string | null }>; + detachedComments?: Array<{ id?: string | null }>; +}; + +function commentEntityId(record: CommentEntityRecord): string | null { + return toNonEmptyString(record.commentId) ?? toNonEmptyString(record.importedId); +} + +function isTrackedChangeLinkedRootComment(record: CommentEntityRecord): boolean { + return ( + !toNonEmptyString(record.parentCommentId) && + (record.trackedChange === true || + toNonEmptyString(record.trackedChangeParentId) != null || + record.trackedChangeType != null || + record.trackedChangeAnchorKey != null) + ); +} + +function trackedCommentRootHasLiveAnchors(editor: Editor, commentId: string, record: CommentEntityRecord): boolean { + const aliases = new Set([commentId, toNonEmptyString(record.importedId)].filter((value): value is string => !!value)); + for (const alias of aliases) { + if (resolveCommentAnchorsById(editor, alias).length > 0) return true; + } + return false; +} + +function emitDeletedCommentUpdate(editor: Editor, commentId: string): void { + const emitter = (editor as unknown as { emit?: (event: string, payload: unknown) => void }).emit; + if (typeof emitter !== 'function') return; + + const documentId = toNonEmptyString(editor.options?.documentId) ?? null; + emitter.call(editor, 'commentsUpdate', { + type: 'deleted', + comment: { + commentId, + documentId, + fileId: documentId, + }, + }); +} + +function pruneMissingTrackedCommentRoots(editor: Editor): string[] { + const store = getCommentEntityStore(editor); + const rootIds = Array.from( + new Set( + store + .filter(isTrackedChangeLinkedRootComment) + .map((record) => commentEntityId(record)) + .filter((commentId): commentId is string => commentId != null), + ), + ); + const removedIds = new Set(); + + for (const commentId of rootIds) { + const record = findCommentEntity(store, commentId); + if (!record) continue; + if (trackedCommentRootHasLiveAnchors(editor, commentId, record)) continue; + const removed = removeCommentEntityTree(store, commentId); + stashRemovedCommentEntities(editor, removed); + for (const removedRecord of removed) { + const removedId = commentEntityId(removedRecord); + if (removedId) removedIds.add(removedId); + } + } + + return Array.from(removedIds); +} + +function applyDecisionCommentEffects(hostEditor: Editor, decisionEditor: Editor): void { + const storage = ( + decisionEditor as { + storage?: { + trackChanges?: { + lastDecisionReceipt?: DecisionCommentEffectReceipt | null; + }; + }; + } + ).storage; + const store = getCommentEntityStore(hostEditor); + const receipt = storage?.trackChanges?.lastDecisionReceipt ?? null; + const deletedCommentIds = new Set( + (receipt?.deletedComments ?? []).map((entry) => toNonEmptyString(entry?.id)).filter(Boolean), + ); + const detachedCommentIds = new Set( + (receipt?.detachedComments ?? []) + .map((entry) => toNonEmptyString(entry?.id)) + .filter((id): id is string => Boolean(id) && !deletedCommentIds.has(id)), + ); + + for (const commentId of deletedCommentIds) { + const removed = removeCommentEntityTree(store, commentId); + stashRemovedCommentEntities(hostEditor, removed); + } + + for (const commentId of detachedCommentIds) { + const record = findCommentEntity(store, commentId); + if (!record) continue; + record.trackedChange = false; + record.trackedChangeParentId = null; + record.trackedChangeType = null; + record.trackedChangeDisplayType = null; + record.trackedChangeStory = null; + record.trackedChangeStoryKind = null; + record.trackedChangeStoryLabel = null; + record.trackedChangeAnchorKey = null; + record.trackedChangeText = null; + record.deletedText = null; + } + + const prunedCommentIds = pruneMissingTrackedCommentRoots(hostEditor); + for (const commentId of prunedCommentIds) { + emitDeletedCommentUpdate(hostEditor, commentId); + } +} + function resolveListScope(input: TrackChangesListInput | undefined): 'body' | 'all' | { story: StoryLocator } { if (!input || input.in === undefined) return 'body'; if (input.in === 'all') return 'all'; @@ -107,26 +447,51 @@ export function trackChangesListWrapper(editor: Editor, input?: TrackChangesList rawSnapshots = index.get(scope.story); } - const filtered = filterByType(rawSnapshots, input?.type); + const projected = projectSnapshots(rawSnapshots, readReplacementsMode(editor)); + const evaluatedRevision = getRevision(editor); + cacheProjectedTrackedChanges(editor, projected, evaluatedRevision); + const filtered = filterProjectedByType(projected, input?.type); const paged = paginate(filtered, input?.offset, input?.limit); // Track-changes discovery uses a document-level revision token across every // scope. Part commits also advance the host revision, so one shared token - // correctly guards body, story-scoped, and aggregate review flows. - const evaluatedRevision = getRevision(editor); + // correctly guards body, story-scoped, and replacement-aware review flows. - const items = paged.items.map((snapshot) => { - const info = snapshotToInfo(snapshot); - const handle = buildResolvedHandle(snapshot.anchorKey, 'stable', 'trackedChange'); - const { address, type, wordRevisionIds, author, authorEmail, authorImage, date, excerpt } = info; + const items = paged.items.map((row) => { + const info = row.info; + const handle = buildResolvedHandle(row.handleKey, 'stable', 'trackedChange'); + const { + address, + type, + grouping, + pairedWithChangeId, + wordRevisionIds, + overlap, + author, + authorEmail, + authorImage, + date, + excerpt, + insertedText, + deletedText, + origin, + imported, + } = info; return buildDiscoveryItem(info.id, handle, { address, type, + grouping, + pairedWithChangeId, wordRevisionIds, + overlap, author, authorEmail, authorImage, date, excerpt, + insertedText, + deletedText, + origin, + imported, }); }); @@ -155,10 +520,25 @@ export function trackChangesGetWrapper(editor: Editor, input: TrackChangesGetInp const anchorKey = makeTrackedChangeAnchorKey(resolved.runtimeRef); const snapshots = storyKey === BODY_STORY_KEY ? index.get({ kind: 'story', storyType: 'body' }) : index.get(resolved.story); - const snapshot = snapshots.find((item) => item.anchorKey === anchorKey); + const projected = projectSnapshots(snapshots, readReplacementsMode(editor)); + const projectedMatch = projected.find((row) => row.info.id === id); + + if (projectedMatch) return projectedMatch.info; + + const { baseId } = splitProjectedTrackedChangeId(id); + const snapshot = snapshots.find((item) => item.anchorKey === anchorKey || item.address.entityId === baseId); if (snapshot) return snapshotToInfo(snapshot); + const type = resolveTrackedChangeType(resolved.change); + const excerpt = + (resolved.change.excerpt !== undefined ? resolved.change.excerpt : undefined) ?? + normalizeExcerpt(resolved.editor.state.doc.textBetween(resolved.change.from, resolved.change.to, ' ', '\ufffc')); + const grouping = + resolved.change.hasInsert && resolved.change.hasDelete && !resolved.change.hasFormat + ? 'replacement-pair' + : undefined; + return { address: { kind: 'entity', @@ -167,15 +547,18 @@ export function trackChangesGetWrapper(editor: Editor, input: TrackChangesGetInp ...(storyKey === BODY_STORY_KEY ? {} : { story: resolved.story }), }, id: resolved.change.id, - type: resolveTrackedChangeType(resolved.change), + type, + grouping, wordRevisionIds: normalizeWordRevisionIds(resolved.change.wordRevisionIds), + overlap: resolved.change.overlap, author: toNonEmptyString(resolved.change.attrs.author), authorEmail: toNonEmptyString(resolved.change.attrs.authorEmail), authorImage: toNonEmptyString(resolved.change.attrs.authorImage), date: toNonEmptyString(resolved.change.attrs.date), - excerpt: normalizeExcerpt( - resolved.editor.state.doc.textBetween(resolved.change.from, resolved.change.to, ' ', '\ufffc'), - ), + excerpt, + ...buildChangedTextFields(type, excerpt), + origin: toNonEmptyString(resolved.change.attrs.origin) as TrackChangeInfo['origin'], + imported: Boolean(toNonEmptyString(resolved.change.attrs.sourceId)), }; } @@ -211,13 +594,18 @@ function decideSingle( checkRevision(hostEditor, options?.expectedRevision); - const receipt = executeDomainCommand(resolved.editor, () => Boolean(command(resolved.change.rawId))); + const commandRawId = resolved.change.commandRawId ?? resolved.change.rawId; + const receipt = executeDomainCommand(resolved.editor, () => Boolean(command(commandRawId))); if (receipt.steps[0]?.effect !== 'changed') { - return toNoOpReceipt(`${decision === 'accept' ? 'Accept' : 'Reject'} tracked change "${id}" produced no change.`, { - id, - story, - }); + return decisionFailureReceipt( + resolved.editor, + `${decision === 'accept' ? 'Accept' : 'Reject'} tracked change "${id}" produced no change.`, + { + id, + story, + }, + ); } if (resolved.commit) { @@ -225,6 +613,7 @@ function decideSingle( } getTrackedChangeIndex(hostEditor).invalidate(resolved.story); + applyDecisionCommentEffects(hostEditor, resolved.editor); return { success: true }; } @@ -245,17 +634,27 @@ export function trackChangesRejectWrapper( return decideSingle(editor, 'reject', input.id, input.story, options); } -function decideAll(editor: Editor, decision: ReviewDecision, options: RevisionGuardOptions | undefined): Receipt { +function decideAll( + editor: Editor, + decision: ReviewDecision, + input: TrackChangesAcceptAllInput | TrackChangesRejectAllInput, + options: RevisionGuardOptions | undefined, +): Receipt { const index = getTrackedChangeIndex(editor); + const requestedStoryKey = input.story && input.story !== 'all' ? buildStoryKey(input.story) : null; const allSnapshots = index.getAll(); - if (allSnapshots.length === 0) { + const matchingSnapshots = requestedStoryKey + ? allSnapshots.filter((snapshot) => snapshot.runtimeRef.storyKey === requestedStoryKey) + : allSnapshots; + + if (matchingSnapshots.length === 0) { return toNoOpReceipt(`${decision === 'accept' ? 'Accept' : 'Reject'} all tracked changes produced no change.`); } checkRevision(editor, options?.expectedRevision); const byStoryKey = new Map(); - for (const snapshot of allSnapshots) { + for (const snapshot of matchingSnapshots) { const key = snapshot.runtimeRef.storyKey; const entry = byStoryKey.get(key); if (entry) { @@ -282,7 +681,9 @@ function decideAll(editor: Editor, decision: ReviewDecision, options: RevisionGu let applied = false; for (const snapshot of snapshots) { - if (perChangeCommand(snapshot.runtimeRef.rawId)) { + const resolved = resolveTrackedChangeInStory(editor, snapshot.address); + const commandRawId = resolved?.change.commandRawId ?? snapshot.runtimeRef.rawId; + if (perChangeCommand(commandRawId)) { applied = true; } } @@ -297,10 +698,14 @@ function decideAll(editor: Editor, decision: ReviewDecision, options: RevisionGu runtime.commit(editor); } index.invalidate(story); + applyDecisionCommentEffects(editor, runtime.editor); } if (!anyApplied) { - return toNoOpReceipt(`${decision === 'accept' ? 'Accept' : 'Reject'} all tracked changes produced no change.`); + return decisionFailureReceipt( + editor, + `${decision === 'accept' ? 'Accept' : 'Reject'} all tracked changes produced no change.`, + ); } return { success: true }; @@ -308,16 +713,110 @@ function decideAll(editor: Editor, decision: ReviewDecision, options: RevisionGu export function trackChangesAcceptAllWrapper( editor: Editor, - _input: TrackChangesAcceptAllInput, + input: TrackChangesAcceptAllInput, options?: RevisionGuardOptions, ): Receipt { - return decideAll(editor, 'accept', options); + return decideAll(editor, 'accept', input, options); } export function trackChangesRejectAllWrapper( editor: Editor, - _input: TrackChangesRejectAllInput, + input: TrackChangesRejectAllInput, options?: RevisionGuardOptions, ): Receipt { - return decideAll(editor, 'reject', options); + return decideAll(editor, 'reject', input, options); +} + +// --------------------------------------------------------------------------- +// trackChanges.decide range targets +// --------------------------------------------------------------------------- + +/** + * Resolve a {@link TextTarget} into a single contiguous PM range within the + * body story. Multi-segment ranges are deferred (CAPABILITY_UNAVAILABLE) + * until fixtures land — partial decisions per phase0-004 only need a single + * contiguous selection. + */ +function resolveRangeToPmCoords(editor: Editor, range: TextTarget): { from: number; to: number } | null { + if (!range.segments?.length) return null; + if (range.segments.length > 1) return null; // multi-segment ranges deferred. + const seg = range.segments[0]; + const doc = editor.state.doc; + const block = findBlockStart(doc, seg.blockId); + if (!block) return null; + return resolveTextRangeInBlock(block.node, block.pos, seg.range); +} + +function findBlockStart( + doc: { + descendants: (fn: (node: ProseMirrorNode, pos: number) => boolean | void) => void; + }, + blockId: string, +): { node: ProseMirrorNode; pos: number } | null { + let found: { node: ProseMirrorNode; pos: number } | null = null; + doc.descendants((node, pos) => { + if (found !== null) return false; + const attrs = node.attrs as Record; + if ((attrs?.blockId ?? attrs?.sdBlockId ?? attrs?.id) === blockId) { + found = { node, pos }; + return false; + } + return true; + }); + return found; +} + +export function trackChangesDecideRangeWrapper( + editor: Editor, + input: { decision: 'accept' | 'reject'; range: TextTarget; story?: StoryLocator }, + options?: RevisionGuardOptions, +): Receipt { + // Story routing — for now partial-range decisions are implemented + // for the body story only. Non-body stories require structural plumbing + // owned by phase0-005 and fail closed here. + const story = input.story; + if (story && (story.kind !== 'story' || story.storyType !== 'body')) { + return { + success: false, + failure: { + code: 'CAPABILITY_UNAVAILABLE', + message: 'trackChanges.decide range targets currently support the body story only.', + details: { story: input.story }, + }, + }; + } + const resolved = resolveRangeToPmCoords(editor, input.range); + if (!resolved) { + return { + success: false, + failure: { + code: 'INVALID_TARGET', + message: 'trackChanges.decide range could not be resolved to a contiguous PM coordinate.', + details: { range: input.range }, + }, + }; + } + checkRevision(editor, options?.expectedRevision); + + const commandName = input.decision === 'accept' ? 'acceptTrackedChangesBetween' : 'rejectTrackedChangesBetween'; + const command = (editor.commands as Record boolean) | undefined>)[commandName]; + if (typeof command !== 'function') { + return { + success: false, + failure: { + code: 'CAPABILITY_UNAVAILABLE', + message: `${commandName} command is not available on the editor.`, + }, + }; + } + const applied = Boolean(command(resolved.from, resolved.to)); + if (!applied) { + return decisionFailureReceipt(editor, 'No tracked changes matched the requested decision target.', { + range: input.range, + story: input.story, + }); + } + getTrackedChangeIndex(editor).invalidate({ kind: 'story', storyType: 'body' }); + applyDecisionCommentEffects(editor, editor); + return { success: true }; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/__tests__/tracked-change-index.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/__tests__/tracked-change-index.test.ts index 56e23a3d40..1d599398f3 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/__tests__/tracked-change-index.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/__tests__/tracked-change-index.test.ts @@ -132,6 +132,45 @@ describe('TrackedChangeIndex — per-story cache', () => { }); }); + it('preserves overlap metadata on snapshots', () => { + const editor = makeEditor(); + mocks.groupTrackedChanges.mockReturnValueOnce([ + { + ...makeGroupedChange('parent-insert', 0, 5), + overlap: { + visualLayers: [ + { id: 'stale-parent-id', rawId: 'parent-insert', type: 'insert', relationship: 'parent' }, + { id: 'stale-child-id', rawId: 'child-delete', type: 'delete', relationship: 'child' }, + ], + preferredContextTargetId: 'stale-child-id', + preferredContextTarget: { + id: 'stale-child-id', + rawId: 'child-delete', + type: 'delete', + relationship: 'child', + }, + }, + }, + { + ...makeGroupedChange('child-delete', 1, 4), + hasInsert: false, + hasDelete: true, + }, + ]); + + const index = getTrackedChangeIndex(editor); + const snapshots = index.get({ kind: 'story', storyType: 'body' }); + + expect(snapshots[0]?.overlap).toEqual({ + visualLayers: [ + { id: 'canon-parent-insert', type: 'insert', relationship: 'parent' }, + { id: 'canon-child-delete', type: 'delete', relationship: 'child' }, + ], + preferredContextTargetId: 'canon-child-delete', + preferredContextTarget: { id: 'canon-child-delete', type: 'delete', relationship: 'child' }, + }); + }); + it('returns story-scoped anchor keys for footnote stories', () => { const editor = makeEditor(); mocks.enumerateRevisionCapableStories.mockReturnValue([ @@ -293,4 +332,33 @@ describe('TrackedChangeIndex — broadcast', () => { await Promise.resolve(); expect(listener).toHaveBeenCalledTimes(1); }); + + // Phase 005 — v1-3220 collaboration requirement: remote (Yjs-origin) + // transactions still produce a `transaction` event with `docChanged: true` + // because the synced ProseMirror plugin applies steps locally. The + // tracked-change-index must broadcast `tracked-changes-changed` for those + // remote-origin transactions so bubble / sidebar / extract consumers see + // newly merged tracked marks without an extra local edit. + it('broadcasts after remote (Yjs-origin) transactions that change the doc', async () => { + const editor = makeEditor(); + const index = getTrackedChangeIndex(editor); + + // Simulate a remote Yjs update that mutated the document. The index does + // not inspect tr.meta(ySyncPluginKey) — it only requires docChanged so + // that remote applies trigger the same refresh path local edits use. + editor._emit('transaction', { transaction: { docChanged: true } }); + + await Promise.resolve(); + + expect(editor.emit).toHaveBeenCalledTimes(1); + expect(editor.emit).toHaveBeenCalledWith( + 'tracked-changes-changed', + expect.objectContaining({ + editor, + source: 'body-edit', + stories: expect.arrayContaining([{ kind: 'story', storyType: 'body' }]), + }), + ); + void index; + }); }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-index.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-index.ts index 6d143f1640..9fff279c03 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-index.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-index.ts @@ -12,7 +12,7 @@ * comments-store, navigation, and review surfaces can resync. */ -import type { StoryLocator } from '@superdoc/document-api'; +import type { StoryLocator, TrackChangeOverlapInfo, TrackChangeOverlapLayer } from '@superdoc/document-api'; import type { Editor } from '../../core/Editor.js'; import type { PartChangedEvent } from '../../core/parts/types.js'; import { buildStoryKey, BODY_STORY_KEY, parseStoryKeyType } from '../story-runtime/story-key.js'; @@ -65,6 +65,73 @@ function buildTrackedChangeAddress( }; } +type OverlapLayerWithAliases = TrackChangeOverlapLayer & { + rawId?: string; + commandRawId?: string; +}; + +function addCanonicalAlias(map: Map, alias: unknown, canonicalId: string): void { + const normalized = toNonEmptyString(alias); + if (!normalized || map.has(normalized)) return; + map.set(normalized, canonicalId); +} + +function buildCanonicalIdByAlias(grouped: readonly GroupedTrackedChange[]): Map { + const map = new Map(); + for (const change of grouped) { + addCanonicalAlias(map, change.id, change.id); + addCanonicalAlias(map, change.rawId, change.id); + addCanonicalAlias(map, change.commandRawId, change.id); + addCanonicalAlias(map, change.attrs.id, change.id); + addCanonicalAlias(map, change.attrs.sourceId, change.id); + } + return map; +} + +function resolveCanonicalId(alias: unknown, canonicalIdByAlias: ReadonlyMap): string | undefined { + const normalized = toNonEmptyString(alias); + if (!normalized) return undefined; + return canonicalIdByAlias.get(normalized) ?? normalized; +} + +function copyOverlapLayer( + layer: TrackChangeOverlapLayer, + canonicalIdByAlias: ReadonlyMap, +): TrackChangeOverlapLayer { + const layerWithAliases = layer as OverlapLayerWithAliases; + const id = + resolveCanonicalId(layerWithAliases.rawId, canonicalIdByAlias) ?? + resolveCanonicalId(layerWithAliases.commandRawId, canonicalIdByAlias) ?? + resolveCanonicalId(layerWithAliases.id, canonicalIdByAlias) ?? + layer.id; + + return { + id, + type: layer.type, + relationship: layer.relationship, + }; +} + +function copyOverlapInfo( + overlap: TrackChangeOverlapInfo | undefined, + canonicalIdByAlias: ReadonlyMap, +): TrackChangeOverlapInfo | undefined { + if (!overlap) return undefined; + + const visualLayers = overlap.visualLayers?.map((layer) => copyOverlapLayer(layer, canonicalIdByAlias)); + const preferredContextTarget = overlap.preferredContextTarget + ? copyOverlapLayer(overlap.preferredContextTarget, canonicalIdByAlias) + : undefined; + const preferredContextTargetId = + preferredContextTarget?.id ?? resolveCanonicalId(overlap.preferredContextTargetId, canonicalIdByAlias); + + return { + ...(visualLayers && visualLayers.length > 0 ? { visualLayers } : {}), + ...(preferredContextTargetId ? { preferredContextTargetId } : {}), + ...(preferredContextTarget ? { preferredContextTarget } : {}), + }; +} + class TrackedChangeIndexImpl implements TrackedChangeIndex { readonly #hostEditor: Editor; readonly #snapshots = new Map(); @@ -180,8 +247,11 @@ class TrackedChangeIndexImpl implements TrackedChangeIndex { const storyKind = classifyStoryKind(locator); const storyLabel = describeStoryLocation(locator); + const canonicalIdByAlias = buildCanonicalIdByAlias(grouped); - return grouped.map((change) => this.#buildSnapshot(editor, change, storyKey, locator, storyKind, storyLabel)); + return grouped.map((change) => + this.#buildSnapshot(editor, change, storyKey, locator, storyKind, storyLabel, canonicalIdByAlias), + ); } #buildSnapshot( @@ -191,11 +261,14 @@ class TrackedChangeIndexImpl implements TrackedChangeIndex { locator: StoryLocator, storyKind: TrackedChangeSnapshot['storyKind'], storyLabel: string, + canonicalIdByAlias: ReadonlyMap, ): TrackedChangeSnapshot { const runtimeRef: TrackedChangeRuntimeRef = { storyKey, rawId: change.rawId }; const address = buildTrackedChangeAddress(locator, storyKey, change.id); const type = resolveTrackedChangeType(change); - const excerpt = normalizeExcerpt(editor.state.doc.textBetween(change.from, change.to, ' ', '\ufffc')); + const excerpt = + (change.excerpt !== undefined ? change.excerpt : undefined) ?? + normalizeExcerpt(editor.state.doc.textBetween(change.from, change.to, ' ', '\ufffc')); return { address, @@ -208,9 +281,18 @@ class TrackedChangeIndexImpl implements TrackedChangeIndex { date: toNonEmptyString(change.attrs.date), excerpt, wordRevisionIds: change.wordRevisionIds ? { ...change.wordRevisionIds } : undefined, + origin: toNonEmptyString(change.attrs.origin) as TrackedChangeSnapshot['origin'], + imported: Boolean(toNonEmptyString(change.attrs.sourceId)), + overlap: copyOverlapInfo(change.overlap, canonicalIdByAlias), storyLabel, storyKind, anchorKey: makeTrackedChangeAnchorKey(runtimeRef), + commandRawId: change.commandRawId, + replacementGroupId: toNonEmptyString(change.attrs.replacementGroupId), + replacementSideId: toNonEmptyString(change.attrs.replacementSideId), + hasInsert: change.hasInsert, + hasDelete: change.hasDelete, + hasFormat: change.hasFormat, range: { from: change.from, to: change.to }, }; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-snapshot.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-snapshot.ts index b9ed36a2e2..1d4a08583a 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-snapshot.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-snapshot.ts @@ -7,7 +7,9 @@ import type { StoryLocator, TrackedChangeAddress, + TrackChangeProvenanceOrigin, TrackChangeType, + TrackChangeOverlapInfo, TrackChangeWordRevisionIds, } from '@superdoc/document-api'; import type { TrackedChangeRuntimeRef } from '../helpers/tracked-change-runtime-ref.js'; @@ -33,12 +35,27 @@ export interface TrackedChangeSnapshot { excerpt?: string; /** Raw imported Word revision IDs, if present. */ wordRevisionIds?: TrackChangeWordRevisionIds; + /** Source application or package family detected on import. */ + origin?: TrackChangeProvenanceOrigin; + /** True when this tracked change came from an imported document revision. */ + imported?: boolean; + /** Overlap metadata for nested tracked changes that share the same text range. */ + overlap?: TrackChangeOverlapInfo; /** Human-readable label for sidebar cards ("Footer · Section 3", "Footnote 12"). */ storyLabel: string; /** Coarse classifier for UI decisions (icon, label). */ storyKind: 'body' | 'headerFooter' | 'footnote' | 'endnote'; /** Canonical shared-map anchor key (`tc::::`). */ anchorKey: string; + /** Internal raw command id when distinct from the story-level raw id. */ + commandRawId?: string; + /** Replacement metadata used by public projection helpers. */ + replacementGroupId?: string; + replacementSideId?: string; + /** Raw grouped-change shape retained for projection logic. */ + hasInsert: boolean; + hasDelete: boolean; + hasFormat: boolean; /** Absolute PM position range within the story editor. */ range: { from: number; to: number }; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.test.ts index c72c4b2362..75ce893298 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.test.ts @@ -862,6 +862,38 @@ describe('writeAdapter', () => { }); }); + it('preserves overlap compiler failure receipts when tracked write command does not apply', () => { + const { editor } = makeEditor('Hello'); + (editor as unknown as { options: Record; storage: Record }).options = { + user: { name: 'Test User' }, + trackedChanges: {}, + }; + (editor as unknown as { storage: Record }).storage = { + trackChanges: { + lastCompilerFailure: { + code: 'PRECONDITION_FAILED', + message: 'Tracked review graph has invariant errors before edit.', + }, + }, + }; + (editor.commands as { insertTrackedChange?: ReturnType }).insertTrackedChange = vi.fn(() => false); + + const receipt = writeAdapter( + editor, + { + kind: 'replace', + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + text: 'World', + }, + { changeMode: 'tracked' }, + ); + + expect(receipt.success).toBe(false); + expect(receipt.failure).toMatchObject({ + code: 'PRECONDITION_FAILED', + }); + }); + it('supports direct dry-run without mutating editor state', () => { const { editor, dispatch, tr } = makeEditor('Hello'); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.ts b/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.ts index 264007aa17..30e2f092a8 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.ts @@ -254,6 +254,15 @@ function applyTrackedWrite( }); if (!didApply) { + const lastCompilerFailure = (editor.storage?.trackChanges as { lastCompilerFailure?: ReceiptFailure } | undefined) + ?.lastCompilerFailure; + if (lastCompilerFailure) { + return { + success: false, + resolution: resolvedTarget.resolution, + failure: lastCompilerFailure, + }; + } return { success: false, resolution: resolvedTarget.resolution, diff --git a/packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.js b/packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.js index 52a7dabd1a..a5dec3659c 100644 --- a/packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.js +++ b/packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.js @@ -35,6 +35,7 @@ export const CommentsPlugin = Extension.create({ * @param {string} [contentOrOptions.content] - The comment content (text or HTML) * @param {string} [contentOrOptions.commentId] - Explicit comment ID (defaults to a new UUID) * @param {string} [contentOrOptions.author] - Author name (defaults to user from editor config) + * @param {string} [contentOrOptions.authorId] - Stable actor id (defaults to user from editor config) * @param {string} [contentOrOptions.authorEmail] - Author email (defaults to user from editor config) * @param {string} [contentOrOptions.authorImage] - Author image URL (defaults to user from editor config) * @param {boolean} [contentOrOptions.isInternal=false] - Whether the comment is internal/private @@ -70,7 +71,7 @@ export const CommentsPlugin = Extension.create({ } // Handle string or options object - let content, explicitCommentId, author, authorEmail, authorImage, isInternal; + let content, explicitCommentId, author, authorId, authorEmail, authorImage, isInternal; if (typeof contentOrOptions === 'string') { content = contentOrOptions; @@ -78,6 +79,7 @@ export const CommentsPlugin = Extension.create({ content = contentOrOptions.content; explicitCommentId = contentOrOptions.commentId; author = contentOrOptions.author; + authorId = contentOrOptions.authorId; authorEmail = contentOrOptions.authorEmail; authorImage = contentOrOptions.authorImage; isInternal = contentOrOptions.isInternal; @@ -110,6 +112,7 @@ export const CommentsPlugin = Extension.create({ isInternal: resolvedInternal, commentText: content, creatorName: author ?? configUser.name, + creatorId: authorId ?? configUser.id, creatorEmail: authorEmail ?? configUser.email, creatorImage: authorImage ?? configUser.image, createdTime: Date.now(), @@ -135,6 +138,7 @@ export const CommentsPlugin = Extension.create({ * @param {string} options.parentId - The ID of the parent comment or tracked change * @param {string} [options.content] - The reply content (text or HTML) * @param {string} [options.author] - Author name (defaults to user from editor config) + * @param {string} [options.authorId] - Stable actor id (defaults to user from editor config) * @param {string} [options.authorEmail] - Author email (defaults to user from editor config) * @param {string} [options.authorImage] - Author image URL (defaults to user from editor config) * @returns {boolean} True if the reply was added successfully, false otherwise @@ -147,7 +151,15 @@ export const CommentsPlugin = Extension.create({ addCommentReply: (options = {}) => ({ editor }) => { - const { parentId, content, author, authorEmail, authorImage, commentId: explicitCommentId } = options; + const { + parentId, + content, + author, + authorId, + authorEmail, + authorImage, + commentId: explicitCommentId, + } = options; if (!parentId) { console.warn('addCommentReply requires a parentId'); @@ -163,6 +175,7 @@ export const CommentsPlugin = Extension.create({ parentCommentId: parentId, commentText: content, creatorName: author ?? configUser.name, + creatorId: authorId ?? configUser.id, creatorEmail: authorEmail ?? configUser.email, creatorImage: authorImage ?? configUser.image, createdTime: Date.now(), @@ -918,6 +931,15 @@ const handleTrackedChangeTransaction = (trackedChangeMeta, trackedChanges, newEd return true; }; + const trackedChangesForIdCache = new Map(); + const getTrackedChangesForId = (changeId) => { + if (!changeId) return []; + if (!trackedChangesForIdCache.has(changeId)) { + trackedChangesForIdCache.set(changeId, getTrackChanges(newEditorState, changeId)); + } + return trackedChangesForIdCache.get(changeId); + }; + const buildTrackedChangePayload = ({ event, marks, nodes, deletionNodes = [] }) => { if (!marks.insertedMark && !marks.deletionMark && !marks.formatMark) { return null; @@ -936,7 +958,7 @@ const handleTrackedChangeTransaction = (trackedChangeMeta, trackedChanges, newEd deletionNodes, nodes, newEditorState, - trackedChangesForId: getTrackChanges(newEditorState, trackedMarkId), + trackedChangesForId: getTrackedChangesForId(trackedMarkId), }); }; @@ -954,15 +976,18 @@ const handleTrackedChangeTransaction = (trackedChangeMeta, trackedChanges, newEd }); } - const hasCandidateNodes = nodes.length > 0 || Boolean(deletionNodes?.length); + const hasLiveTrackedChange = (changeId) => getTrackedChangesForId(changeId).length > 0; + const hasCandidateNodes = nodes.length > 0 || Boolean(deletionNodes?.length) || hasLiveTrackedChange(primaryId); const hasIndependentReplacementIds = Boolean(insertedMark && deletionMark) && Boolean(insertedId) && Boolean(deletionId) && insertedId !== deletionId; if (hasIndependentReplacementIds) { const isNewInsertion = registerTrackedChangeId(insertedId, { insertion: insertedId }); const isNewDeletion = registerTrackedChangeId(deletionId, { deletion: deletionId }); + const hasInsertionCandidateNodes = nodes.length > 0 || hasLiveTrackedChange(insertedId); + const hasDeletionCandidateNodes = Boolean(deletionNodes?.length) || hasLiveTrackedChange(deletionId); - const insertionPayload = hasCandidateNodes + const insertionPayload = hasInsertionCandidateNodes ? buildTrackedChangePayload({ event: isNewInsertion ? 'add' : 'update', marks: { @@ -976,7 +1001,7 @@ const handleTrackedChangeTransaction = (trackedChangeMeta, trackedChanges, newEd : null; const deletionPayload = - deletionMark && (hasCandidateNodes || getTrackChanges(newEditorState, deletionId).length > 0) + deletionMark && hasDeletionCandidateNodes ? buildTrackedChangePayload({ event: isNewDeletion ? 'add' : 'update', marks: { @@ -1151,7 +1176,7 @@ const createOrUpdateTrackedChangeComment = ({ const { type, attrs } = trackedMark; const { name: trackedChangeType } = type; - const { author, authorEmail, authorImage, date, importedAuthor } = attrs; + const { author, authorId, authorEmail, authorImage, date, importedAuthor } = attrs; const id = attrs.id; const insertedMarkId = marks.insertedMark?.attrs?.id ?? null; @@ -1233,6 +1258,7 @@ const createOrUpdateTrackedChangeComment = ({ trackedChangeDisplayType, deletedText: isReplacement || marks.deletionMark ? deletionText : null, author, + ...(authorId && { authorId }), authorEmail, ...(authorImage && { authorImage }), date, diff --git a/packages/super-editor/src/editors/v1/extensions/comment/helpers/normalize-comment-event-payload.js b/packages/super-editor/src/editors/v1/extensions/comment/helpers/normalize-comment-event-payload.js index 169c04af73..7fd2f3da90 100644 --- a/packages/super-editor/src/editors/v1/extensions/comment/helpers/normalize-comment-event-payload.js +++ b/packages/super-editor/src/editors/v1/extensions/comment/helpers/normalize-comment-event-payload.js @@ -22,6 +22,10 @@ export const normalizeCommentEventPayload = ({ conversation, editorOptions, fall normalized.creatorName = user.name; } + if (!normalized.creatorId && user?.id) { + normalized.creatorId = user.id; + } + if (!normalized.creatorEmail && user?.email) { normalized.creatorEmail = user.email; } diff --git a/packages/super-editor/src/editors/v1/extensions/permission-ranges/permission-ranges.test.js b/packages/super-editor/src/editors/v1/extensions/permission-ranges/permission-ranges.test.js index cba42bd057..58da795981 100644 --- a/packages/super-editor/src/editors/v1/extensions/permission-ranges/permission-ranges.test.js +++ b/packages/super-editor/src/editors/v1/extensions/permission-ranges/permission-ranges.test.js @@ -66,6 +66,21 @@ const docWithUserSpecificPermission = { ], }; +const docWithActorIdPermission = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'permStart', attrs: { id: 'actor-1', ed: 'alice-id' } }, + { type: 'text', text: 'Actor specific section. ' }, + { type: 'permEnd', attrs: { id: 'actor-1', ed: 'alice-id' } }, + { type: 'text', text: 'Locked section.' }, + ], + }, + ], +}; + const findTextPos = (doc, searchText) => { let found = null; doc.descendants((node, pos) => { @@ -780,4 +795,13 @@ describe('PermissionRanges extension', () => { expect(instance.isEditable).toBe(true); expect(instance.storage.permissionRanges?.ranges?.length).toBeGreaterThan(0); }); + + it('matches permission ranges by actor id when no explicit permissionPrincipals are set', () => { + const instance = createProtectedEditor(docWithActorIdPermission, { + user: { id: 'alice-id', name: 'Alice', email: 'shared@example.com' }, + }); + + expect(instance.isEditable).toBe(true); + expect(instance.storage.permissionRanges?.ranges?.length).toBeGreaterThan(0); + }); }); diff --git a/packages/super-editor/src/editors/v1/extensions/protection/editability.js b/packages/super-editor/src/editors/v1/extensions/protection/editability.js index 10201088e4..d281458606 100644 --- a/packages/super-editor/src/editors/v1/extensions/protection/editability.js +++ b/packages/super-editor/src/editors/v1/extensions/protection/editability.js @@ -171,12 +171,23 @@ export function buildAllowedIdentifierSetFromEditor(editor) { return new Set(principals.map((p) => (typeof p === 'string' ? p.trim().toLowerCase() : '')).filter(Boolean)); } - // Fallback: derive from email only when permissionPrincipals is not set + // Fallback: derive from stable actor id when available, plus the legacy + // email-derived principal for documents that still use Word-style `ed` + // values. This lets embedders move to first-class actor ids without + // breaking older documents. + const identifiers = new Set(); + const actorId = typeof user.id === 'string' ? user.id.trim().toLowerCase() : ''; + if (actorId) identifiers.add(actorId); + const email = typeof user.email === 'string' ? user.email.trim().toLowerCase() : ''; - if (!email) return new Set(); - const [localPart, domain] = email.split('@'); - if (!localPart || !domain) return new Set(); - return new Set([`${domain}\\${localPart}`]); + if (email) { + const [localPart, domain] = email.split('@'); + if (localPart && domain) { + identifiers.add(`${domain}\\${localPart}`); + } + } + + return identifiers; } /** diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/permission-helpers.js b/packages/super-editor/src/editors/v1/extensions/track-changes/permission-helpers.js index a69430070b..a9c1cd3d5a 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/permission-helpers.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/permission-helpers.js @@ -1,4 +1,10 @@ import { getTrackChanges } from './trackChangesHelpers/getTrackChanges.js'; +import { + classifyOwnership, + isSameUserHighConfidence, + getCurrentUserIdentity, + getChangeAuthorIdentity, +} from './review-model/identity.js'; const PERMISSION_MAP = { accept: { @@ -70,13 +76,15 @@ const derivePermissionKey = ({ action, isOwn }) => { return isOwn ? mapping.own : mapping.other; }; -const normalizeEmail = (value) => { - if (typeof value !== 'string') return ''; - return value.trim().toLowerCase(); -}; - const resolveChanges = (editor) => { - if (!editor) return { role: 'editor', isInternal: false, currentUser: null, resolver: null }; + if (!editor) { + return { + role: 'editor', + isInternal: false, + currentUser: null, + resolver: null, + }; + } const role = editor.options?.role ?? 'editor'; const isInternal = Boolean(editor.options?.isInternal); const currentUser = editor.options?.user ?? null; @@ -95,14 +103,17 @@ const resolveChanges = (editor) => { */ export const isTrackedChangeActionAllowed = ({ editor, action, trackedChanges }) => { if (!trackedChanges?.length) return true; - const { role, isInternal, currentUser, resolver } = resolveChanges(editor); + const { role, isInternal, resolver } = resolveChanges(editor); if (typeof resolver !== 'function') return true; - const currentEmail = normalizeEmail(currentUser?.email); + const currentIdentity = getCurrentUserIdentity(editor); return trackedChanges.every((change) => { - const authorEmail = normalizeEmail(change.attrs?.authorEmail); - const isOwn = !currentEmail || !authorEmail || currentEmail === authorEmail; + const classification = classifyOwnership({ + currentUser: currentIdentity, + change: getChangeAuthorIdentity(change.attrs ?? change), + }); + const isOwn = isSameUserHighConfidence(classification); const permission = derivePermissionKey({ action, isOwn }); if (!permission) return true; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/permission-helpers.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/permission-helpers.test.js index 926c0b815d..6fa56790bc 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/permission-helpers.test.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/permission-helpers.test.js @@ -101,8 +101,8 @@ describe('permission-helpers', () => { expect(mockResolver).toHaveBeenCalledTimes(2); }); - it('isTrackedChangeActionAllowed treats missing user email as own change', () => { - const mockResolver = vi.fn(({ permission }) => permission === 'RESOLVE_OWN'); + it('isTrackedChangeActionAllowed treats missing user email as other change', () => { + const mockResolver = vi.fn(({ permission }) => permission === 'RESOLVE_OTHER'); editor.options.permissionResolver = mockResolver; editor.options.user = null; @@ -114,12 +114,12 @@ describe('permission-helpers', () => { expect(result).toBe(true); expect(mockResolver).toHaveBeenCalledWith( - expect.objectContaining({ permission: 'RESOLVE_OWN', trackedChange: expect.any(Object) }), + expect.objectContaining({ permission: 'RESOLVE_OTHER', trackedChange: expect.any(Object) }), ); }); - it('isTrackedChangeActionAllowed treats missing author email as own change', () => { - const mockResolver = vi.fn(({ permission }) => permission === 'RESOLVE_OWN'); + it('isTrackedChangeActionAllowed treats missing author email as other change', () => { + const mockResolver = vi.fn(({ permission }) => permission === 'RESOLVE_OTHER'); editor.options.permissionResolver = mockResolver; const result = isTrackedChangeActionAllowed({ @@ -130,7 +130,7 @@ describe('permission-helpers', () => { expect(result).toBe(true); expect(mockResolver).toHaveBeenCalledWith( - expect.objectContaining({ permission: 'RESOLVE_OWN', trackedChange: expect.any(Object) }), + expect.objectContaining({ permission: 'RESOLVE_OTHER', trackedChange: expect.any(Object) }), ); }); @@ -151,6 +151,23 @@ describe('permission-helpers', () => { ); }); + it('treats same-email different actor ids as other-user changes', () => { + const mockResolver = vi.fn(() => true); + editor.options.permissionResolver = mockResolver; + editor.options.user = { id: 'owner-id', email: 'shared@example.com' }; + + const result = isTrackedChangeActionAllowed({ + editor, + action: 'accept', + trackedChanges: [{ id: 'case-match', attrs: { authorId: 'other-id', authorEmail: 'shared@example.com' } }], + }); + + expect(result).toBe(true); + expect(mockResolver).toHaveBeenCalledWith( + expect.objectContaining({ permission: 'RESOLVE_OTHER', trackedChange: expect.any(Object) }), + ); + }); + it('isTrackedChangeActionAllowed short-circuits when any resolver call denies access', () => { const mockResolver = vi.fn(({ permission }) => permission !== 'REJECT_OTHER'); editor.options.permissionResolver = mockResolver; @@ -167,4 +184,56 @@ describe('permission-helpers', () => { expect(result).toBe(false); expect(mockResolver).toHaveBeenCalledTimes(2); }); + + describe('overlap ownership routing', () => { + // Phase0-002 "Ownership Model": missing identity on either side must be + // treated as different-user under overlap. This is the documented + // customer-visible behavior. + beforeEach(() => { + editor.options.trackedChanges = { ...(editor.options.trackedChanges ?? {}) }; + }); + + it('missing change author email is no longer "own" under overlap', () => { + const mockResolver = vi.fn(({ permission }) => permission === 'RESOLVE_OTHER'); + editor.options.permissionResolver = mockResolver; + + const result = isTrackedChangeActionAllowed({ + editor, + action: 'accept', + trackedChanges: [{ id: 'no-author', attrs: { authorEmail: '' } }], + }); + + expect(result).toBe(true); + expect(mockResolver).toHaveBeenCalledWith(expect.objectContaining({ permission: 'RESOLVE_OTHER' })); + }); + + it('missing current user email is no longer "own" under overlap', () => { + const mockResolver = vi.fn(({ permission }) => permission === 'RESOLVE_OTHER'); + editor.options.permissionResolver = mockResolver; + editor.options.user = null; + + const result = isTrackedChangeActionAllowed({ + editor, + action: 'accept', + trackedChanges: [{ id: 'x', attrs: { authorEmail: 'someone@example.com' } }], + }); + + expect(result).toBe(true); + expect(mockResolver).toHaveBeenCalledWith(expect.objectContaining({ permission: 'RESOLVE_OTHER' })); + }); + + it('matching emails remain same-user under overlap', () => { + const mockResolver = vi.fn(({ permission }) => permission === 'RESOLVE_OWN'); + editor.options.permissionResolver = mockResolver; + + const result = isTrackedChangeActionAllowed({ + editor, + action: 'accept', + trackedChanges: [{ id: 'same', attrs: { authorEmail: 'owner@example.com' } }], + }); + + expect(result).toBe(true); + expect(mockResolver).toHaveBeenCalledWith(expect.objectContaining({ permission: 'RESOLVE_OWN' })); + }); + }); }); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/plugins/index.d.ts b/packages/super-editor/src/editors/v1/extensions/track-changes/plugins/index.d.ts index 156678d428..1a0c4e2d65 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/plugins/index.d.ts +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/plugins/index.d.ts @@ -1 +1,2 @@ export const TrackChangesBasePluginKey: any; +export function TrackChangesBasePlugin(): any; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/comment-effects.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/comment-effects.js new file mode 100644 index 0000000000..0d5eb6d178 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/comment-effects.js @@ -0,0 +1,163 @@ +// @ts-check +/** + * Comment effects plan computation for the tracked-change decision engine. + * + * Produces a {@link CommentEffectsPlan} describing how comment threads + * should change as a side effect of a tracked-change decision. Inputs are + * the planned coverage that will be removed/kept and the current PM doc; + * outputs are deterministic ids to delete or shrink plus PM-level anchor + * changes (removing `commentRangeStart` / `commentRangeEnd` / + * `commentReference` nodes that fall inside removed coverage). + * + * Boundary: this module does NOT mutate PM state. The decision engine + * applies the plan inside the atomic transaction once preflight succeeds. + */ + +/** + * @typedef {Object} CommentAnchorSpan + * @property {string} commentId + * @property {number} startPos `commentRangeStart` node position. + * @property {number} endPos `commentRangeEnd` node position. + * @property {number} referencePos `commentReference` position when present. + */ + +/** + * @typedef {Object} CommentNodeDelete + * @property {'commentRangeStart'|'commentRangeEnd'|'commentReference'} kind + * @property {number} from + * @property {number} to + * @property {string} commentId + */ + +/** + * @typedef {Object} CommentEffectsPlan + * @property {CommentNodeDelete[]} nodeDeletes PM ranges to remove. + * @property {Array<{ id: string, cause: string }>} entityDeletes Comment thread ids to remove. + * @property {Array<{ id: string, cause: string, anchor?: { from: number, to: number } }>} entityShrinks Comments whose anchor shrinks. + * @property {Array<{ id: string, cause: string }>} entityDetaches Comments whose anchor survives but should detach from tracked-change threading. + * @property {Array<{ code: string, message: string }>} diagnostics + */ + +const COMMENT_NODE_NAMES = new Set(['commentRangeStart', 'commentRangeEnd', 'commentReference']); + +/** + * Walk the document and produce the list of comment anchor spans. + * + * @param {import('prosemirror-model').Node} doc + * @returns {CommentAnchorSpan[]} + */ +export const enumerateCommentAnchors = (doc) => { + /** @type {Map} */ + const byId = new Map(); + if (!doc) return []; + doc.descendants((node, pos) => { + if (!COMMENT_NODE_NAMES.has(node.type.name)) return; + const id = node.attrs?.['w:id'] ?? node.attrs?.commentId ?? node.attrs?.attributes?.['w:id']; + if (id == null) return; + const key = String(id); + const existing = byId.get(key); + if (node.type.name === 'commentRangeStart') { + if (existing) { + existing.startPos = pos; + } else { + byId.set(key, { commentId: key, startPos: pos, endPos: -1, referencePos: -1 }); + } + } else if (node.type.name === 'commentRangeEnd') { + if (existing) { + existing.endPos = pos; + } else { + byId.set(key, { commentId: key, startPos: -1, endPos: pos, referencePos: -1 }); + } + } else if (node.type.name === 'commentReference') { + if (existing) { + existing.referencePos = pos; + } else { + byId.set(key, { commentId: key, startPos: -1, endPos: -1, referencePos: pos }); + } + } + }); + return Array.from(byId.values()); +}; + +/** + * Compute the comment effects plan for a set of PM ranges that will be + * removed from the document. Implements the rule subset required by + * phase0-004 Comment Effects and cross-feature-interactions.md: + * + * - comment anchor wholly inside removed coverage → delete the thread. + * - removed coverage spans the `commentRangeEnd` / `commentReference` + * boundary → delete the thread (asymmetric Word boundary rule). + * - removed coverage strictly inside an anchor (start preserved, end + * preserved) → shrink anchor; the anchor nodes themselves survive but + * the comment's coverage is reported as shrunken. + * - removed coverage at the left edge only → shrink anchor (commentRangeStart + * will be carried away by PM's range replacement; we still need to + * delete that anchor node and report a shrink with new bounds). + * + * @param {Object} input + * @param {import('prosemirror-model').Node} input.doc Document before mutation. + * @param {Array<{ from: number, to: number, cause: string }>} input.removedRanges Coverage to be removed. + * @param {Array<{ from: number, to: number, cause: string }>} [input.resolvedRanges] Full tracked-change coverage being retired while content may survive. + * @returns {CommentEffectsPlan} + */ +export const planCommentEffects = ({ doc, removedRanges, resolvedRanges = [] }) => { + /** @type {CommentEffectsPlan} */ + const plan = { nodeDeletes: [], entityDeletes: [], entityShrinks: [], entityDetaches: [], diagnostics: [] }; + if (!doc) return plan; + const anchors = enumerateCommentAnchors(doc); + if (!anchors.length) return plan; + + const sortedRemoved = [...removedRanges].filter((r) => r.from < r.to).sort((a, b) => a.from - b.from); + const sortedResolved = [...resolvedRanges].filter((r) => r.from < r.to).sort((a, b) => a.from - b.from); + if (!sortedRemoved.length && !sortedResolved.length) return plan; + + for (const anchor of anchors) { + const start = anchor.startPos; + const end = anchor.endPos; + const ref = anchor.referencePos; + if (start < 0 && end < 0 && ref < 0) continue; + const anchorStart = start >= 0 ? start : ref >= 0 ? ref : end; + const anchorEnd = end >= 0 ? end + 1 : ref >= 0 ? ref + 1 : start + 1; + const removedCause = sortedRemoved[0]?.cause ?? 'trackedChange'; + const resolvedMatch = sortedResolved.find((r) => r.from < anchorEnd && r.to > anchorStart); + const resolvedCause = resolvedMatch?.cause ?? removedCause; + + // Determine whether any removed range covers the anchor end or reference. + const removesEnd = sortedRemoved.some((r) => end >= 0 && r.from <= end && r.to > end); + const removesRef = sortedRemoved.some((r) => ref >= 0 && r.from <= ref && r.to > ref); + const fullyCovered = sortedRemoved.some((r) => r.from <= anchorStart && r.to >= anchorEnd); + + if (fullyCovered || removesEnd || removesRef) { + plan.entityDeletes.push({ id: anchor.commentId, cause: removedCause }); + if (start >= 0) + plan.nodeDeletes.push({ kind: 'commentRangeStart', from: start, to: start + 1, commentId: anchor.commentId }); + if (end >= 0) + plan.nodeDeletes.push({ kind: 'commentRangeEnd', from: end, to: end + 1, commentId: anchor.commentId }); + if (ref >= 0) + plan.nodeDeletes.push({ kind: 'commentReference', from: ref, to: ref + 1, commentId: anchor.commentId }); + continue; + } + + // Removed coverage overlaps the anchor but preserves end/reference: shrink. + const overlaps = sortedRemoved.some((r) => r.from < anchorEnd && r.to > anchorStart); + if (overlaps) { + plan.entityShrinks.push({ + id: anchor.commentId, + cause: removedCause, + anchor: { from: anchorStart, to: anchorEnd }, + }); + // We do not remove the commentRangeStart/End nodes themselves when only + // shrinking; PM will reposition them when surrounding text is removed. + if (resolvedMatch) { + plan.entityDetaches.push({ id: anchor.commentId, cause: resolvedCause }); + } + continue; + } + + if (resolvedMatch) { + plan.entityDetaches.push({ id: anchor.commentId, cause: resolvedCause }); + } + } + + return plan; +}; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/comment-effects.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/comment-effects.test.js new file mode 100644 index 0000000000..d2d8013710 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/comment-effects.test.js @@ -0,0 +1,73 @@ +// @ts-check +import { describe, expect, it } from 'vitest'; + +import { planCommentEffects } from './comment-effects.js'; + +const node = (name, id) => ({ + type: { name }, + attrs: { 'w:id': id }, +}); + +const fakeDoc = (entries) => ({ + descendants: (fn) => { + for (const entry of entries) { + fn(node(entry.name, entry.id), entry.pos); + } + }, +}); + +describe('planCommentEffects', () => { + it('deletes a comment thread when removed coverage wholly contains its anchor', () => { + const doc = fakeDoc([ + { name: 'commentRangeStart', id: 'c1', pos: 3 }, + { name: 'commentRangeEnd', id: 'c1', pos: 7 }, + { name: 'commentReference', id: 'c1', pos: 8 }, + ]); + + const result = planCommentEffects({ + doc, + removedRanges: [{ from: 3, to: 9, cause: 'reject-insertion:ins-1' }], + }); + + expect(result.entityDeletes).toEqual([{ id: 'c1', cause: 'reject-insertion:ins-1' }]); + expect(result.entityShrinks).toEqual([]); + expect(result.nodeDeletes.map(({ kind, from, to, commentId }) => ({ kind, from, to, commentId }))).toEqual([ + { kind: 'commentRangeStart', from: 3, to: 4, commentId: 'c1' }, + { kind: 'commentRangeEnd', from: 7, to: 8, commentId: 'c1' }, + { kind: 'commentReference', from: 8, to: 9, commentId: 'c1' }, + ]); + }); + + it('deletes a comment thread when removed coverage spans the end boundary', () => { + const doc = fakeDoc([ + { name: 'commentRangeStart', id: 'c2', pos: 3 }, + { name: 'commentRangeEnd', id: 'c2', pos: 7 }, + ]); + + const result = planCommentEffects({ + doc, + removedRanges: [{ from: 6, to: 8, cause: 'accept-deletion:del-1' }], + }); + + expect(result.entityDeletes).toEqual([{ id: 'c2', cause: 'accept-deletion:del-1' }]); + expect(result.entityShrinks).toEqual([]); + }); + + it('shrinks a comment thread when removed coverage is inside the anchor and preserves boundaries', () => { + const doc = fakeDoc([ + { name: 'commentRangeStart', id: 'c3', pos: 3 }, + { name: 'commentRangeEnd', id: 'c3', pos: 8 }, + ]); + + const result = planCommentEffects({ + doc, + removedRanges: [{ from: 5, to: 7, cause: 'partial-accept-deletion:del-2' }], + }); + + expect(result.entityDeletes).toEqual([]); + expect(result.nodeDeletes).toEqual([]); + expect(result.entityShrinks).toEqual([ + { id: 'c3', cause: 'partial-accept-deletion:del-2', anchor: { from: 3, to: 9 } }, + ]); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js new file mode 100644 index 0000000000..19d81f0346 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js @@ -0,0 +1,953 @@ +// @ts-check +/** + * Tracked-change decision engine. + * + * Single atomic entry point for accept/reject of one or more logical + * tracked changes within a story. Used by: + * + * - native commands (acceptTrackedChangeById / rejectTrackedChangeById / + * acceptTrackedChangesBetween / rejectTrackedChangesBetween / + * accept|rejectAllTrackedChanges) + * - document-api `trackChanges.decide` for id, range, and all targets + * - toolbar / context-menu wrappers via the existing resolveTrackedChangeAction + * + * Callers MUST treat `ok: false` as an abort and MUST NOT fall through to + * older mark-scan paths. + * + * The engine does NOT mutate PM state on failure. Preflight produces a + * complete mutation plan (PM ops + comment effects + bubble lifecycle + * payload + receipt entities) and the engine applies it under one + * transaction once preflight succeeds. + */ + +import { Slice } from 'prosemirror-model'; +import { AddMarkStep, RemoveMarkStep, ReplaceStep, Mapping } from 'prosemirror-transform'; + +import { TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName } from '../constants.js'; +import { CommentsPluginKey } from '../../comment/comments-plugin.js'; +import { TrackChangesBasePluginKey } from '../plugins/index.js'; +import { findMarkInRangeBySnapshot } from '../trackChangesHelpers/markSnapshotHelpers.js'; + +import { buildReviewGraph, CanonicalChangeType, SegmentSide } from './review-graph.js'; +import { graphHasErrors } from './graph-invariants.js'; +import { + classifyOwnership, + getCurrentUserIdentity, + getChangeAuthorIdentity, + isSameUserHighConfidence, +} from './identity.js'; +import { planCommentEffects } from './comment-effects.js'; + +/** + * @typedef {'accept'|'reject'} ReviewDecision + */ + +/** + * @typedef {{ kind: 'id', id: string } + * | { kind: 'range', from: number, to: number } + * | { kind: 'all' }} NormalizedDecisionTarget + */ + +/** + * @typedef {Object} DecisionDiagnostic + * @property {string} code + * @property {'info'|'warning'|'error'} severity + * @property {string} message + * @property {string[]} [changeIds] + * @property {unknown} [details] + */ + +/** + * @typedef {Object} DecisionReceiptEntities + * @property {string[]} createdChangeIds successor fragment ids minted by partial-range decisions. + * @property {string[]} updatedChangeIds changes whose surviving coverage changed. + * @property {Array<{ id: string, cause?: string }>} removedChangeIds retired logical change ids. + * @property {Array<{ id: string, cause: string }>} deletedComments comment threads removed as side effects. + * @property {Array<{ id: string, cause: string }>} detachedComments comment threads whose anchors survive but should detach from tracked-change threading. + * @property {Array<{ id: string, cause: string }>} shrunkenComments comment threads that shrank. + * @property {Array<{ changeId: string }>} affectedChildren child ids that retired with their parent. + */ + +/** + * @typedef {Object} DecisionResult + * @property {true} ok + * @property {import('prosemirror-state').Transaction} tr Pending transaction the caller dispatches. + * @property {DecisionReceiptEntities} receipt + * @property {Set} touchedChangeIds Ids the bubble lifecycle should refresh. + * @property {DecisionDiagnostic[]} diagnostics + */ + +/** + * @typedef {Object} DecisionFailure + * @property {false} ok + * @property {'TARGET_NOT_FOUND'|'INVALID_TARGET'|'REVISION_MISMATCH'|'PERMISSION_DENIED'|'CAPABILITY_UNAVAILABLE'|'PRECONDITION_FAILED'|'COMMENT_CASCADE_PARTIAL'|'NO_OP'} code + * @property {string} message + * @property {DecisionDiagnostic[]} [diagnostics] + * @property {unknown} [details] + */ + +const TRACKED_MARK_NAMES = new Set([TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName]); + +const failure = (code, message, extra) => ({ ok: false, code, message, ...(extra || {}) }); + +/** + * Plan and apply (or fail closed) a tracked-change decision. + * + * @param {Object} input + * @param {import('prosemirror-state').EditorState} input.state PM editor state at decision time. + * @param {object} input.editor v1 editor; used for permission resolver + identity context. + * @param {ReviewDecision} input.decision + * @param {object} input.target Raw target shape (id, range, all, legacy aliases). + * @param {'paired'|'independent'} [input.replacements] replacements mode. + * @returns {DecisionResult | DecisionFailure} + */ +export const decideTrackedChanges = ({ state, editor, decision, target, replacements = 'paired' }) => { + if (decision !== 'accept' && decision !== 'reject') { + return failure('INVALID_TARGET', `decision must be "accept" or "reject" (got "${String(decision)}").`); + } + + const normalized = normalizeDecisionTarget(target); + if (!normalized.ok) return normalized.failure; + + const graph = buildReviewGraph({ + state, + replacementsMode: replacements, + }); + if (graphHasErrors(graph)) { + return failure('PRECONDITION_FAILED', 'tracked review graph has invariant errors before decision.', { + diagnostics: graph.validate(), + }); + } + + // Resolve the target into a set of selections describing which logical + // changes get resolved and how (full vs partial coverage). The selections + // are deterministic across undo/redo and collaboration replay because they + // derive from logical change ids and normalized offsets, not transient PM + // positions. + const selectionResult = resolveTargetToSelections({ graph, normalized: normalized.value }); + if (!selectionResult.ok) return selectionResult.failure; + const { selections } = selectionResult; + if (!selections.length) { + return failure('TARGET_NOT_FOUND', 'no tracked changes match the requested decision target.'); + } + + // Permission preflight — call once per logical change. One denial aborts. + const permissionResult = runPermissionPreflight({ editor, decision, selections }); + if (!permissionResult.ok) return permissionResult.failure; + + // Compute the PM mutation plan + comment effects. + const planResult = buildMutationPlan({ state, graph, selections, decision, replacements }); + if (!planResult.ok) return planResult.failure; + const { plan } = planResult; + + // Apply the plan atomically. + const applyResult = applyPlan({ state, plan }); + if (!applyResult.ok) return applyResult.failure; + + return { + ok: true, + tr: applyResult.tr, + receipt: applyResult.receipt, + touchedChangeIds: applyResult.touchedChangeIds, + diagnostics: plan.diagnostics, + }; +}; + +// --------------------------------------------------------------------------- +// Target normalization +// --------------------------------------------------------------------------- + +/** + * Normalize the raw target shape into the canonical + * `{ kind: 'id'|'range'|'all' }` form. Accepts legacy aliases: + * - `{ id: string }` → `{ kind: 'id', id }` + * - `{ scope: 'all' }` → `{ kind: 'all' }` + * - `{ from, to }` → `{ kind: 'range', from, to }` + * - canonical `{ kind: 'id'|'range'|'all' }` is passed through. + */ +const normalizeDecisionTarget = (target) => { + if (!target || typeof target !== 'object') { + return { ok: false, failure: failure('INVALID_TARGET', 'decision target must be an object.') }; + } + const t = /** @type {Record} */ (target); + if (t.kind === 'id') { + if (typeof t.id !== 'string' || !t.id) { + return { ok: false, failure: failure('INVALID_TARGET', 'target.kind = "id" requires a non-empty id.') }; + } + return { ok: true, value: { kind: 'id', id: t.id } }; + } + if (t.kind === 'range') { + const from = Number(t.from); + const to = Number(t.to); + if (!Number.isFinite(from) || !Number.isFinite(to) || from < 0 || to < 0 || from > to) { + return { ok: false, failure: failure('INVALID_TARGET', 'target.kind = "range" requires from <= to.') }; + } + return { ok: true, value: { kind: 'range', from, to } }; + } + if (t.kind === 'all') { + return { ok: true, value: { kind: 'all' } }; + } + // Legacy aliases. + if (typeof t.id === 'string' && t.id) { + return { ok: true, value: { kind: 'id', id: t.id } }; + } + if (t.scope === 'all') { + return { ok: true, value: { kind: 'all' } }; + } + if (Number.isFinite(t.from) && Number.isFinite(t.to)) { + const from = Number(t.from); + const to = Number(t.to); + if (from > to) { + return { ok: false, failure: failure('INVALID_TARGET', 'range target requires from <= to.') }; + } + return { ok: true, value: { kind: 'range', from, to } }; + } + return { ok: false, failure: failure('INVALID_TARGET', 'decision target shape was not recognised.') }; +}; + +// --------------------------------------------------------------------------- +// Target → selections +// --------------------------------------------------------------------------- + +/** + * @typedef {Object} ChangeSelection + * @property {import('./review-graph.js').LogicalTrackedChange} change + * @property {'full'|'partial'} coverage `full` resolves whole logical change. + * @property {Array<{ from: number, to: number }>} ranges Concrete PM ranges to resolve. + */ + +const resolveTargetToSelections = ({ graph, normalized }) => { + if (normalized.kind === 'all') { + /** @type {ChangeSelection[]} */ + const sel = []; + for (const change of graph.changes.values()) { + sel.push({ change, coverage: 'full', ranges: change.segments.map((s) => ({ from: s.from, to: s.to })) }); + } + // Document-order sort to make the apply pass deterministic and to keep + // reverse-order step application stable. + sel.sort((a, b) => firstFrom(a) - firstFrom(b)); + return { ok: true, selections: sel }; + } + if (normalized.kind === 'id') { + const change = graph.changes.get(normalized.id); + if (!change) + return { ok: false, failure: failure('TARGET_NOT_FOUND', `no tracked change with id "${normalized.id}".`) }; + return { + ok: true, + selections: [ + { + change, + coverage: 'full', + ranges: change.segments.map((s) => ({ from: s.from, to: s.to })), + }, + ], + }; + } + // range + const { from, to } = normalized; + /** @type {Map} */ + const byId = new Map(); + for (const segment of graph.segments) { + const overlapFrom = Math.max(segment.from, from); + const overlapTo = Math.min(segment.to, to); + if (overlapFrom >= overlapTo) { + // collapsed cursor inside a segment also counts as "select whole change" + // per phase0-004 "Range Decisions": collapsed range inside a change + // resolves the whole logical change. + if (from === to && segment.from <= from && segment.to > from) { + const change = graph.changes.get(segment.changeId); + if (!change) continue; + const existing = byId.get(change.id); + if (existing) { + existing.coverage = 'full'; + existing.ranges = change.segments.map((s) => ({ from: s.from, to: s.to })); + } else { + byId.set(change.id, { + change, + coverage: 'full', + ranges: change.segments.map((s) => ({ from: s.from, to: s.to })), + }); + } + } + continue; + } + const change = graph.changes.get(segment.changeId); + if (!change) continue; + const existing = byId.get(change.id); + if (existing) { + existing.ranges.push({ from: overlapFrom, to: overlapTo }); + // Promote to full if the union covers all segments of the change. + if (rangesCoverChange(existing.ranges, change)) { + existing.coverage = 'full'; + existing.ranges = change.segments.map((s) => ({ from: s.from, to: s.to })); + } else { + existing.coverage = 'partial'; + } + continue; + } + const isFull = + segment.from >= from && segment.to <= to && change.segments.every((s) => s.from >= from && s.to <= to); + byId.set(change.id, { + change, + coverage: isFull ? 'full' : 'partial', + ranges: [{ from: overlapFrom, to: overlapTo }], + }); + } + // Sort selections by first PM position so apply order is deterministic. + const sel = Array.from(byId.values()).sort((a, b) => firstFrom(a) - firstFrom(b)); + return { ok: true, selections: sel }; +}; + +const firstFrom = (selection) => selection.ranges[0]?.from ?? 0; + +const rangesCoverChange = (ranges, change) => { + const sorted = [...ranges].sort((a, b) => a.from - b.from); + // Merge into max envelope ranges + /** @type {Array<{from:number,to:number}>} */ + const merged = []; + for (const r of sorted) { + const last = merged[merged.length - 1]; + if (last && r.from <= last.to) { + last.to = Math.max(last.to, r.to); + } else { + merged.push({ from: r.from, to: r.to }); + } + } + return change.segments.every((seg) => merged.some((r) => r.from <= seg.from && r.to >= seg.to)); +}; + +// --------------------------------------------------------------------------- +// Permission preflight +// --------------------------------------------------------------------------- + +const runPermissionPreflight = ({ editor, decision, selections }) => { + const resolver = editor?.options?.permissionResolver; + if (typeof resolver !== 'function') return { ok: true }; + + const role = editor.options?.role ?? 'editor'; + const isInternal = Boolean(editor.options?.isInternal); + const currentIdentity = getCurrentUserIdentity(editor); + + for (const selection of selections) { + const change = selection.change; + const classification = classifyOwnership({ + currentUser: currentIdentity, + change: getChangeAuthorIdentity({ + author: change.author, + authorId: change.authorId, + authorEmail: change.authorEmail, + importedAuthor: change.importedAuthor, + }), + }); + const isOwn = isSameUserHighConfidence(classification); + const permission = + decision === 'accept' ? (isOwn ? 'RESOLVE_OWN' : 'RESOLVE_OTHER') : isOwn ? 'REJECT_OWN' : 'REJECT_OTHER'; + + const allowed = resolver({ + permission, + role, + isInternal, + trackedChange: { + id: change.id, + type: change.type, + attrs: { author: change.author, authorId: change.authorId, authorEmail: change.authorEmail, date: change.date }, + from: selection.ranges[0]?.from ?? 0, + to: selection.ranges[selection.ranges.length - 1]?.to ?? 0, + segments: change.segments.map((s) => ({ from: s.from, to: s.to })), + commentId: change.id, + }, + comment: null, + }); + if (allowed === false) { + return { + ok: false, + failure: failure('PERMISSION_DENIED', `permission denied for ${decision} of change "${change.id}".`, { + details: { changeId: change.id, permission }, + }), + }; + } + } + return { ok: true }; +}; + +// --------------------------------------------------------------------------- +// Mutation plan +// --------------------------------------------------------------------------- + +/** + * @typedef {Object} MutationOp + * @property {'removeContent'|'removeMark'|'addMark'|'unwrapInsert'|'restoreFormat'|'removeFormat'} kind + * @property {number} from + * @property {number} to + * @property {string} [changeId] + * @property {string} [side] + * @property {import('prosemirror-model').Mark} [mark] + * @property {Array} [beforeMarks] + * @property {Array} [afterMarks] + */ + +/** + * @typedef {Object} MutationPlan + * @property {MutationOp[]} ops Document-order op list. + * @property {import('./comment-effects.js').CommentEffectsPlan & { _affectedChildren?: Array<{ changeId: string }> }} commentEffects + * @property {Set} touchedChangeIds Logical ids retired/updated by the decision. + * @property {Set} retiredChangeIds Logical ids retired by the decision (subset). + * @property {DecisionDiagnostic[]} diagnostics + */ + +const buildMutationPlan = ({ state, graph, selections, decision, replacements }) => { + /** @type {MutationOp[]} */ + const ops = []; + /** @type {Array<{ from: number, to: number, cause: string }>} */ + const removedRanges = []; + /** @type {Array<{ from: number, to: number, cause: string }>} */ + const resolvedRanges = []; + /** @type {Set} */ + const touched = new Set(); + /** @type {Set} */ + const retired = new Set(); + /** @type {DecisionDiagnostic[]} */ + const diagnostics = []; + + for (const selection of selections) { + const { change } = selection; + const isFull = selection.coverage === 'full'; + if (!isFull) { + if (change.type === CanonicalChangeType.Replacement) { + return { + ok: false, + failure: failure( + 'CAPABILITY_UNAVAILABLE', + 'partial-range replacement decisions are not yet fixture-backed.', + { + details: { changeId: change.id }, + }, + ), + }; + } + if (change.type === CanonicalChangeType.Formatting) { + return { + ok: false, + failure: failure('CAPABILITY_UNAVAILABLE', 'partial-range formatting decisions are not yet fixture-backed.', { + details: { changeId: change.id }, + }), + }; + } + } + touched.add(change.id); + if (isFull) { + for (const segment of change.segments) { + resolvedRanges.push({ + from: segment.from, + to: segment.to, + cause: `${decision}:${change.id}`, + }); + } + } + + if (!isFull && (change.type === CanonicalChangeType.Insertion || change.type === CanonicalChangeType.Deletion)) { + const partialResult = planPartialTextDecision({ + ops, + change, + selection, + decision, + removedRanges, + retired, + }); + if (!partialResult.ok) return { ok: false, failure: partialResult.failure }; + for (const id of partialResult.createdChangeIds) touched.add(id); + } else if (change.type === CanonicalChangeType.Insertion) { + planInsertionDecision({ ops, change, selection, decision, removedRanges, retired }); + } else if (change.type === CanonicalChangeType.Deletion) { + planDeletionDecision({ ops, change, selection, decision, removedRanges, retired }); + } else if (change.type === CanonicalChangeType.Replacement) { + const repResult = planReplacementDecision({ ops, change, decision, removedRanges, retired }); + if (!repResult.ok) return { ok: false, failure: repResult.failure }; + } else if (change.type === CanonicalChangeType.Formatting) { + planFormattingDecision({ ops, change, decision, retired }); + } else { + return { + ok: false, + failure: failure( + 'CAPABILITY_UNAVAILABLE', + `unsupported change type "${change.type}" for change "${change.id}".`, + ), + }; + } + } + + if (!ops.length) { + return { + ok: false, + failure: failure('NO_OP', 'decision target produced no operations.', { + details: { selections: selections.map((s) => s.change.id) }, + }), + }; + } + + // Identify child changes wholly inside removed ranges and mark them as + // retired side effects. Per phase0-004 "Parent/Child Decision Rules": + // accepting/rejecting a parent insertion retires children inside removed + // content; accepting a parent deletion retires children wholly inside the + // removed content; rejecting a parent deletion removes child insertions + // that were meaningful only inside it. + /** @type {Array<{ changeId: string }>} */ + const affectedChildren = []; + for (const change of graph.changes.values()) { + if (touched.has(change.id)) continue; + if (!change.parent) continue; + if (!retired.has(change.parent) && !touched.has(change.parent)) continue; + const inside = change.segments.every((seg) => removedRanges.some((r) => r.from <= seg.from && r.to >= seg.to)); + if (inside) { + retired.add(change.id); + touched.add(change.id); + affectedChildren.push({ changeId: change.id }); + } + } + + const commentEffects = planCommentEffects({ doc: state.doc, removedRanges, resolvedRanges }); + + // Convert comment node deletions into removeContent ops so apply respects + // the same reverse-order pass. Removing the anchor nodes from inside + // already-removed coverage is harmless (PM clips); explicitly listing them + // makes shrink+keep cases idempotent. + for (const del of commentEffects.nodeDeletes) { + ops.push({ kind: 'removeContent', from: del.from, to: del.to }); + } + + /** @type {MutationPlan} */ + const plan = { + ops, + commentEffects: { ...commentEffects, _affectedChildren: affectedChildren }, + touchedChangeIds: touched, + retiredChangeIds: retired, + diagnostics, + }; + return { ok: true, plan }; +}; + +const planInsertionDecision = ({ ops, change, selection, decision, removedRanges, retired }) => { + const isFull = selection.coverage === 'full'; + if (decision === 'accept') { + // Accept insertion: keep content, remove the trackInsert mark. + const ranges = isFull ? change.insertedSegments.map((s) => ({ from: s.from, to: s.to })) : selection.ranges; + for (const range of ranges) { + pushRemoveMarkOpsForRange({ + ops, + segments: change.insertedSegments, + range, + changeId: change.id, + side: SegmentSide.Inserted, + }); + } + if (isFull) retired.add(change.id); + return; + } + // Reject insertion: remove inserted content. + const ranges = isFull ? change.insertedSegments.map((s) => ({ from: s.from, to: s.to })) : selection.ranges; + for (const range of ranges) { + ops.push({ + kind: 'removeContent', + from: range.from, + to: range.to, + changeId: change.id, + side: SegmentSide.Inserted, + }); + removedRanges.push({ from: range.from, to: range.to, cause: `reject-insertion:${change.id}` }); + } + if (isFull) retired.add(change.id); +}; + +const planDeletionDecision = ({ ops, change, selection, decision, removedRanges, retired }) => { + const isFull = selection.coverage === 'full'; + if (decision === 'accept') { + // Accept deletion: remove tracked-deleted content permanently. + const ranges = isFull ? change.deletedSegments.map((s) => ({ from: s.from, to: s.to })) : selection.ranges; + for (const range of ranges) { + ops.push({ + kind: 'removeContent', + from: range.from, + to: range.to, + changeId: change.id, + side: SegmentSide.Deleted, + }); + removedRanges.push({ from: range.from, to: range.to, cause: `accept-deletion:${change.id}` }); + } + if (isFull) retired.add(change.id); + return; + } + // Reject deletion: remove the trackDelete mark; content stays as live. + const ranges = isFull ? change.deletedSegments.map((s) => ({ from: s.from, to: s.to })) : selection.ranges; + for (const range of ranges) { + pushRemoveMarkOpsForRange({ + ops, + segments: change.deletedSegments, + range, + changeId: change.id, + side: SegmentSide.Deleted, + }); + } + if (isFull) retired.add(change.id); +}; + +const planReplacementDecision = ({ ops, change, decision, removedRanges, retired }) => { + const inserted = change.insertedSegments; + const deleted = change.deletedSegments; + if (!inserted.length || !deleted.length) { + return { + ok: false, + failure: failure('PRECONDITION_FAILED', `replacement "${change.id}" missing inserted or deleted side.`), + }; + } + if (decision === 'accept') { + for (const seg of deleted) { + ops.push({ kind: 'removeContent', from: seg.from, to: seg.to, changeId: change.id, side: SegmentSide.Deleted }); + removedRanges.push({ from: seg.from, to: seg.to, cause: `accept-replacement-deleted:${change.id}` }); + } + for (const seg of inserted) { + pushRemoveMarkOpsForSegment({ + ops, + segment: seg, + changeId: change.id, + side: SegmentSide.Inserted, + }); + } + } else { + // Reject replacement: remove inserted side, restore deleted side as live. + for (const seg of inserted) { + ops.push({ kind: 'removeContent', from: seg.from, to: seg.to, changeId: change.id, side: SegmentSide.Inserted }); + removedRanges.push({ from: seg.from, to: seg.to, cause: `reject-replacement-inserted:${change.id}` }); + } + for (const seg of deleted) { + pushRemoveMarkOpsForSegment({ + ops, + segment: seg, + changeId: change.id, + side: SegmentSide.Deleted, + }); + } + } + retired.add(change.id); + return { ok: true }; +}; + +const planFormattingDecision = ({ ops, change, decision, retired }) => { + for (const seg of change.formattingSegments) { + if (decision === 'accept') { + pushRemoveMarkOpsForSegment({ + ops, + segment: seg, + changeId: change.id, + side: SegmentSide.Formatting, + }); + } else { + for (const run of getSegmentMarkRuns(seg)) { + ops.push({ + kind: 'restoreFormat', + from: run.from, + to: run.to, + changeId: change.id, + side: SegmentSide.Formatting, + mark: run.mark, + beforeMarks: run.mark.attrs?.before ?? [], + afterMarks: run.mark.attrs?.after ?? [], + }); + } + } + } + retired.add(change.id); +}; + +const planPartialTextDecision = ({ ops, change, selection, decision, removedRanges, retired }) => { + const side = change.type === CanonicalChangeType.Insertion ? SegmentSide.Inserted : SegmentSide.Deleted; + const segments = side === SegmentSide.Inserted ? change.insertedSegments : change.deletedSegments; + if (!segments.length) { + return { ok: false, failure: failure('PRECONDITION_FAILED', `change "${change.id}" has no ${side} segments.`) }; + } + + const selectedRanges = mergeRanges(selection.ranges); + const successorRanges = []; + let logicalOffset = 0; + let successorOrdinal = 0; + + for (const segment of segments) { + pushRemoveMarkOpsForSegment({ + ops, + segment, + changeId: change.id, + side, + }); + + const pieces = subtractRanges({ from: segment.from, to: segment.to }, selectedRanges); + for (const piece of pieces) { + const offsetStart = logicalOffset + (piece.from - segment.from); + const offsetEnd = logicalOffset + (piece.to - segment.from); + const successorId = deterministicSuccessorId({ + sourceId: change.id, + revisionGroupId: segment.attrs?.revisionGroupId || change.revisionGroupId || change.id, + side, + offsetStart, + offsetEnd, + decision, + ordinal: successorOrdinal, + }); + successorOrdinal += 1; + const successorMark = segment.mark.type.create({ + ...segment.mark.attrs, + id: successorId, + splitFromId: change.id, + revisionGroupId: segment.attrs?.revisionGroupId || change.revisionGroupId || change.id, + }); + ops.push({ kind: 'addMark', from: piece.from, to: piece.to, changeId: successorId, side, mark: successorMark }); + successorRanges.push({ id: successorId, from: piece.from, to: piece.to }); + } + logicalOffset += segment.to - segment.from; + } + + for (const range of selectedRanges) { + if (change.type === CanonicalChangeType.Insertion && decision === 'reject') { + ops.push({ kind: 'removeContent', from: range.from, to: range.to, changeId: change.id, side }); + removedRanges.push({ from: range.from, to: range.to, cause: `partial-reject-insertion:${change.id}` }); + } else if (change.type === CanonicalChangeType.Deletion && decision === 'accept') { + ops.push({ kind: 'removeContent', from: range.from, to: range.to, changeId: change.id, side }); + removedRanges.push({ from: range.from, to: range.to, cause: `partial-accept-deletion:${change.id}` }); + } + } + + retired.add(change.id); + return { ok: true, createdChangeIds: successorRanges.map((entry) => entry.id) }; +}; + +const pushRemoveMarkOpsForRange = ({ ops, segments, range, changeId, side }) => { + for (const segment of segments) { + if (segment.to <= range.from || segment.from >= range.to) continue; + pushRemoveMarkOpsForSegment({ + ops, + segment, + changeId, + side, + from: Math.max(segment.from, range.from), + to: Math.min(segment.to, range.to), + }); + } +}; + +const pushRemoveMarkOpsForSegment = ({ ops, segment, changeId, side, from = segment.from, to = segment.to }) => { + for (const run of getSegmentMarkRuns(segment)) { + const clippedFrom = Math.max(from, run.from); + const clippedTo = Math.min(to, run.to); + if (clippedFrom >= clippedTo) continue; + ops.push({ + kind: 'removeMark', + from: clippedFrom, + to: clippedTo, + changeId, + side, + mark: run.mark, + }); + } +}; + +const getSegmentMarkRuns = (segment) => { + return segment.markRuns?.length ? segment.markRuns : [{ from: segment.from, to: segment.to, mark: segment.mark }]; +}; + +const mergeRanges = (ranges) => { + const sorted = ranges + .filter((range) => range.from < range.to) + .map((range) => ({ from: range.from, to: range.to })) + .sort((a, b) => a.from - b.from || a.to - b.to); + const merged = []; + for (const range of sorted) { + const last = merged[merged.length - 1]; + if (last && range.from <= last.to) { + last.to = Math.max(last.to, range.to); + } else { + merged.push(range); + } + } + return merged; +}; + +const subtractRanges = (range, removals) => { + let pieces = [range]; + for (const removal of removals) { + const next = []; + for (const piece of pieces) { + if (removal.to <= piece.from || removal.from >= piece.to) { + next.push(piece); + continue; + } + if (removal.from > piece.from) next.push({ from: piece.from, to: Math.min(removal.from, piece.to) }); + if (removal.to < piece.to) next.push({ from: Math.max(removal.to, piece.from), to: piece.to }); + } + pieces = next; + } + return pieces.filter((piece) => piece.from < piece.to); +}; + +const deterministicSuccessorId = ({ sourceId, revisionGroupId, side, offsetStart, offsetEnd, decision, ordinal }) => { + const input = `${sourceId}|${revisionGroupId}|${side}|${offsetStart}|${offsetEnd}|${decision}|${ordinal}`; + let hash = 2166136261; + for (let i = 0; i < input.length; i += 1) { + hash ^= input.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return `${sourceId}~${side}~${(hash >>> 0).toString(36)}`; +}; + +// --------------------------------------------------------------------------- +// Plan application +// --------------------------------------------------------------------------- + +const applyPlan = ({ state, plan }) => { + const tr = state.tr; + tr.setMeta('inputType', 'acceptReject'); + + // Mark mutations are position-stable and must run before content deletion: + // partial decisions remove the source mark, add successor marks to surviving + // text, then delete only the selected text when the decision semantics ask + // for removal. Applying all ops in one reversed pass can make a broad source + // mark removal erase freshly-added successor marks. + const sortedOps = [...plan.ops].sort((a, b) => a.from - b.from || a.to - b.to); + const markOps = sortedOps.filter((op) => op.kind !== 'removeContent'); + const contentOps = sortedOps.filter((op) => op.kind === 'removeContent').reverse(); + + try { + for (const op of markOps) { + if (op.kind === 'removeMark' && op.mark) { + tr.step(new RemoveMarkStep(op.from, op.to, op.mark)); + continue; + } + if (op.kind === 'addMark' && op.mark) { + tr.step(new AddMarkStep(op.from, op.to, op.mark)); + continue; + } + if (op.kind === 'restoreFormat' && op.mark) { + // Remove the "after" marks first so the restored "before" marks aren't + // shadowed by overlap matching (mirrors legacy rejectTrackedChangesBetween). + for (const afterSnapshot of op.afterMarks ?? []) { + const liveMark = findMarkInRangeBySnapshot({ + doc: tr.doc, + from: op.from, + to: op.to, + snapshot: afterSnapshot, + }); + if (liveMark) { + tr.step(new RemoveMarkStep(op.from, op.to, liveMark)); + } + } + for (const beforeSnapshot of op.beforeMarks ?? []) { + const markType = state.schema.marks[beforeSnapshot.type]; + if (!markType) continue; + tr.step(new AddMarkStep(op.from, op.to, markType.create(beforeSnapshot.attrs))); + } + tr.step(new RemoveMarkStep(op.from, op.to, op.mark)); + continue; + } + } + for (const op of contentOps) { + tr.step(new ReplaceStep(op.from, op.to, Slice.empty)); + } + } catch (error) { + return { + ok: false, + failure: failure( + 'PRECONDITION_FAILED', + /** @type {Error} */ (error).message ?? 'failed to apply mutation plan.', + { + details: { error: String(error) }, + }, + ), + }; + } + + // Tracked-change plugin meta — preserve compatibility with the comments + // plugin which listens for tracked-change resolution to update bubbles. + tr.setMeta(TrackChangesBasePluginKey, { + insertedMark: null, + deletionMark: null, + deletionNodes: [], + step: null, + emitCommentEvent: true, + decisionTouchedChangeIds: Array.from(plan.touchedChangeIds), + decisionRetiredChangeIds: Array.from(plan.retiredChangeIds), + }); + tr.setMeta(CommentsPluginKey, { type: 'force' }); + tr.setMeta('skipTrackChanges', true); + + return { + ok: true, + tr, + touchedChangeIds: plan.touchedChangeIds, + receipt: buildReceipt({ plan }), + }; +}; + +const buildReceipt = ({ plan }) => { + /** @type {DecisionReceiptEntities} */ + const receipt = { + createdChangeIds: collectCreatedChangeIds(plan), + updatedChangeIds: [], + removedChangeIds: Array.from(plan.retiredChangeIds).map((id) => ({ id, cause: 'decision' })), + deletedComments: plan.commentEffects.entityDeletes, + detachedComments: plan.commentEffects.entityDetaches, + shrunkenComments: plan.commentEffects.entityShrinks.map(({ id, cause }) => ({ id, cause })), + affectedChildren: plan.commentEffects._affectedChildren ?? [], + }; + return receipt; +}; + +const collectCreatedChangeIds = (plan) => { + const ids = new Set(); + for (const op of plan.ops) { + if (op.kind === 'addMark' && op.changeId) ids.add(op.changeId); + } + return Array.from(ids); +}; + +// --------------------------------------------------------------------------- +// Bubble lifecycle support +// --------------------------------------------------------------------------- + +/** + * Build the bubble lifecycle payload from a successful decision result. The + * caller (acceptTrackedChangesBetween wrapper) emits this through the editor + * once the transaction dispatches so consumers update from decision data, + * not from re-scanning marks after dispatch. + * + * @param {Object} input + * @param {DecisionResult} input.result + * @param {object} input.editor + * @returns {Array<{ type: 'trackedChange', event: 'resolve'|'update', changeId: string, resolvedById?: string, resolvedByEmail?: string, resolvedByName?: string }>} + */ +export const buildDecisionBubbleEvents = ({ result, editor }) => { + const resolvedById = editor?.options?.user?.id; + const resolvedByEmail = editor?.options?.user?.email; + const resolvedByName = editor?.options?.user?.name; + /** @type {Array<{ type: 'trackedChange', event: 'resolve'|'update', changeId: string, resolvedById?: string, resolvedByEmail?: string, resolvedByName?: string }>} */ + const events = []; + for (const entry of result.receipt.removedChangeIds) { + events.push({ + type: 'trackedChange', + event: 'resolve', + changeId: entry.id, + resolvedById, + resolvedByEmail, + resolvedByName, + }); + } + for (const child of result.receipt.affectedChildren) { + events.push({ + type: 'trackedChange', + event: 'resolve', + changeId: child.changeId, + resolvedById, + resolvedByEmail, + resolvedByName, + }); + } + return events; +}; + +export { TRACKED_MARK_NAMES }; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.test.js new file mode 100644 index 0000000000..cf14ff45cf --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.test.js @@ -0,0 +1,645 @@ +// @ts-check +/** + * Overlap decision engine unit tests. + * + * Validates the canonical accept/reject behavior, atomicity, partial-range + * shape, parent/child rules, and comment effects produced by the + * decision engine against a real PM schema with tracked marks. + */ + +import { describe, it, expect } from 'vitest'; + +import { decideTrackedChanges } from './decision-engine.js'; +import { buildReviewGraph } from './review-graph.js'; +import { createReviewGraphTestSchema, stateFromTrackedSpans, markAttrs } from './test-fixtures.js'; +import { TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName } from '../constants.js'; + +const SAME_USER = { name: 'Alice', email: 'alice@example.com' }; +const OTHER_USER = { name: 'Bob', email: 'bob@example.com' }; + +const editorFor = (user, extra) => ({ + options: { + user, + trackedChanges: {}, + ...extra, + }, + storage: { trackChanges: { lastDecisionFailure: null } }, +}); + +const insertAttrs = (id, user = SAME_USER, extra = {}) => + markAttrs({ + id, + author: user.name, + authorEmail: user.email, + revisionGroupId: id, + changeType: 'insertion', + ...extra, + }); + +const deleteAttrs = (id, user = SAME_USER, extra = {}) => + markAttrs({ + id, + author: user.name, + authorEmail: user.email, + revisionGroupId: id, + changeType: 'deletion', + ...extra, + }); + +const formatAttrsWithSnapshots = (id, user = SAME_USER, before = [], after = []) => ({ + ...markAttrs({ + id, + author: user.name, + authorEmail: user.email, + revisionGroupId: id, + changeType: 'formatting', + }), + before, + after, +}); + +describe('decideTrackedChanges overlap behavior', () => { + it('accept insertion by id keeps content and removes the trackInsert mark', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'before ' }, + { text: 'NEW', marks: [{ markType: TrackInsertMarkName, attrs: insertAttrs('ins-1') }] }, + { text: ' after' }, + ], + }); + const editor = editorFor(SAME_USER); + + const result = decideTrackedChanges({ + state, + editor, + decision: 'accept', + target: { kind: 'id', id: 'ins-1' }, + }); + expect(result.ok).toBe(true); + expect(result.receipt.removedChangeIds).toEqual([{ id: 'ins-1', cause: 'decision' }]); + const nextState = state.apply(result.tr); + expect(nextState.doc.textContent).toBe('before NEW after'); + nextState.doc.nodesBetween(0, nextState.doc.content.size, (node) => { + if (node.isText) { + for (const mark of node.marks) expect(mark.type.name).not.toBe(TrackInsertMarkName); + } + }); + }); + + it('accept insertion by id removes every same-id segment even when attrs differ across split text nodes', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'before ' }, + { + text: 'N', + marks: [ + { + markType: TrackInsertMarkName, + attrs: markAttrs({ + id: 'ins-split', + author: SAME_USER.name, + authorEmail: SAME_USER.email, + }), + }, + ], + }, + { text: 'EW', marks: [{ markType: TrackInsertMarkName, attrs: insertAttrs('ins-split') }] }, + { text: ' after' }, + ], + }); + + const result = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'accept', + target: { kind: 'id', id: 'ins-split' }, + }); + + expect(result.ok).toBe(true); + const nextState = state.apply(result.tr); + expect(nextState.doc.textContent).toBe('before NEW after'); + nextState.doc.nodesBetween(0, nextState.doc.content.size, (node) => { + if (node.isText) { + for (const mark of node.marks) expect(mark.type.name).not.toBe(TrackInsertMarkName); + } + }); + }); + + it('reject insertion by id removes inserted content atomically', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'keep ' }, + { text: 'BAD', marks: [{ markType: TrackInsertMarkName, attrs: insertAttrs('ins-2') }] }, + { text: ' tail' }, + ], + }); + const editor = editorFor(SAME_USER); + + const result = decideTrackedChanges({ + state, + editor, + decision: 'reject', + target: { kind: 'id', id: 'ins-2' }, + }); + expect(result.ok).toBe(true); + const next = state.apply(result.tr); + expect(next.doc.textContent).toBe('keep tail'); + }); + + it('accept deletion removes content; reject deletion drops the mark and keeps content', () => { + const schema = createReviewGraphTestSchema(); + const accept = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'A ' }, + { text: 'CUT', marks: [{ markType: TrackDeleteMarkName, attrs: deleteAttrs('del-1') }] }, + { text: ' B' }, + ], + }); + const editor = editorFor(SAME_USER); + const acceptResult = decideTrackedChanges({ + state: accept.state, + editor, + decision: 'accept', + target: { kind: 'id', id: 'del-1' }, + }); + expect(acceptResult.ok).toBe(true); + expect(accept.state.apply(acceptResult.tr).doc.textContent).toBe('A B'); + + const reject = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'A ' }, + { text: 'CUT', marks: [{ markType: TrackDeleteMarkName, attrs: deleteAttrs('del-2') }] }, + { text: ' B' }, + ], + }); + const rejectResult = decideTrackedChanges({ + state: reject.state, + editor, + decision: 'reject', + target: { kind: 'id', id: 'del-2' }, + }); + expect(rejectResult.ok).toBe(true); + const next = reject.state.apply(rejectResult.tr); + expect(next.doc.textContent).toBe('A CUT B'); + next.doc.nodesBetween(0, next.doc.content.size, (node) => { + if (node.isText) { + for (const mark of node.marks) expect(mark.type.name).not.toBe(TrackDeleteMarkName); + } + }); + }); + + it('accept all retires every open change', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'pre ' }, + { text: 'INS', marks: [{ markType: TrackInsertMarkName, attrs: insertAttrs('ins-3') }] }, + { text: ' mid ' }, + { text: 'DEL', marks: [{ markType: TrackDeleteMarkName, attrs: deleteAttrs('del-3') }] }, + { text: ' post' }, + ], + }); + const result = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'accept', + target: { kind: 'all' }, + }); + expect(result.ok).toBe(true); + const ids = result.receipt.removedChangeIds.map((entry) => entry.id).sort(); + expect(ids).toEqual(['del-3', 'ins-3']); + expect(state.apply(result.tr).doc.textContent).toBe('pre INS mid post'); + }); + + it('accept paired replacement removes deleted side and keeps inserted side', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'a ' }, + { + text: 'OLD', + marks: [ + { + markType: TrackDeleteMarkName, + attrs: deleteAttrs('rep-1', SAME_USER, { + changeType: 'replacement', + replacementGroupId: 'rep-1', + replacementSideId: 'rep-1#deleted', + }), + }, + ], + }, + { + text: 'NEW', + marks: [ + { + markType: TrackInsertMarkName, + attrs: insertAttrs('rep-1', SAME_USER, { + changeType: 'replacement', + replacementGroupId: 'rep-1', + replacementSideId: 'rep-1#inserted', + }), + }, + ], + }, + { text: ' b' }, + ], + }); + const result = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'accept', + target: { kind: 'id', id: 'rep-1' }, + }); + expect(result.ok).toBe(true); + const next = state.apply(result.tr); + expect(next.doc.textContent).toBe('a NEW b'); + }); + + it('reject paired replacement restores deleted side and removes inserted side', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'a ' }, + { + text: 'OLD', + marks: [ + { + markType: TrackDeleteMarkName, + attrs: deleteAttrs('rep-2', SAME_USER, { + changeType: 'replacement', + replacementGroupId: 'rep-2', + replacementSideId: 'rep-2#deleted', + }), + }, + ], + }, + { + text: 'NEW', + marks: [ + { + markType: TrackInsertMarkName, + attrs: insertAttrs('rep-2', SAME_USER, { + changeType: 'replacement', + replacementGroupId: 'rep-2', + replacementSideId: 'rep-2#inserted', + }), + }, + ], + }, + { text: ' b' }, + ], + }); + const result = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'reject', + target: { kind: 'id', id: 'rep-2' }, + }); + expect(result.ok).toBe(true); + expect(state.apply(result.tr).doc.textContent).toBe('a OLD b'); + }); + + it('formatting accept removes the trackFormat mark; reject restores the before snapshot', () => { + const schema = createReviewGraphTestSchema(); + const beforeSnap = [{ type: TrackInsertMarkName, attrs: insertAttrs('inner-ins') }]; + const afterSnap = [{ type: TrackInsertMarkName, attrs: insertAttrs('inner-ins-new') }]; + // To exercise mark restoration we'd need a richer mark set; here we just + // verify the engine handles formatting decisions structurally. + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { + text: 'FORMATTED', + marks: [ + { + markType: TrackFormatMarkName, + attrs: formatAttrsWithSnapshots('fmt-1', SAME_USER, beforeSnap, afterSnap), + }, + ], + }, + ], + }); + const accept = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'accept', + target: { kind: 'id', id: 'fmt-1' }, + }); + expect(accept.ok).toBe(true); + const next = state.apply(accept.tr); + next.doc.nodesBetween(0, next.doc.content.size, (node) => { + if (node.isText) { + for (const mark of node.marks) expect(mark.type.name).not.toBe(TrackFormatMarkName); + } + }); + + const reject = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'reject', + target: { kind: 'id', id: 'fmt-1' }, + }); + expect(reject.ok).toBe(true); + }); + + it('range target resolving fully-covered insertion is treated as full coverage', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'A ' }, + { text: 'XYZ', marks: [{ markType: TrackInsertMarkName, attrs: insertAttrs('ins-r1') }] }, + { text: ' B' }, + ], + }); + const result = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'accept', + target: { kind: 'range', from: 0, to: state.doc.content.size }, + }); + expect(result.ok).toBe(true); + expect(result.receipt.removedChangeIds[0]?.id).toBe('ins-r1'); + }); + + it('range target with collapsed cursor inside change resolves whole change', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'A ' }, + { text: 'NEW', marks: [{ markType: TrackInsertMarkName, attrs: insertAttrs('ins-r2') }] }, + { text: ' B' }, + ], + }); + // Find a position inside the insertion (somewhere between offset 3 and 6). + const cursor = 4; + const result = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'accept', + target: { kind: 'range', from: cursor, to: cursor }, + }); + expect(result.ok).toBe(true); + expect(result.receipt.removedChangeIds[0]?.id).toBe('ins-r2'); + }); + + it('partial accept of an insertion retires the source id and mints successor fragments', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'A ' }, + { text: 'XYZ', marks: [{ markType: TrackInsertMarkName, attrs: insertAttrs('ins-partial-accept') }] }, + { text: ' B' }, + ], + }); + + const result = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'accept', + target: { kind: 'range', from: 4, to: 5 }, + }); + + expect(result.ok).toBe(true); + expect(result.receipt.removedChangeIds).toEqual([{ id: 'ins-partial-accept', cause: 'decision' }]); + expect(result.receipt.createdChangeIds).toHaveLength(2); + const next = state.apply(result.tr); + expect(next.doc.textContent).toBe('A XYZ B'); + const graph = buildReviewGraph({ state: next }); + expect(graph.changes.has('ins-partial-accept')).toBe(false); + const fragments = Array.from(graph.changes.values()).sort((a, b) => a.excerpt.localeCompare(b.excerpt)); + expect(fragments.map((change) => change.excerpt)).toEqual(['X', 'Z']); + for (const fragment of fragments) { + expect(fragment.splitFromId).toBe('ins-partial-accept'); + expect(fragment.revisionGroupId).toBe('ins-partial-accept'); + expect(result.receipt.createdChangeIds).toContain(fragment.id); + } + }); + + it('partial reject of an insertion removes selected text and keeps deterministic successor fragments', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'A ' }, + { text: 'XYZ', marks: [{ markType: TrackInsertMarkName, attrs: insertAttrs('ins-partial-reject') }] }, + { text: ' B' }, + ], + }); + + const result = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'reject', + target: { kind: 'range', from: 4, to: 5 }, + }); + + expect(result.ok).toBe(true); + const next = state.apply(result.tr); + expect(next.doc.textContent).toBe('A XZ B'); + const graph = buildReviewGraph({ state: next }); + expect(graph.changes.has('ins-partial-reject')).toBe(false); + const fragments = Array.from(graph.changes.values()).sort((a, b) => a.excerpt.localeCompare(b.excerpt)); + expect(fragments.map((change) => change.excerpt)).toEqual(['X', 'Z']); + expect(fragments.every((change) => change.splitFromId === 'ins-partial-reject')).toBe(true); + }); + + it('partial accept of a deletion removes selected deleted text and preserves successor deletion fragments', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'A ' }, + { text: 'XYZ', marks: [{ markType: TrackDeleteMarkName, attrs: deleteAttrs('del-partial-accept') }] }, + { text: ' B' }, + ], + }); + + const result = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'accept', + target: { kind: 'range', from: 4, to: 5 }, + }); + + expect(result.ok).toBe(true); + const next = state.apply(result.tr); + expect(next.doc.textContent).toBe('A XZ B'); + const graph = buildReviewGraph({ state: next }); + expect(graph.changes.has('del-partial-accept')).toBe(false); + const fragments = Array.from(graph.changes.values()).sort((a, b) => a.excerpt.localeCompare(b.excerpt)); + expect(fragments.map((change) => change.excerpt)).toEqual(['X', 'Z']); + expect(fragments.every((change) => change.splitFromId === 'del-partial-accept')).toBe(true); + expect(fragments.every((change) => change.type === 'deletion')).toBe(true); + }); + + it('partial reject of a deletion unwraps selected text and preserves successor deletion fragments', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'A ' }, + { text: 'XYZ', marks: [{ markType: TrackDeleteMarkName, attrs: deleteAttrs('del-partial-reject') }] }, + { text: ' B' }, + ], + }); + + const result = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'reject', + target: { kind: 'range', from: 4, to: 5 }, + }); + + expect(result.ok).toBe(true); + const next = state.apply(result.tr); + expect(next.doc.textContent).toBe('A XYZ B'); + const graph = buildReviewGraph({ state: next }); + expect(graph.changes.has('del-partial-reject')).toBe(false); + const fragments = Array.from(graph.changes.values()).sort((a, b) => a.excerpt.localeCompare(b.excerpt)); + expect(fragments.map((change) => change.excerpt)).toEqual(['X', 'Z']); + expect(fragments.every((change) => change.splitFromId === 'del-partial-reject')).toBe(true); + }); + + it('range target with no overlap returns TARGET_NOT_FOUND', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [{ text: 'plain text only' }], + }); + const result = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'accept', + target: { kind: 'range', from: 1, to: 4 }, + }); + expect(result.ok).toBe(false); + expect(result.code).toBe('TARGET_NOT_FOUND'); + }); + + it('permission denial aborts before any mutation', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'A ' }, + { text: 'NEW', marks: [{ markType: TrackInsertMarkName, attrs: insertAttrs('ins-p1', OTHER_USER) }] }, + { text: ' B' }, + ], + }); + const editor = editorFor(SAME_USER, { + permissionResolver: () => false, + }); + const result = decideTrackedChanges({ + state, + editor, + decision: 'accept', + target: { kind: 'id', id: 'ins-p1' }, + }); + expect(result.ok).toBe(false); + expect(result.code).toBe('PERMISSION_DENIED'); + }); + + it('rejects unknown id with TARGET_NOT_FOUND', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [{ text: 'plain' }], + }); + const result = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'accept', + target: { kind: 'id', id: 'does-not-exist' }, + }); + expect(result.ok).toBe(false); + expect(result.code).toBe('TARGET_NOT_FOUND'); + }); + + it('rejects invalid target shapes', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ schema, spans: [{ text: 'x' }] }); + const result = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'accept', + target: { not: 'real' }, + }); + expect(result.ok).toBe(false); + expect(result.code).toBe('INVALID_TARGET'); + }); + + it('child change wholly inside a rejected insertion retires as a side effect', () => { + const schema = createReviewGraphTestSchema(); + // Parent insertion "AAA" by other user, with a child same-user delete on "AA" inside. + // Rejecting parent insertion removes "AAA" content; child id should be retired. + const parentAttrs = insertAttrs('parent-1', OTHER_USER); + const childAttrs = deleteAttrs('child-1', SAME_USER, { overlapParentId: 'parent-1' }); + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'A ' }, + { + text: 'AA', + marks: [ + { markType: TrackInsertMarkName, attrs: parentAttrs }, + { markType: TrackDeleteMarkName, attrs: childAttrs }, + ], + }, + { + text: 'A', + marks: [{ markType: TrackInsertMarkName, attrs: parentAttrs }], + }, + { text: ' B' }, + ], + }); + const result = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'reject', + target: { kind: 'id', id: 'parent-1' }, + }); + expect(result.ok).toBe(true); + const retired = result.receipt.removedChangeIds.map((e) => e.id).sort(); + expect(retired).toContain('parent-1'); + expect(retired).toContain('child-1'); + expect(result.receipt.affectedChildren.some((c) => c.changeId === 'child-1')).toBe(true); + }); + + it('legacy aliases { id } and { scope: "all" } normalize correctly', () => { + const schema = createReviewGraphTestSchema(); + const { state } = stateFromTrackedSpans({ + schema, + spans: [{ text: 'X', marks: [{ markType: TrackInsertMarkName, attrs: insertAttrs('legacy-id') }] }], + }); + const byId = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'accept', + target: { id: 'legacy-id' }, + }); + expect(byId.ok).toBe(true); + + const all = decideTrackedChanges({ + state, + editor: editorFor(SAME_USER), + decision: 'accept', + target: { scope: 'all' }, + }); + expect(all.ok).toBe(true); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/edit-intent.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/edit-intent.js new file mode 100644 index 0000000000..63be895c44 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/edit-intent.js @@ -0,0 +1,257 @@ +// @ts-check +/** + * TrackedEditIntent factories and helpers. + * + * Every tracked text mutation — native step rewrites, the + * `insertTrackedChange` command, and document-api tracked writes — must + * normalize its input into a TrackedEditIntent before consulting the + * overlap compiler. The intent type is the only contract the compiler + * accepts; each call site decides whether it produces a `text-insert`, + * `text-delete`, `text-replace`, or `format-apply`/`format-remove`. + * + * `source` is preserved purely for failure routing (native vs document-api + * vs plan-engine) and never changes semantic rules. `replacementGroupHint` + * exists for transitional callers that cannot avoid producing adjacent + * delete/insert intents while migrating to one fused `text-replace`. + */ + +import { Slice, Fragment } from 'prosemirror-model'; + +/** @typedef {'native'|'document-api'|'programmatic'} EditIntentSource */ + +/** + * @typedef {Object} TrackedEditIntentUser + * @property {string} name + * @property {string} email + * @property {string} [image] + */ + +/** + * @typedef {Object} TrackedEditIntentBase + * @property {EditIntentSource} source + * @property {TrackedEditIntentUser} user + * @property {string} date + * @property {string} [replacementGroupHint] + * @property {boolean} [probeForDeletionSpan] When true, the compiler may + * probe for an adjacent tracked-delete span and move the insertion to + * after it. Single-step user replace turns this on; multi-step transactions + * leave it off so each granular op lands at its own position. + * @property {boolean} [preserveExistingReviewState] When true, the compiler + * must not collapse/refine existing tracked review marks even if the + * current user owns them. Used by explicit direct mutations that are + * re-routed through tracking only to protect existing review state. + */ + +/** + * @typedef {(TrackedEditIntentBase & { + * kind: 'text-insert', + * at: number, + * content: import('prosemirror-model').Slice, + * }) | (TrackedEditIntentBase & { + * kind: 'text-delete', + * from: number, + * to: number, + * }) | (TrackedEditIntentBase & { + * kind: 'text-replace', + * from: number, + * to: number, + * content: import('prosemirror-model').Slice, + * replacements: 'paired'|'independent', + * }) | (TrackedEditIntentBase & { + * kind: 'format-apply'|'format-remove', + * from: number, + * to: number, + * mark: import('prosemirror-model').Mark, + * })} TrackedEditIntent + */ + +const isFiniteNonNeg = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0; + +/** + * Build a Slice that wraps a single text string with the given marks. + * openStart/openEnd are 0 so callers can use `replaceRange` for inline merge. + * + * @param {*} schema + * @param {string} text + * @param {readonly import('prosemirror-model').Mark[]} [marks] + * @returns {import('prosemirror-model').Slice} + */ +export const sliceFromText = (schema, text, marks) => { + if (!text) return Slice.empty; + return new Slice(Fragment.from(schema.text(text, marks ?? null)), 0, 0); +}; + +/** + * Coerce string or Slice into a Slice. + * + * @param {*} schema + * @param {*} content + * @returns {import('prosemirror-model').Slice} + */ +export const toSliceContent = (schema, content) => { + if (content instanceof Slice) return content; + if (typeof content === 'string') return sliceFromText(schema, content); + return Slice.empty; +}; + +/** + * Build a `text-insert` intent. The caller is expected to have already + * resolved `at` against the transaction it will pass to the compiler. + * + * @param {{ + * at: number, + * content: import('prosemirror-model').Slice | string, + * schema?: *, + * user: TrackedEditIntentUser, + * date: string, + * source: EditIntentSource, + * replacementGroupHint?: string, + * preserveExistingReviewState?: boolean, + * }} input + * @returns {TrackedEditIntent} + */ +export const makeTextInsertIntent = ({ + at, + content, + schema, + user, + date, + source, + replacementGroupHint, + preserveExistingReviewState, +}) => { + if (!isFiniteNonNeg(at)) { + throw new Error('makeTextInsertIntent: `at` must be a non-negative finite number'); + } + const slice = + content instanceof Slice ? content : schema ? sliceFromText(schema, /** @type {string} */ (content)) : Slice.empty; + return { + kind: 'text-insert', + at, + content: slice, + user, + date, + source, + ...(replacementGroupHint ? { replacementGroupHint } : {}), + ...(preserveExistingReviewState ? { preserveExistingReviewState: true } : {}), + }; +}; + +/** + * Build a `text-delete` intent. + * + * @param {{ + * from: number, + * to: number, + * user: TrackedEditIntentUser, + * date: string, + * source: EditIntentSource, + * replacementGroupHint?: string, + * preserveExistingReviewState?: boolean, + * }} input + * @returns {TrackedEditIntent} + */ +export const makeTextDeleteIntent = ({ + from, + to, + user, + date, + source, + replacementGroupHint, + preserveExistingReviewState, +}) => { + if (!isFiniteNonNeg(from) || !isFiniteNonNeg(to)) { + throw new Error('makeTextDeleteIntent: `from`/`to` must be non-negative finite numbers'); + } + if (from > to) throw new Error('makeTextDeleteIntent: `from` must be <= `to`'); + return { + kind: 'text-delete', + from, + to, + user, + date, + source, + ...(replacementGroupHint ? { replacementGroupHint } : {}), + ...(preserveExistingReviewState ? { preserveExistingReviewState: true } : {}), + }; +}; + +/** + * Build a `text-replace` intent. + * + * @param {{ + * from: number, + * to: number, + * content: import('prosemirror-model').Slice | string, + * schema?: *, + * replacements: 'paired'|'independent', + * user: TrackedEditIntentUser, + * date: string, + * source: EditIntentSource, + * replacementGroupHint?: string, + * preserveExistingReviewState?: boolean, + * }} input + * @returns {TrackedEditIntent} + */ +export const makeTextReplaceIntent = ({ + from, + to, + content, + schema, + replacements, + user, + date, + source, + replacementGroupHint, + preserveExistingReviewState, +}) => { + if (!isFiniteNonNeg(from) || !isFiniteNonNeg(to)) { + throw new Error('makeTextReplaceIntent: `from`/`to` must be non-negative finite numbers'); + } + if (from > to) throw new Error('makeTextReplaceIntent: `from` must be <= `to`'); + const slice = + content instanceof Slice ? content : schema ? sliceFromText(schema, /** @type {string} */ (content)) : Slice.empty; + return { + kind: 'text-replace', + from, + to, + content: slice, + replacements, + user, + date, + source, + ...(replacementGroupHint ? { replacementGroupHint } : {}), + ...(preserveExistingReviewState ? { preserveExistingReviewState: true } : {}), + }; +}; + +/** + * Build a `format-apply`/`format-remove` intent. + * + * @param {{ + * kind: 'format-apply'|'format-remove', + * from: number, + * to: number, + * mark: import('prosemirror-model').Mark, + * user: TrackedEditIntentUser, + * date: string, + * source: EditIntentSource, + * }} input + * @returns {TrackedEditIntent} + */ +export const makeFormatIntent = ({ kind, from, to, mark, user, date, source }) => { + if (kind !== 'format-apply' && kind !== 'format-remove') { + throw new Error(`makeFormatIntent: unsupported kind ${kind}`); + } + if (!isFiniteNonNeg(from) || !isFiniteNonNeg(to)) { + throw new Error('makeFormatIntent: `from`/`to` must be non-negative finite numbers'); + } + if (from > to) throw new Error('makeFormatIntent: `from` must be <= `to`'); + return { kind, from, to, mark, user, date, source }; +}; + +/** + * @param {TrackedEditIntent} intent + * @returns {string} + */ +export const intentKind = (intent) => intent.kind; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/graph-invariants.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/graph-invariants.js new file mode 100644 index 0000000000..4083414426 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/graph-invariants.js @@ -0,0 +1,51 @@ +// @ts-check +/** + * Graph-invariant convenience layer. + * + * The invariant checks themselves live next to the builder in + * review-graph.js so they share private types. This module is a thin entry + * point that downstream code (compiler/decision engine) imports without + * pulling in the full graph builder API surface. + */ + +import { validateGraph } from './review-graph.js'; + +/** + * Run invariants and return diagnostics by severity bucket. + * + * @param {import('./review-graph.js').TrackedReviewGraph} graph + * @returns {{ + * errors: Array, + * warnings: Array, + * info: Array, + * all: Array, + * }} + */ +export const runGraphInvariants = (graph) => { + const all = validateGraph(graph); + const errors = []; + const warnings = []; + const info = []; + for (const d of all) { + if (d.severity === 'error') errors.push(d); + else if (d.severity === 'warning') warnings.push(d); + else info.push(d); + } + return { errors, warnings, info, all }; +}; + +/** + * True when the graph has any `error`-severity diagnostic. + * + * Decision and compiler paths must abort before dispatch when this returns + * true. + * + * @param {import('./review-graph.js').TrackedReviewGraph} graph + * @returns {boolean} + */ +export const graphHasErrors = (graph) => { + for (const d of validateGraph(graph)) { + if (d.severity === 'error') return true; + } + return false; +}; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.js new file mode 100644 index 0000000000..0871012543 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.js @@ -0,0 +1,230 @@ +// @ts-check +import { normalizeActorEmail, normalizeActorId, normalizeActorName } from '@superdoc/common'; + +/** + * Identity helpers for the review graph. + * + * Same-user behavior requires high-confidence identity. Actor ids are the + * canonical principal when both sides provide them. Legacy/imported content + * still falls back to trusted author-email matching when ids are absent. + */ + +/** + * Trim and lowercase an email value. Anything not a string normalizes to ''. + * @param {unknown} value + * @returns {string} + */ +export const normalizeEmail = (value) => { + return normalizeActorEmail(value); +}; + +/** + * Trim and lowercase a display-name value. Anything not a string normalizes to ''. + * @param {unknown} value + * @returns {string} + */ +export const normalizeName = (value) => { + return normalizeActorName(value); +}; + +const normalizeImportedAuthorName = (value) => { + const normalized = normalizeName(value).replace(/\s+\(imported\)$/, ''); + return normalized === 'undefined' || normalized === 'null' ? '' : normalized; +}; + +/** + * @typedef {Object} UserIdentity + * @property {string} id normalized actor id, '' when unknown. + * @property {string} email normalized email, '' when unknown. + * @property {string} name display name (may be empty). + * @property {boolean} hasId true when normalized id is non-empty. + * @property {boolean} hasEmail true when normalized email is non-empty. + * @property {string} [importedAuthor] imported author provenance, when present. + */ + +/** + * Read the current user identity from the editor options. + * Tolerates a missing editor / options / user so callers can pass a partial + * editor (or a snapshot for tests). + * + * @param {{ options?: { user?: { id?: unknown, name?: unknown, email?: unknown } } } | null | undefined} editor + * @returns {UserIdentity} + */ +export const getCurrentUserIdentity = (editor) => { + const user = editor?.options?.user ?? null; + const id = normalizeActorId(user?.id); + const email = normalizeEmail(user?.email); + const name = typeof user?.name === 'string' ? user.name : ''; + return { id, email, name, hasId: id.length > 0, hasEmail: email.length > 0 }; +}; + +/** + * Pull the author identity off a mark's attrs (or a graph-shaped change/segment). + * Accepts the raw mark, its attrs, or a graph object with `author`/`authorEmail`. + * + * @param {*} changeOrAttrs + * @returns {UserIdentity} + */ +export const getChangeAuthorIdentity = (changeOrAttrs) => { + if (!changeOrAttrs) return { id: '', email: '', name: '', hasId: false, hasEmail: false }; + + // Accept either { attrs: {...} }, { mark: { attrs } }, or a flat attrs map. + const attrs = changeOrAttrs.attrs ?? changeOrAttrs.mark?.attrs ?? changeOrAttrs; + + const id = normalizeActorId(attrs?.authorId); + const email = normalizeEmail(attrs?.authorEmail); + const name = typeof attrs?.author === 'string' ? attrs.author : ''; + const importedAuthor = typeof attrs?.importedAuthor === 'string' ? attrs.importedAuthor : ''; + /** @type {UserIdentity} */ + const identity = { id, email, name, hasId: id.length > 0, hasEmail: email.length > 0 }; + if (importedAuthor) identity.importedAuthor = importedAuthor; + return identity; +}; + +const hasImportedAuthorConflict = ({ currentUser, change }) => { + const importedAuthorName = normalizeImportedAuthorName(change?.importedAuthor); + const currentName = normalizeName(currentUser?.name); + const changeName = normalizeName(change?.name); + + if (!importedAuthorName || !currentName || !changeName) return false; + if (importedAuthorName === currentName) return false; + if (changeName === currentName) return false; + return true; +}; + +const hasImportedInsertionProvenance = (attrs) => { + const sourceId = attrs?.sourceId; + if (sourceId !== undefined && sourceId !== null && String(sourceId).trim()) { + return true; + } + + if (normalizeImportedAuthorName(attrs?.importedAuthor)) { + return true; + } + + const sourceIds = attrs?.sourceIds; + if (typeof sourceIds === 'string') { + const trimmed = sourceIds.trim(); + return Boolean(trimmed && trimmed !== '{}' && trimmed !== 'null'); + } + if (sourceIds && typeof sourceIds === 'object' && !Array.isArray(sourceIds)) { + return Object.keys(sourceIds).length > 0; + } + + return false; +}; + +/** + * @typedef {( + * | 'same-user' + * | 'different-user' + * | 'unknown-current-user' + * | 'unknown-change-author' + * | 'conflicting' + * )} OwnershipClassification + */ + +/** + * Classify ownership between the current editor user and a change author. + * + * Rules: + * - when both sides provide actor ids, id match is authoritative. + * - otherwise, normalized authorEmail match is high-confidence => `same-user`. + * - display name alone is never same-user. + * - missing current user email is `unknown-current-user`. + * - missing change author email is `unknown-change-author`. + * - both emails present but different => `different-user`. + * - conflicting signals (e.g. emails match but names differ in a way that + * indicates an impersonation) are reported as `conflicting`. Caller treats + * it as different-user; the distinct code lets diagnostics report it. + * + * Only `same-user` may trigger same-user refinement. Every other code MUST + * use different-user overlap behavior. + * + * @param {{ currentUser?: UserIdentity, change?: UserIdentity }} input + * @returns {OwnershipClassification} + */ +export const classifyOwnership = ({ currentUser, change }) => { + const cur = currentUser ?? { id: '', email: '', name: '', hasId: false, hasEmail: false }; + const auth = change ?? { id: '', email: '', name: '', hasId: false, hasEmail: false }; + + if (hasImportedAuthorConflict({ currentUser: cur, change: auth })) { + return 'conflicting'; + } + + if (cur.hasId && auth.hasId) { + return cur.id === auth.id ? 'same-user' : 'different-user'; + } + + if (!cur.hasEmail) return 'unknown-current-user'; + if (!auth.hasEmail) return 'unknown-change-author'; + + if (cur.email === auth.email) { + return 'same-user'; + } + + return 'different-user'; +}; + +/** + * Convenience: returns true only when the classification is high-confidence + * same-user. Use this as the gate before applying same-user refinement. + * + * @param {OwnershipClassification} classification + * @returns {boolean} + */ +export const isSameUserHighConfidence = (classification) => classification === 'same-user'; + +/** + * Refinement is slightly more permissive than review ownership. When neither + * side carries actor ids or emails, legacy anonymous typing still coalesces + * into the same logical change only when the stored no-email author is + * actually unattributed, or when display names match. + * + * @param {{ currentUser?: UserIdentity, change?: UserIdentity }} input + * @returns {boolean} + */ +export const matchesSameUserRefinement = ({ currentUser, change }) => { + const cur = currentUser ?? { id: '', email: '', name: '', hasId: false, hasEmail: false }; + const auth = change ?? { id: '', email: '', name: '', hasId: false, hasEmail: false }; + const classification = classifyOwnership({ currentUser: cur, change: auth }); + if (isSameUserHighConfidence(classification)) return true; + + if (!cur.hasId && !auth.hasId && !cur.hasEmail && !auth.hasEmail) { + const changeName = normalizeName(auth.name) || normalizeImportedAuthorName(auth.importedAuthor); + if (!changeName) return true; + const currentName = normalizeName(cur.name); + return Boolean(currentName && currentName === changeName); + } + + return false; +}; + +/** + * Imported/no-email insertions predate reliable authorEmail metadata. They may + * collapse on delete only when the mark is truly unattributed, or when its + * no-email display name matches the current user. A named different author + * with no email is still different-user review state and must be protected. + * + * @param {{ currentUser?: { id?: unknown, name?: unknown, email?: unknown }, insertionAttrs?: Record | null | undefined }} input + * @returns {boolean} + */ +export const shouldCollapseNoEmailInsertion = ({ currentUser, insertionAttrs }) => { + const authorId = normalizeActorId(insertionAttrs?.authorId); + const currentId = normalizeActorId(currentUser?.id); + if (authorId || currentId) { + return Boolean(authorId && currentId && authorId === currentId); + } + + const authorEmail = normalizeEmail(insertionAttrs?.authorEmail); + if (authorEmail) return false; + + if (!hasImportedInsertionProvenance(insertionAttrs)) return false; + + const authorName = + normalizeName(insertionAttrs?.author) || normalizeImportedAuthorName(insertionAttrs?.importedAuthor); + if (!authorName) return true; + + const currentName = normalizeName(currentUser?.name); + return Boolean(currentName && currentName === authorName); +}; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.test.js new file mode 100644 index 0000000000..76fcea2d77 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.test.js @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest'; +import { + normalizeEmail, + getCurrentUserIdentity, + getChangeAuthorIdentity, + classifyOwnership, + isSameUserHighConfidence, + matchesSameUserRefinement, + shouldCollapseNoEmailInsertion, +} from './identity.js'; + +describe('review-model/identity', () => { + describe('normalizeEmail', () => { + it('lowercases and trims a string email', () => { + expect(normalizeEmail(' Alice@Example.COM ')).toBe('alice@example.com'); + }); + it('returns "" for non-string values', () => { + expect(normalizeEmail(undefined)).toBe(''); + expect(normalizeEmail(null)).toBe(''); + expect(normalizeEmail(42)).toBe(''); + expect(normalizeEmail({ email: 'x' })).toBe(''); + }); + it('returns "" for whitespace-only strings', () => { + expect(normalizeEmail(' ')).toBe(''); + }); + }); + + describe('getCurrentUserIdentity', () => { + it('extracts identity from a configured editor', () => { + const editor = { options: { user: { id: 'alice-id', name: 'Alice', email: 'Alice@example.com' } } }; + expect(getCurrentUserIdentity(editor)).toEqual({ + id: 'alice-id', + email: 'alice@example.com', + name: 'Alice', + hasId: true, + hasEmail: true, + }); + }); + it('returns empty identity for missing editor/user', () => { + expect(getCurrentUserIdentity(undefined)).toEqual({ id: '', email: '', name: '', hasId: false, hasEmail: false }); + expect(getCurrentUserIdentity({ options: {} })).toEqual({ + id: '', + email: '', + name: '', + hasId: false, + hasEmail: false, + }); + }); + }); + + describe('getChangeAuthorIdentity', () => { + it('reads from a raw mark', () => { + const mark = { attrs: { author: 'Bob', authorId: 'bob-id', authorEmail: 'BOB@example.com' } }; + expect(getChangeAuthorIdentity(mark)).toEqual({ + id: 'bob-id', + email: 'bob@example.com', + name: 'Bob', + hasId: true, + hasEmail: true, + }); + }); + it('reads from flat attrs', () => { + expect( + getChangeAuthorIdentity({ author: 'Carol', authorId: 'carol-id', authorEmail: 'carol@example.com' }), + ).toEqual({ + id: 'carol-id', + email: 'carol@example.com', + name: 'Carol', + hasId: true, + hasEmail: true, + }); + }); + it('returns empty for null', () => { + expect(getChangeAuthorIdentity(null)).toEqual({ id: '', email: '', name: '', hasId: false, hasEmail: false }); + }); + }); + + describe('classifyOwnership', () => { + const alice = { id: 'alice-id', email: 'alice@example.com', name: 'Alice', hasId: true, hasEmail: true }; + const bob = { id: 'bob-id', email: 'bob@example.com', name: 'Bob', hasId: true, hasEmail: true }; + + it('prefers actor ids over matching emails', () => { + expect( + classifyOwnership({ + currentUser: alice, + change: { ...bob, email: alice.email }, + }), + ).toBe('different-user'); + }); + it('treats matching actor ids as same-user even when emails differ', () => { + expect( + classifyOwnership({ + currentUser: alice, + change: { ...alice, email: 'alias@example.com' }, + }), + ).toBe('same-user'); + }); + it('returns same-user for matching emails', () => { + expect( + classifyOwnership({ + currentUser: { id: '', email: alice.email, name: 'Alice', hasId: false, hasEmail: true }, + change: { id: '', email: alice.email, name: 'Alice', hasId: false, hasEmail: true }, + }), + ).toBe('same-user'); + }); + it('returns different-user for distinct emails', () => { + expect( + classifyOwnership({ + currentUser: { id: '', email: alice.email, name: 'Alice', hasId: false, hasEmail: true }, + change: { id: '', email: bob.email, name: 'Bob', hasId: false, hasEmail: true }, + }), + ).toBe('different-user'); + }); + it('returns unknown-current-user when current email is missing', () => { + expect( + classifyOwnership({ + currentUser: { id: '', email: '', name: '', hasId: false, hasEmail: false }, + change: { id: '', email: bob.email, name: 'Bob', hasId: false, hasEmail: true }, + }), + ).toBe('unknown-current-user'); + }); + it('returns unknown-change-author when change email is missing', () => { + expect( + classifyOwnership({ + currentUser: { id: '', email: alice.email, name: 'Alice', hasId: false, hasEmail: true }, + change: { id: '', email: '', name: 'B', hasId: false, hasEmail: false }, + }), + ).toBe('unknown-change-author'); + }); + it('display-name-only never matches', () => { + expect( + classifyOwnership({ + currentUser: { id: '', email: '', name: 'Alice', hasId: false, hasEmail: false }, + change: { id: '', email: '', name: 'Alice', hasId: false, hasEmail: false }, + }), + ).toBe('unknown-current-user'); + }); + it('returns conflicting when importedAuthor disagrees with name', () => { + expect( + classifyOwnership({ + currentUser: alice, + change: { ...alice, name: 'Imported Alice', importedAuthor: 'Mallory' }, + }), + ).toBe('conflicting'); + }); + it('isSameUserHighConfidence only on same-user', () => { + expect(isSameUserHighConfidence('same-user')).toBe(true); + expect(isSameUserHighConfidence('different-user')).toBe(false); + expect(isSameUserHighConfidence('unknown-current-user')).toBe(false); + expect(isSameUserHighConfidence('unknown-change-author')).toBe(false); + expect(isSameUserHighConfidence('conflicting')).toBe(false); + }); + it('allows legacy anonymous refinement only when names match or are absent', () => { + expect( + matchesSameUserRefinement({ + currentUser: { id: '', email: '', name: 'Alice', hasId: false, hasEmail: false }, + change: { id: '', email: '', name: 'Alice', hasId: false, hasEmail: false }, + }), + ).toBe(true); + expect( + matchesSameUserRefinement({ + currentUser: { id: '', email: '', name: 'Alice', hasId: false, hasEmail: false }, + change: { + id: '', + email: '', + name: '', + hasId: false, + hasEmail: false, + importedAuthor: 'Mallory (imported)', + }, + }), + ).toBe(false); + }); + + it('only collapses no-email insertions through imported provenance', () => { + expect( + shouldCollapseNoEmailInsertion({ + currentUser: { name: '', email: '' }, + insertionAttrs: { author: '', authorEmail: '', sourceId: '1' }, + }), + ).toBe(true); + + expect( + shouldCollapseNoEmailInsertion({ + currentUser: { name: '', email: '' }, + insertionAttrs: { author: '', authorEmail: '', sourceId: '' }, + }), + ).toBe(false); + }); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-context.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-context.js new file mode 100644 index 0000000000..b61de6ba77 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-context.js @@ -0,0 +1,185 @@ +// @ts-check +/** + * Importer tracking context for DOCX tracked-change overlap. + * + * The current `w:ins`/`w:del`/`w:rPrChange` translators receive an + * `insideTrackChange` boolean. That's enough to decide that a nested change + * exists, but it carries no information about the parent's logical id, side, + * or part path. The import pipeline needs all of that to stamp `overlapParentId` and to + * report diagnostics when the imported OOXML shape cannot reconstruct the + * full internal graph. + * + * Usage: + * const ctx = createImportTrackingContext({ + * partPath: 'word/document.xml', + * replacements: 'paired', + * }); + * + * ctx.pushParent({ logicalId, side, sourceId, author, date }); + * // ... descend into nested element ... + * ctx.popParent(); + * + * ctx.reportDiagnostic({ code: 'IMPORT_MISSING_REPLACEMENT_SIDE', ...details }); + * + * The context is intentionally lightweight: it does not own the PM document + * and does not allocate ids. The translators continue to call + * `buildTrackedChangeIdMap`; the context only adds parent-stack and + * diagnostics surfaces that the legacy translator code lacked. + * + * @typedef {'paired' | 'independent'} ReplacementsMode + * @typedef {'insertion' | 'deletion' | 'formatting'} ParentSide + * + * @typedef {{ + * logicalId: string, + * side: ParentSide, + * sourceId: string, + * author: string, + * date: string, + * }} ParentFrame + * + * @typedef {( + * | 'IMPORT_UNSUPPORTED_STRUCTURAL_OVERLAP' + * | 'IMPORT_MISSING_AUTHOR_IDENTITY' + * | 'IMPORT_HEURISTIC_RECONSTRUCTION' + * | 'IMPORT_REPLACEMENT_MISSING_SIDE' + * | 'IMPORT_CHILD_MISSING_PARENT' + * | 'IMPORT_DUPLICATE_LOGICAL_ID' + * | 'EXPORT_FALLBACK_LOSSY' + * )} ImportDiagnosticCode + * + * @typedef {{ + * code: ImportDiagnosticCode, + * partPath: string, + * logicalId?: string, + * parentLogicalId?: string, + * sourceId?: string, + * side?: ParentSide, + * message?: string, + * detail?: Record, + * }} ImportDiagnostic + * + * @typedef {{ + * partPath: string, + * replacements: ReplacementsMode, + * parentStack: () => ReadonlyArray, + * currentParent: () => ParentFrame | null, + * pushParent: (frame: ParentFrame) => void, + * popParent: () => ParentFrame | null, + * reportDiagnostic: (d: ImportDiagnostic) => void, + * diagnostics: () => ReadonlyArray, + * recordLogicalId: (id: string, source: { sourceId?: string, side?: ParentSide }) => void, + * hasLogicalId: (id: string) => boolean, + * forNestedPart: (partPath: string) => ImportTrackingContext, + * }} ImportTrackingContext + */ + +/** + * @param {{ + * partPath?: string, + * replacements?: ReplacementsMode, + * diagnostics?: ImportDiagnostic[], + * knownLogicalIds?: Map }>, + * }} [options] + * @returns {ImportTrackingContext} + */ +export function createImportTrackingContext(options = {}) { + const partPath = + typeof options.partPath === 'string' && options.partPath.length > 0 ? options.partPath : 'word/document.xml'; + const replacements = options.replacements === 'independent' ? 'independent' : 'paired'; + + /** @type {ParentFrame[]} */ + const stack = []; + + /** @type {ImportDiagnostic[]} */ + const diagnostics = Array.isArray(options.diagnostics) ? options.diagnostics : []; + + /** @type {Map }>} */ + const knownLogicalIds = options.knownLogicalIds instanceof Map ? options.knownLogicalIds : new Map(); + + /** @param {ImportDiagnostic} d */ + const reportDiagnostic = (d) => { + if (!d || typeof d !== 'object' || !d.code) return; + diagnostics.push({ ...d, partPath: d.partPath || partPath }); + }; + + /** @type {ImportTrackingContext} */ + const ctx = { + partPath, + replacements, + parentStack: () => stack.slice(), + currentParent: () => (stack.length > 0 ? stack[stack.length - 1] : null), + pushParent: (frame) => { + if (!frame || typeof frame !== 'object' || !frame.logicalId) return; + stack.push({ + logicalId: String(frame.logicalId), + side: frame.side, + sourceId: typeof frame.sourceId === 'string' ? frame.sourceId : '', + author: typeof frame.author === 'string' ? frame.author : '', + date: typeof frame.date === 'string' ? frame.date : '', + }); + }, + popParent: () => (stack.length > 0 ? stack.pop() : null) ?? null, + reportDiagnostic, + diagnostics: () => diagnostics.slice(), + recordLogicalId: (id, source) => { + if (!id) return; + const prior = knownLogicalIds.get(id); + if (prior) { + const priorSides = prior.sides instanceof Set ? prior.sides : new Set(prior.side ? [prior.side] : []); + // Word paired replacements can reuse the same w:id across insertion + // and deletion sides. Reusing the same logical id on the same side is + // the ambiguous duplicate case this context should report. + if (source?.side && priorSides.has(source.side)) { + reportDiagnostic({ + code: 'IMPORT_DUPLICATE_LOGICAL_ID', + partPath, + logicalId: id, + side: source.side, + }); + } + if (source?.side) priorSides.add(source.side); + knownLogicalIds.set(id, { + sourceId: prior.sourceId || source?.sourceId, + side: prior.side || source?.side, + sides: priorSides, + }); + return; + } + knownLogicalIds.set(id, { + sourceId: source?.sourceId, + side: source?.side, + sides: source?.side ? new Set([source.side]) : new Set(), + }); + }, + hasLogicalId: (id) => Boolean(id) && knownLogicalIds.has(id), + forNestedPart: (nestedPartPath) => + createImportTrackingContext({ + partPath: nestedPartPath, + replacements, + diagnostics, + knownLogicalIds, + }), + }; + + return ctx; +} + +/** + * Convenience helper that wraps a recursive descent, ensuring `popParent` + * runs even when the body throws. Used by the tracked-change translator + * integration helpers. + * + * @template T + * @param {ImportTrackingContext} ctx + * @param {ParentFrame} frame + * @param {() => T} body + * @returns {T} + */ +export function withParentFrame(ctx, frame, body) { + ctx.pushParent(frame); + try { + return body(); + } finally { + ctx.popParent(); + } +} diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-context.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-context.test.js new file mode 100644 index 0000000000..9fec177481 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-context.test.js @@ -0,0 +1,101 @@ +// @ts-check +import { describe, it, expect } from 'vitest'; +import { createImportTrackingContext, withParentFrame } from './import-context.js'; + +describe('createImportTrackingContext', () => { + it('defaults partPath / replacements', () => { + const ctx = createImportTrackingContext(); + expect(ctx.partPath).toBe('word/document.xml'); + expect(ctx.replacements).toBe('paired'); + }); + + it('tracks a parent stack via pushParent / popParent', () => { + const ctx = createImportTrackingContext(); + expect(ctx.currentParent()).toBeNull(); + + ctx.pushParent({ logicalId: 'a', side: 'insertion', sourceId: '1', author: 'Alice', date: '2024-01-01' }); + expect(ctx.currentParent()).toMatchObject({ logicalId: 'a', side: 'insertion' }); + + ctx.pushParent({ logicalId: 'b', side: 'deletion', sourceId: '2', author: 'Bob', date: '2024-01-02' }); + expect(ctx.parentStack().map((f) => f.logicalId)).toEqual(['a', 'b']); + + expect(ctx.popParent()?.logicalId).toBe('b'); + expect(ctx.currentParent()?.logicalId).toBe('a'); + }); + + it('withParentFrame pops the frame even on throw', () => { + const ctx = createImportTrackingContext(); + expect(() => { + withParentFrame(ctx, { logicalId: 'x', side: 'insertion', sourceId: '7', author: 'A', date: 'D' }, () => { + expect(ctx.currentParent()?.logicalId).toBe('x'); + throw new Error('boom'); + }); + }).toThrow('boom'); + expect(ctx.currentParent()).toBeNull(); + }); + + it('collects diagnostics and exposes a snapshot copy', () => { + const ctx = createImportTrackingContext(); + ctx.reportDiagnostic({ code: 'IMPORT_MISSING_AUTHOR_IDENTITY', partPath: 'word/document.xml', sourceId: '1' }); + ctx.reportDiagnostic({ code: 'IMPORT_REPLACEMENT_MISSING_SIDE', partPath: 'word/document.xml' }); + + const list = ctx.diagnostics(); + expect(list).toHaveLength(2); + expect(list[0].code).toBe('IMPORT_MISSING_AUTHOR_IDENTITY'); + // snapshot must not allow callers to mutate internal storage. + list.push({ code: 'EXPORT_FALLBACK_LOSSY', partPath: 'word/document.xml' }); + expect(ctx.diagnostics()).toHaveLength(2); + }); + + it('forNestedPart shares diagnostics + knownLogicalIds across parts', () => { + const ctx = createImportTrackingContext(); + ctx.recordLogicalId('uuid-1', { sourceId: '1', side: 'insertion' }); + + const nested = ctx.forNestedPart('word/header1.xml'); + expect(nested.partPath).toBe('word/header1.xml'); + expect(nested.hasLogicalId('uuid-1')).toBe(true); + + nested.reportDiagnostic({ code: 'IMPORT_HEURISTIC_RECONSTRUCTION', partPath: 'word/header1.xml' }); + expect(ctx.diagnostics().some((d) => d.partPath === 'word/header1.xml')).toBe(true); + }); + + it('diagnoses duplicate same-side logical ids but allows paired opposite sides', () => { + const ctx = createImportTrackingContext(); + ctx.recordLogicalId('7', { sourceId: '7', side: 'insertion' }); + ctx.recordLogicalId('7', { sourceId: '7', side: 'deletion' }); + expect(ctx.diagnostics()).toEqual([]); + + ctx.recordLogicalId('7', { sourceId: '7', side: 'insertion' }); + expect(ctx.diagnostics()).toEqual([ + { + code: 'IMPORT_DUPLICATE_LOGICAL_ID', + partPath: 'word/document.xml', + logicalId: '7', + side: 'insertion', + }, + ]); + }); + + it('continues to diagnose duplicates after both replacement sides were seen', () => { + const ctx = createImportTrackingContext(); + ctx.recordLogicalId('7', { sourceId: '7', side: 'insertion' }); + ctx.recordLogicalId('7', { sourceId: '7', side: 'deletion' }); + ctx.recordLogicalId('7', { sourceId: '7', side: 'deletion' }); + + expect(ctx.diagnostics()).toEqual([ + { + code: 'IMPORT_DUPLICATE_LOGICAL_ID', + partPath: 'word/document.xml', + logicalId: '7', + side: 'deletion', + }, + ]); + }); + + it('ignores malformed parent frames', () => { + const ctx = createImportTrackingContext(); + ctx.pushParent(/** @type {any} */ (null)); + ctx.pushParent(/** @type {any} */ ({})); + expect(ctx.parentStack()).toEqual([]); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-diagnostics-integration.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-diagnostics-integration.test.js new file mode 100644 index 0000000000..609f67a1db --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-diagnostics-integration.test.js @@ -0,0 +1,58 @@ +// @ts-check +/** + * Phase 005 — integration test for `scanImportDiagnostics` against real + * DOCX fixtures. + * + * Plan: v1-3220 / phase0-005 "Repo-Local Critical Tests Only". + * + * The unit tests in `import-diagnostics.test.js` already cover every + * diagnostic code with synthetic XML. This file runs the scanner against + * existing real-world DOCX fixtures shipped with the repo to prove that: + * + * 1) the scanner does NOT false-positive on clean Word-tracked-change + * documents (gdocs / msword paired revisions); + * 2) it does run end-to-end without throwing on a real OOXML body. + */ + +import { describe, it, expect } from 'vitest'; +import { loadTestDataForEditorTests, initTestEditor } from '../../../tests/helpers/helpers.js'; +import { scanImportDiagnostics } from './import-diagnostics.js'; + +const realDocxAsParsedParts = async (filename) => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests(filename); + const { editor } = await initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true }); + try { + // SuperConverter parsed every XML file in the DOCX into convertedXml. + return editor.converter.convertedXml; + } finally { + editor.destroy(); + } +}; + +describe('scanImportDiagnostics against real DOCX fixtures', () => { + it('does not flag clean msword-tracked-changes.docx as missing replacement sides', async () => { + const parts = await realDocxAsParsedParts('msword-tracked-changes.docx'); + const ctx = scanImportDiagnostics(parts, { replacements: 'paired' }); + const diagnostics = ctx.diagnostics(); + // The msword fixture has both insertions and deletions, so the strict + // REPLACEMENT_MISSING_SIDE diagnostic (which requires a single side in + // the part) must NOT fire. Heuristic-reconstruction diagnostics may + // still fire for asymmetric author/date clusters — that signal is + // expected and informational under overlap. + const replacementMissing = diagnostics.filter((d) => d.code === 'IMPORT_REPLACEMENT_MISSING_SIDE'); + expect(replacementMissing).toEqual([]); + }); + + it('returns diagnostics as a stable array for a large DOCX', async () => { + const parts = await realDocxAsParsedParts('features-redlines-comments-annotations-and-more.docx'); + const ctx = scanImportDiagnostics(parts, {}); + expect(Array.isArray(ctx.diagnostics())).toBe(true); + }); + + it('runs to completion without throwing on real DOCX with many tracked changes', async () => { + const parts = await realDocxAsParsedParts('features-redlines-comments-annotations-and-more.docx'); + expect(() => { + scanImportDiagnostics(parts, { replacements: 'paired' }); + }).not.toThrow(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-diagnostics.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-diagnostics.js new file mode 100644 index 0000000000..456003d720 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-diagnostics.js @@ -0,0 +1,298 @@ +// @ts-check +/** + * OOXML import diagnostics scanner for tracked-change overlap. + * + * Walks `w:ins`, `w:del`, and `w:rPrChange` elements across each revision- + * capable part and emits diagnostics for cases where the Word-native shape + * cannot be losslessly reconstructed into the review graph. This is a pure + * read-only scan. + * + * Diagnostics codes: + * - IMPORT_MISSING_AUTHOR_IDENTITY: tracked change has no `w:author`. + * The graph layer routes such changes through different-user behavior. + * - IMPORT_REPLACEMENT_MISSING_SIDE: a `w:ins` / `w:del` whose paired + * opposite-side was never observed under paired mode. Sometimes + * intentional (deletion-only / insertion-only revisions), but the + * internal graph cannot reconstruct a replacement pair. + * - IMPORT_CHILD_MISSING_PARENT: a nested tracked change whose parent + * element does not carry a `w:id`. Internal `overlapParentId` is + * unrecoverable; child must be linked heuristically. + * - IMPORT_DUPLICATE_LOGICAL_ID: same `w:id` appears more than once on + * incompatible sides in the same part — Word treats this as an error. + * - IMPORT_HEURISTIC_RECONSTRUCTION: emitted when adjacent ins+del were + * paired by author/date heuristics rather than carried provenance. + * - IMPORT_UNSUPPORTED_STRUCTURAL_OVERLAP: structural OOXML (e.g. + * `w:moveFrom`/`w:moveTo`) appearing as a child of `w:ins`/`w:del`. + */ + +import { createImportTrackingContext } from './import-context.js'; + +const TRACKED_NAMES = new Set(['w:ins', 'w:del']); +const FORMAT_REVISION_NAMES = new Set(['w:rPrChange', 'w:pPrChange', 'w:cellPrChange']); +const STRUCTURAL_MOVE_NAMES = new Set([ + 'w:moveFrom', + 'w:moveTo', + 'w:moveFromRangeStart', + 'w:moveFromRangeEnd', + 'w:moveToRangeStart', + 'w:moveToRangeEnd', +]); + +/** + * @param {string} name + * @returns {'insertion' | 'deletion' | 'formatting' | null} + */ +function sideFromXmlName(name) { + if (name === 'w:ins') return 'insertion'; + if (name === 'w:del') return 'deletion'; + if (FORMAT_REVISION_NAMES.has(name)) return 'formatting'; + return null; +} + +/** + * @typedef {ReturnType} ImportTrackingContext + * + * @typedef {object} ScanRecord + * @property {string} wordId + * @property {'insertion' | 'deletion' | 'formatting'} side + * @property {string} author + * @property {string} date + * @property {string | null} parentWordId + * @property {'insertion' | 'deletion' | 'formatting' | null} parentSide + */ + +/** + * Walks one parsed OOXML part and records every tracked-change element + + * its parent stack. Used by both the diagnostics emitter and tests. + * + * @param {object | undefined} part + * @returns {ScanRecord[]} + */ +export function scanTrackedElements(part) { + const records = /** @type {ScanRecord[]} */ ([]); + const root = part?.elements?.[0]; + if (!root?.elements) return records; + + /** @type {{ wordId: string, side: 'insertion' | 'deletion' | 'formatting' }[]} */ + const stack = []; + + const visit = (node) => { + if (!node || typeof node !== 'object') return; + + const side = sideFromXmlName(node.name); + if (side) { + const rawWordId = node.attributes?.['w:id']; + const wordId = rawWordId == null ? '' : String(rawWordId); + const parent = stack.length > 0 ? stack[stack.length - 1] : null; + records.push({ + wordId, + side, + author: String(node.attributes?.['w:author'] ?? ''), + date: String(node.attributes?.['w:date'] ?? ''), + parentWordId: parent ? parent.wordId : null, + parentSide: parent ? parent.side : null, + }); + + stack.push({ wordId, side }); + if (Array.isArray(node.elements)) node.elements.forEach(visit); + stack.pop(); + return; + } + + if (STRUCTURAL_MOVE_NAMES.has(node.name)) { + // Move revisions are out of scope for tracked text overlap, but we + // tag them so the emitter can report unsupported overlap when they + // nest inside `w:ins`/`w:del`. + const parent = stack.length > 0 ? stack[stack.length - 1] : null; + if (parent) { + records.push({ + wordId: '__move__', + side: parent.side, // placeholder; emitter recognizes __move__ + author: String(node.attributes?.['w:author'] ?? ''), + date: String(node.attributes?.['w:date'] ?? ''), + parentWordId: parent.wordId, + parentSide: parent.side, + }); + } + } + + if (Array.isArray(node.elements)) node.elements.forEach(visit); + }; + + root.elements.forEach(visit); + return records; +} + +/** + * Scan a DOCX (or a single part) for tracked-change shapes that cannot be + * losslessly reconstructed and emit diagnostics into the provided context. + * + * @param {Record | null | undefined} docx + * @param {{ + * replacements?: 'paired' | 'independent', + * parts?: string[], + * }} [options] + * @returns {ImportTrackingContext} + */ +export function scanImportDiagnostics(docx, options = {}) { + const ctx = createImportTrackingContext({ + partPath: 'word/document.xml', + replacements: options.replacements === 'independent' ? 'independent' : 'paired', + }); + + if (!docx || typeof docx !== 'object') { + return ctx; + } + + const parts = Array.isArray(options.parts) + ? options.parts + : Object.keys(docx).filter((p) => /^word\/(?:document|header\d+|footer\d+|footnotes|endnotes)\.xml$/.test(p)); + + for (const partPath of parts) { + const partCtx = ctx.forNestedPart(partPath); + const records = scanTrackedElements(docx[partPath]); + if (records.length === 0) continue; + + // Tracks side observations per Word id so we can flag duplicates and + // detect replacement missing-side after the full scan. + /** @type {Map, author: string, date: string }>} */ + const sideObservations = new Map(); + + for (const record of records) { + if (record.wordId === '__move__') { + partCtx.reportDiagnostic({ + code: 'IMPORT_UNSUPPORTED_STRUCTURAL_OVERLAP', + partPath, + parentLogicalId: record.parentWordId ?? undefined, + side: record.parentSide ?? undefined, + message: 'w:moveFrom/w:moveTo nested inside a tracked-change wrapper is not modeled as text overlap.', + }); + continue; + } + + if (!record.author) { + partCtx.reportDiagnostic({ + code: 'IMPORT_MISSING_AUTHOR_IDENTITY', + partPath, + sourceId: record.wordId, + side: record.side, + message: 'Tracked change has no w:author; ownership classification falls through to different-user.', + }); + } + + if (record.parentWordId !== null && !record.parentWordId) { + // Nested tracked change with an empty parent w:id — provenance lost. + partCtx.reportDiagnostic({ + code: 'IMPORT_CHILD_MISSING_PARENT', + partPath, + sourceId: record.wordId, + side: record.side, + message: 'Nested tracked change has no parent w:id; overlapParentId reconstruction is heuristic.', + }); + } + + if (!record.wordId) continue; + + const existing = sideObservations.get(record.wordId); + if (existing) { + if (existing.sides.has(record.side)) { + // Same w:id, same side, appears twice in the same part — Word + // does this only when split across runs; the importer can usually + // recover but reports it so consumers can correlate. + partCtx.reportDiagnostic({ + code: 'IMPORT_DUPLICATE_LOGICAL_ID', + partPath, + sourceId: record.wordId, + side: record.side, + message: `w:id "${record.wordId}" appears more than once on side "${record.side}" in ${partPath}.`, + }); + } else { + existing.sides.add(record.side); + } + } else { + sideObservations.set(record.wordId, { + sides: new Set([record.side]), + author: record.author, + date: record.date, + }); + } + + partCtx.recordLogicalId(record.wordId, { sourceId: record.wordId, side: record.side }); + } + + if ((options.replacements ?? 'paired') === 'paired') { + // Heuristic replacement reconstruction: in paired mode the importer + // pairs ins+del with matching author/date. Emit a heuristic diagnostic + // when only one side of an apparent replacement candidate is present + // for a given (author, date) tuple — that's an intentional one-sided + // revision but the graph cannot reconstruct a paired replacement. + /** @type {Map} */ + const byAuthorDate = new Map(); + for (const record of records) { + if (record.wordId === '__move__') continue; + if (record.side === 'formatting') continue; + const key = `${record.author}${record.date}`; + let bucket = byAuthorDate.get(key); + if (!bucket) { + bucket = { insertions: 0, deletions: 0 }; + byAuthorDate.set(key, bucket); + } + if (record.side === 'insertion') bucket.insertions++; + else if (record.side === 'deletion') bucket.deletions++; + } + for (const [key, bucket] of byAuthorDate.entries()) { + // A pure one-sided cluster (no deletions or no insertions) is a + // straight independent revision — Word's normal shape and not a + // missing-side case. We only surface a diagnostic when the importer + // sees both sides present but unpaired by the same author/date — + // that's the heuristic-reconstruction signal the graph cannot + // resolve losslessly without explicit SuperDoc metadata. + if (bucket.insertions > 0 && bucket.deletions > 0 && bucket.insertions !== bucket.deletions) { + partCtx.reportDiagnostic({ + code: 'IMPORT_HEURISTIC_RECONSTRUCTION', + partPath, + message: `Imbalanced paired tracked-change candidates for author/date "${key}" (${bucket.insertions} insertion / ${bucket.deletions} deletion). Graph reconstruction is heuristic.`, + detail: { insertions: bucket.insertions, deletions: bucket.deletions }, + }); + } + } + + // Strict "replacement missing one side" — emitted only when a paired + // replacement candidate (matching author/date and adjacency, per the + // `trackedChangeIdMapper` rules) appears as a SINGLE w:ins or w:del + // without any opposite-side observation in the part. This catches the + // case where Word would have written both halves but only one survived. + if (sideObservations.size > 0) { + let totalInsertions = 0; + let totalDeletions = 0; + for (const record of records) { + if (record.wordId === '__move__') continue; + if (record.side === 'insertion') totalInsertions++; + if (record.side === 'deletion') totalDeletions++; + } + // Only fire when there is exactly one side in the entire part and + // the document carries explicit SuperDoc paired metadata (mirrored + // by an existing replacement marker, currently approximated by + // checking that there is a single tracked side present). Real Word + // documents typically have both sides, so this remains rare. + if (totalInsertions > 0 && totalDeletions === 0 && totalInsertions === 1) { + partCtx.reportDiagnostic({ + code: 'IMPORT_REPLACEMENT_MISSING_SIDE', + partPath, + message: `Only one tracked-change side (insertion) present in ${partPath}; paired-replacement reconstruction cannot complete.`, + detail: { insertions: totalInsertions, deletions: totalDeletions }, + }); + } else if (totalDeletions > 0 && totalInsertions === 0 && totalDeletions === 1) { + partCtx.reportDiagnostic({ + code: 'IMPORT_REPLACEMENT_MISSING_SIDE', + partPath, + message: `Only one tracked-change side (deletion) present in ${partPath}; paired-replacement reconstruction cannot complete.`, + detail: { insertions: totalInsertions, deletions: totalDeletions }, + }); + } + } + } + } + + return ctx; +} diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-diagnostics.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-diagnostics.test.js new file mode 100644 index 0000000000..ef99fadc6f --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-diagnostics.test.js @@ -0,0 +1,174 @@ +// @ts-check +import { describe, it, expect } from 'vitest'; +import { scanImportDiagnostics, scanTrackedElements } from './import-diagnostics.js'; + +const makeIns = (id, author = 'Alice', date = '2024-01-01', children = []) => ({ + name: 'w:ins', + attributes: { 'w:id': id, 'w:author': author, 'w:date': date }, + elements: children, +}); + +const makeDel = (id, author = 'Alice', date = '2024-01-01', children = []) => ({ + name: 'w:del', + attributes: { 'w:id': id, 'w:author': author, 'w:date': date }, + elements: children, +}); + +const makePara = (...children) => ({ name: 'w:p', elements: children }); + +const makeDoc = (...bodyChildren) => ({ + 'word/document.xml': { + elements: [{ name: 'w:document', elements: bodyChildren }], + }, +}); + +describe('scanTrackedElements', () => { + it('returns an empty list for a missing part', () => { + expect(scanTrackedElements(undefined)).toEqual([]); + }); + + it('records parent-child stacking for nested w:ins / w:del', () => { + const docx = makeDoc(makePara(makeIns('1', 'Alice', '2024-01-01', [makeDel('2', 'Bob', '2024-01-02')]))); + const records = scanTrackedElements(docx['word/document.xml']); + expect(records).toHaveLength(2); + + const [outer, inner] = records; + expect(outer.wordId).toBe('1'); + expect(outer.side).toBe('insertion'); + expect(outer.parentWordId).toBeNull(); + + expect(inner.wordId).toBe('2'); + expect(inner.side).toBe('deletion'); + expect(inner.parentWordId).toBe('1'); + expect(inner.parentSide).toBe('insertion'); + }); + + it('captures move elements as __move__ placeholders under tracked parents', () => { + const docx = makeDoc( + makePara( + makeIns('1', 'Alice', '2024-01-01', [ + { name: 'w:moveTo', attributes: { 'w:author': 'Alice', 'w:date': '2024-01-01' } }, + ]), + ), + ); + const records = scanTrackedElements(docx['word/document.xml']); + expect(records).toHaveLength(2); + expect(records[1].wordId).toBe('__move__'); + expect(records[1].parentWordId).toBe('1'); + }); +}); + +describe('scanImportDiagnostics', () => { + it('flags missing author identity by default', () => { + const docx = makeDoc(makePara(makeIns('', '', ''))); // missing author + id + const ctx = scanImportDiagnostics(docx); + expect(ctx.diagnostics().map((d) => d.code)).toContain('IMPORT_MISSING_AUTHOR_IDENTITY'); + }); + + it('flags tracked changes missing w:author', () => { + const docx = makeDoc(makePara(makeIns('1', '', '2024-01-01')), makePara(makeDel('2', '', '2024-01-01'))); + const ctx = scanImportDiagnostics(docx); + const codes = ctx.diagnostics().map((d) => d.code); + expect(codes.filter((c) => c === 'IMPORT_MISSING_AUTHOR_IDENTITY')).toHaveLength(2); + }); + + it('flags imbalanced paired ins+del under paired mode as HEURISTIC_RECONSTRUCTION', () => { + const docx = makeDoc( + makePara( + makeDel('1', 'Alice', '2024-01-01'), + makeIns('2', 'Alice', '2024-01-01'), + makeIns('3', 'Alice', '2024-01-01'), // a second insertion: 2 ins vs 1 del + ), + ); + const ctx = scanImportDiagnostics(docx, { replacements: 'paired' }); + const diag = ctx.diagnostics().find((d) => d.code === 'IMPORT_HEURISTIC_RECONSTRUCTION'); + expect(diag).toBeDefined(); + expect(diag?.detail).toMatchObject({ insertions: 2, deletions: 1 }); + }); + + it('does not flag balanced paired insertions+deletions', () => { + const docx = makeDoc(makePara(makeDel('1', 'Alice', '2024-01-01'), makeIns('2', 'Alice', '2024-01-01'))); + const ctx = scanImportDiagnostics(docx, { replacements: 'paired' }); + expect(ctx.diagnostics().some((d) => d.code === 'IMPORT_HEURISTIC_RECONSTRUCTION')).toBe(false); + expect(ctx.diagnostics().some((d) => d.code === 'IMPORT_REPLACEMENT_MISSING_SIDE')).toBe(false); + }); + + it('flags a single-sided revision (only one tracked side in the part) as REPLACEMENT_MISSING_SIDE', () => { + // A document with only a single deletion and no insertions — Word can + // produce this for pure deletions, but under paired-replacement + // reconstruction it is still a missing-side scenario worth surfacing + // for downstream tooling. + const docx = makeDoc(makePara(makeDel('1', 'Alice', '2024-01-01'))); + const ctx = scanImportDiagnostics(docx, { replacements: 'paired' }); + const diag = ctx.diagnostics().find((d) => d.code === 'IMPORT_REPLACEMENT_MISSING_SIDE'); + expect(diag).toBeDefined(); + expect(diag?.detail).toMatchObject({ deletions: 1, insertions: 0 }); + }); + + it('flags nested children with empty parent w:id as CHILD_MISSING_PARENT', () => { + // Build a w:ins whose attributes are missing w:id, with a nested w:del child. + const docx = makeDoc( + makePara({ + name: 'w:ins', + attributes: { 'w:id': '', 'w:author': 'Alice', 'w:date': '2024-01-01' }, + elements: [makeDel('99', 'Bob', '2024-02-02')], + }), + ); + const ctx = scanImportDiagnostics(docx); + const diag = ctx.diagnostics().find((d) => d.code === 'IMPORT_CHILD_MISSING_PARENT'); + expect(diag).toBeDefined(); + expect(diag?.sourceId).toBe('99'); + }); + + it('flags w:moveTo nested inside w:ins as UNSUPPORTED_STRUCTURAL_OVERLAP', () => { + const docx = makeDoc( + makePara( + makeIns('1', 'Alice', '2024-01-01', [ + { name: 'w:moveTo', attributes: { 'w:author': 'Alice', 'w:date': '2024-01-01' } }, + ]), + ), + ); + const ctx = scanImportDiagnostics(docx); + const diag = ctx.diagnostics().find((d) => d.code === 'IMPORT_UNSUPPORTED_STRUCTURAL_OVERLAP'); + expect(diag).toBeDefined(); + expect(diag?.parentLogicalId).toBe('1'); + }); + + it('flags duplicate same-side w:id observations as DUPLICATE_LOGICAL_ID', () => { + const docx = makeDoc(makePara(makeIns('5', 'Alice', '2024-01-01')), makePara(makeIns('5', 'Alice', '2024-01-01'))); + const ctx = scanImportDiagnostics(docx); + const diag = ctx.diagnostics().find((d) => d.code === 'IMPORT_DUPLICATE_LOGICAL_ID'); + expect(diag).toBeDefined(); + expect(diag?.sourceId).toBe('5'); + expect(diag?.side).toBe('insertion'); + }); + + it('scans header / footer / footnote / endnote parts when present', () => { + const docx = { + 'word/document.xml': { elements: [{ name: 'w:document', elements: [] }] }, + 'word/header1.xml': { + elements: [{ name: 'w:hdr', elements: [makePara(makeIns('h1', '', ''))] }], + }, + 'word/footer1.xml': { + elements: [{ name: 'w:ftr', elements: [makePara(makeDel('f1', '', ''))] }], + }, + 'word/footnotes.xml': { + elements: [{ name: 'w:footnotes', elements: [makePara(makeIns('fn1', '', ''))] }], + }, + 'word/endnotes.xml': { + elements: [{ name: 'w:endnotes', elements: [makePara(makeDel('en1', '', ''))] }], + }, + }; + const ctx = scanImportDiagnostics(docx); + const partPaths = new Set(ctx.diagnostics().map((d) => d.partPath)); + expect(partPaths.has('word/header1.xml')).toBe(true); + expect(partPaths.has('word/footer1.xml')).toBe(true); + expect(partPaths.has('word/footnotes.xml')).toBe(true); + expect(partPaths.has('word/endnotes.xml')).toBe(true); + }); + + it('is a no-op when the docx is empty', () => { + expect(scanImportDiagnostics(null).diagnostics()).toEqual([]); + expect(scanImportDiagnostics({}).diagnostics()).toEqual([]); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/index.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/index.js new file mode 100644 index 0000000000..42a6b9f282 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/index.js @@ -0,0 +1,64 @@ +// @ts-check +/** + * Public surface of the review graph layer. + * + * Consumers (compiler, decision engine, document-api wrappers) should import + * from this module rather than reaching into individual files, so the graph + * layer's internal structure can evolve. + */ + +export { + normalizeEmail, + getCurrentUserIdentity, + getChangeAuthorIdentity, + classifyOwnership, + isSameUserHighConfidence, +} from './identity.js'; + +export { + CanonicalChangeType, + ChangeSubtype, + SegmentSide, + sideFromMarkName, + subtypeFromChangeType, + deterministicJson, + canonicalizeSourceIds, + readTrackedAttrs, + normalizedAttrsEqual, + serializeSourceIds, +} from './mark-metadata.js'; + +export { enumerateTrackedMarkSpans } from './segment-index.js'; + +export { + buildReviewGraph, + getOrBuildReviewGraph, + invalidateReviewGraphCache, + validateGraph, + signatureOf, +} from './review-graph.js'; + +export { runGraphInvariants, graphHasErrors } from './graph-invariants.js'; + +export { BODY_STORY, buildStoryKey, storyLocatorsEqual } from './story-locator.js'; + +export { + makeTextInsertIntent, + makeTextDeleteIntent, + makeTextReplaceIntent, + makeFormatIntent, + sliceFromText, + toSliceContent, +} from './edit-intent.js'; + +export { compileTrackedEdit } from './overlap-compiler.js'; + +export { decideTrackedChanges, buildDecisionBubbleEvents } from './decision-engine.js'; + +export { planCommentEffects, enumerateCommentAnchors } from './comment-effects.js'; + +export { createWordIdAllocator, isDecimalWordId } from './word-id-allocator.js'; + +export { createImportTrackingContext, withParentFrame } from './import-context.js'; + +export { scanImportDiagnostics, scanTrackedElements } from './import-diagnostics.js'; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/mark-metadata.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/mark-metadata.js new file mode 100644 index 0000000000..2a8c3cb05c --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/mark-metadata.js @@ -0,0 +1,383 @@ +// @ts-check +/** + * Mark metadata helpers for the review graph. + * + * The graph is the semantic source of truth. Marks are the persistence + * carrier. This module is the boundary that: + * + * 1. Reads optional persisted review attrs from a mark, falling back to + * inference when they are absent. + * 2. Canonicalizes `sourceIds` to a deterministic JSON object so adjacent + * equivalent marks don't differ only by missing-vs-empty defaults. + * 3. Provides the deterministic JSON serialization used by export. + * + * It deliberately knows nothing about transactions, ProseMirror state, or + * editor identity. It operates on mark attrs only. + */ + +import { TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName } from '../constants.js'; + +/** @typedef {'trackInsert'|'trackDelete'|'trackFormat'} TrackedMarkName */ + +/** + * Canonical semantic types stored on marks. + * + * Internal graph values use these canonical v2-style strings. The public + * document-api `TrackChangeType` union remains legacy `insert | delete | + * format` until the contract is widened (closed product decision + * 2026-05-21). + * + * @readonly + */ +export const CanonicalChangeType = Object.freeze({ + Insertion: 'insertion', + Deletion: 'deletion', + Replacement: 'replacement', + Formatting: 'formatting', +}); + +/** + * Derived `subtype` values for v1 text scope. + * + * @readonly + */ +export const ChangeSubtype = Object.freeze({ + TextInsertion: 'text-insertion', + TextDeletion: 'text-deletion', + TextReplacement: 'text-replacement', + RunFormatting: 'run-formatting', +}); + +/** + * Derived side values for v1 text-overlap scope. + * + * @readonly + */ +export const SegmentSide = Object.freeze({ + Inserted: 'inserted', + Deleted: 'deleted', + Formatting: 'formatting', +}); + +/** @type {Set} */ +const CHANGE_TYPE_VALUES = new Set(Object.values(CanonicalChangeType)); + +/** + * Derive the canonical segment side from a tracked mark name. + * + * @param {string} markName + * @returns {'inserted'|'deleted'|'formatting'|null} + */ +export const sideFromMarkName = (markName) => { + if (markName === TrackInsertMarkName) return SegmentSide.Inserted; + if (markName === TrackDeleteMarkName) return SegmentSide.Deleted; + if (markName === TrackFormatMarkName) return SegmentSide.Formatting; + return null; +}; + +/** + * Derive the subtype string from a canonical change type for v1 text scope. + * + * @param {string} changeType + * @returns {string|null} + */ +export const subtypeFromChangeType = (changeType) => { + switch (changeType) { + case CanonicalChangeType.Insertion: + return ChangeSubtype.TextInsertion; + case CanonicalChangeType.Deletion: + return ChangeSubtype.TextDeletion; + case CanonicalChangeType.Replacement: + return ChangeSubtype.TextReplacement; + case CanonicalChangeType.Formatting: + return ChangeSubtype.RunFormatting; + default: + return null; + } +}; + +/** + * Deterministic JSON serialization with sorted keys at every object level. + * + * Mirrors the rule in phase0-002 / "Attribute Defaults And Rendering": + * + * "`sourceIds` must use one canonical serialization when stored on marks + * or exported as custom metadata: deterministic JSON with sorted keys. + * Do not alternate between object and compact string encodings." + * + * Arrays preserve their order — only object keys are sorted. Functions and + * `undefined` values are dropped, matching JSON.stringify. + * + * @param {*} value + * @returns {string} + */ +export const deterministicJson = (value) => { + const canonical = canonicalizeForSerialization(value); + // Top-level undefined/function => match JSON.stringify(undefined) === undefined. + return JSON.stringify(canonical); +}; + +const canonicalizeForSerialization = (value) => { + if (value === undefined || typeof value === 'function') return undefined; + if (value === null) return null; + if (Array.isArray(value)) { + return value.map((entry) => { + const inner = canonicalizeForSerialization(entry); + // Arrays preserve slot positions; JSON.stringify renders undefined + // slots as null to match standard JSON behavior. + return inner === undefined ? null : inner; + }); + } + if (typeof value === 'object') { + /** @type {Record} */ + const out = {}; + const keys = Object.keys(value).sort(); + for (const key of keys) { + const inner = canonicalizeForSerialization(value[key]); + if (inner === undefined) continue; + out[key] = inner; + } + return out; + } + return value; +}; + +/** + * Normalize a `sourceIds` value to a canonical object form. + * + * Returns `{}` when input is null/undefined/non-object. String inputs are + * parsed as JSON if possible; otherwise treated as an opaque `{ raw: }`. + * + * Output is always a plain object whose entries are themselves deterministic. + * + * @param {*} value + * @returns {Record} + */ +export const canonicalizeSourceIds = (value) => { + if (value == null) return {}; + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return {}; + try { + const parsed = JSON.parse(trimmed); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return canonicalSourceIdsFromObject(parsed); + } + } catch { + /* fall through */ + } + return { raw: trimmed }; + } + + if (typeof value === 'object' && !Array.isArray(value)) { + return canonicalSourceIdsFromObject(value); + } + + return {}; +}; + +/** + * @param {Record} obj + * @returns {Record} + */ +const canonicalSourceIdsFromObject = (obj) => { + /** @type {Record} */ + const out = {}; + const keys = Object.keys(obj).sort(); + for (const key of keys) { + const v = obj[key]; + if (v == null) continue; + if (typeof v === 'string') { + const trimmed = v.trim(); + if (!trimmed) continue; + out[key] = trimmed; + continue; + } + if (typeof v === 'number' || typeof v === 'boolean') { + out[key] = v; + continue; + } + if (typeof v === 'object') { + out[key] = canonicalizeForSerialization(v); + continue; + } + // drop functions / undefined + } + return out; +}; + +/** + * @typedef {Object} NormalizedTrackedAttrs + * @property {string} id Active logical tracked-change id. + * @property {string} revisionGroupId Defaults to `id` for unsplit changes. + * @property {string} splitFromId Retired id this fragment descends from. + * @property {string} changeType Canonical type. + * @property {string} replacementGroupId Logical replacement group id. + * @property {string} replacementSideId Stable side id within a replacement. + * @property {string} overlapParentId Logical parent change id, or ''. + * @property {Record} sourceIds Canonical sourceIds object. + * @property {string} sourceId Legacy raw Word `w:id` value or ''. + * @property {string} importedAuthor Imported author provenance. + * @property {string} origin Optional import origin. + * @property {string} author Display name. + * @property {string} authorId Stable actor id. + * @property {string} authorEmail Author email (not lowercased here). + * @property {string} authorImage Author image url/value. + * @property {string} date Created/modified ISO date. + * @property {string} markType One of trackInsert/trackDelete/trackFormat. + * @property {string} side Derived side. + * @property {string} subtype Derived subtype. + * @property {string} explicitChangeType Persisted changeType attr verbatim, or ''. + * @property {boolean} hasReviewMetadata Was any persisted review attr explicit? + */ + +const stringAttr = (value) => (typeof value === 'string' ? value : ''); + +/** + * Read review attrs off a mark/attrs blob with inference. + * + * Compatibility rules from phase0-002: + * - `id` is the logical id. + * - `revisionGroupId` defaults to `id`. + * - `splitFromId` defaults to `''`. + * - `changeType` is inferred from mark type if missing. + * - `side` is inferred from mark type. + * - explicit new metadata wins over legacy inference. + * - `sourceIds` is canonicalized; legacy `sourceId` is folded in when present. + * + * @param {{ attrs?: Record, type?: { name?: string } } | Record} markOrAttrs + * @param {string} [markName] Required when markOrAttrs is a plain attrs object. + * @returns {NormalizedTrackedAttrs} + */ +export const readTrackedAttrs = (markOrAttrs, markName) => { + const isMark = markOrAttrs && typeof markOrAttrs === 'object' && 'attrs' in markOrAttrs; + const attrs = (isMark ? /** @type {*} */ (markOrAttrs).attrs : markOrAttrs) ?? {}; + const resolvedMarkName = markName ?? (isMark ? /** @type {*} */ (markOrAttrs).type?.name : '') ?? ''; + + const id = stringAttr(attrs.id); + const explicitRevisionGroupId = stringAttr(attrs.revisionGroupId); + const explicitChangeType = stringAttr(attrs.changeType); + const explicitOrigin = stringAttr(attrs.origin); + const explicitSplitFromId = stringAttr(attrs.splitFromId); + const explicitReplacementGroupId = stringAttr(attrs.replacementGroupId); + const explicitReplacementSideId = stringAttr(attrs.replacementSideId); + const explicitOverlapParentId = stringAttr(attrs.overlapParentId); + + const sideInferred = sideFromMarkName(resolvedMarkName) ?? ''; + const changeTypeInferred = inferChangeTypeFromMarkName(resolvedMarkName); + const changeType = + explicitChangeType && CHANGE_TYPE_VALUES.has(explicitChangeType) ? explicitChangeType : changeTypeInferred; + const subtype = subtypeFromChangeType(changeType) ?? ''; + + // sourceIds canonicalization — fold legacy `sourceId` into the canonical + // shape when no explicit canonical entry exists. + const sourceIds = canonicalizeSourceIds(attrs.sourceIds); + const legacySourceId = stringAttr(attrs.sourceId); + if (legacySourceId && resolvedMarkName) { + const key = legacySourceIdKey(resolvedMarkName); + if (key && !sourceIds[key]) { + sourceIds[key] = legacySourceId; + } + } + + const hasReviewMetadata = Boolean( + explicitChangeType || + explicitRevisionGroupId || + explicitSplitFromId || + explicitReplacementGroupId || + explicitReplacementSideId || + explicitOverlapParentId || + explicitOrigin || + (attrs.sourceIds != null && Object.keys(canonicalizeSourceIds(attrs.sourceIds)).length > 0), + ); + + return { + id, + revisionGroupId: explicitRevisionGroupId || id, + splitFromId: explicitSplitFromId, + changeType, + replacementGroupId: explicitReplacementGroupId, + replacementSideId: explicitReplacementSideId, + overlapParentId: explicitOverlapParentId, + sourceIds, + sourceId: legacySourceId, + importedAuthor: stringAttr(attrs.importedAuthor), + origin: explicitOrigin, + author: stringAttr(attrs.author), + authorId: stringAttr(attrs.authorId), + authorEmail: stringAttr(attrs.authorEmail), + authorImage: stringAttr(attrs.authorImage), + date: stringAttr(attrs.date), + markType: resolvedMarkName, + side: sideInferred, + subtype, + explicitChangeType: explicitChangeType && CHANGE_TYPE_VALUES.has(explicitChangeType) ? explicitChangeType : '', + hasReviewMetadata, + }; +}; + +const inferChangeTypeFromMarkName = (markName) => { + if (markName === TrackInsertMarkName) return CanonicalChangeType.Insertion; + if (markName === TrackDeleteMarkName) return CanonicalChangeType.Deletion; + if (markName === TrackFormatMarkName) return CanonicalChangeType.Formatting; + return ''; +}; + +const legacySourceIdKey = (markName) => { + if (markName === TrackInsertMarkName) return 'wordIdInsert'; + if (markName === TrackDeleteMarkName) return 'wordIdDelete'; + if (markName === TrackFormatMarkName) return 'wordIdFormat'; + return ''; +}; + +/** + * Two adjacent marks should be considered "the same logical segment" when + * every persisted attribute that survives normalization matches. + * + * Used by the graph builder to merge adjacent same-id, same-side mark spans + * into one TrackedSegment. The comparison is intentionally normalization-aware: + * a mark with an explicit `revisionGroupId: id` and a mark without that attr + * compare equal, because the graph view treats missing-vs-default as the same. + * + * @param {NormalizedTrackedAttrs} a + * @param {NormalizedTrackedAttrs} b + * @returns {boolean} + */ +export const normalizedAttrsEqual = (a, b) => { + if (a.markType !== b.markType) return false; + if (a.id !== b.id) return false; + if (a.revisionGroupId !== b.revisionGroupId) return false; + if (a.splitFromId !== b.splitFromId) return false; + if (a.changeType !== b.changeType) return false; + // Note: explicitChangeType is intentionally NOT compared here. Two + // adjacent marks where one persists `changeType: 'insertion'` and the + // other relies on legacy inference must still merge — the spec's + // attr-normalization rule says missing-vs-default must not split logical + // segments. + if (a.replacementGroupId !== b.replacementGroupId) return false; + if (a.replacementSideId !== b.replacementSideId) return false; + if (a.overlapParentId !== b.overlapParentId) return false; + if (a.author !== b.author) return false; + if (a.authorId !== b.authorId) return false; + if (a.authorEmail !== b.authorEmail) return false; + if (a.authorImage !== b.authorImage) return false; + if (a.date !== b.date) return false; + if (a.importedAuthor !== b.importedAuthor) return false; + if (a.origin !== b.origin) return false; + if (deterministicJson(a.sourceIds) !== deterministicJson(b.sourceIds)) return false; + return true; +}; + +/** + * Build the deterministic JSON encoding of a sourceIds object for storage + * on a mark attr or export. Empty objects encode as `""` (empty string) + * so PM mark equality stays clean for marks without source ids. + * + * @param {Record} sourceIds + * @returns {string} + */ +export const serializeSourceIds = (sourceIds) => { + if (!sourceIds || Object.keys(sourceIds).length === 0) return ''; + return deterministicJson(sourceIds); +}; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/mark-metadata.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/mark-metadata.test.js new file mode 100644 index 0000000000..b909903dd2 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/mark-metadata.test.js @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; +import { + CanonicalChangeType, + ChangeSubtype, + SegmentSide, + canonicalizeSourceIds, + deterministicJson, + normalizedAttrsEqual, + readTrackedAttrs, + serializeSourceIds, + sideFromMarkName, + subtypeFromChangeType, +} from './mark-metadata.js'; +import { TrackDeleteMarkName, TrackFormatMarkName, TrackInsertMarkName } from '../constants.js'; + +describe('review-model/mark-metadata', () => { + describe('deterministicJson', () => { + it('produces stable output regardless of key order', () => { + const a = deterministicJson({ b: 1, a: { z: 3, m: 2 } }); + const b = deterministicJson({ a: { m: 2, z: 3 }, b: 1 }); + expect(a).toBe(b); + expect(a).toBe('{"a":{"m":2,"z":3},"b":1}'); + }); + it('preserves array order', () => { + expect(deterministicJson([3, 1, 2])).toBe('[3,1,2]'); + }); + it('drops undefined and functions', () => { + expect(deterministicJson({ a: undefined, b: () => 1, c: 2 })).toBe('{"c":2}'); + }); + }); + + describe('canonicalizeSourceIds', () => { + it('returns {} for nullish', () => { + expect(canonicalizeSourceIds(null)).toEqual({}); + expect(canonicalizeSourceIds(undefined)).toEqual({}); + }); + it('parses JSON strings', () => { + expect(canonicalizeSourceIds('{"wordIdInsert":"42"}')).toEqual({ wordIdInsert: '42' }); + }); + it('wraps non-JSON strings as { raw }', () => { + expect(canonicalizeSourceIds('rsid-7')).toEqual({ raw: 'rsid-7' }); + }); + it('drops empty string and null entries', () => { + expect(canonicalizeSourceIds({ wordIdInsert: '', wordIdDelete: null, rsid: 'rs1' })).toEqual({ + rsid: 'rs1', + }); + }); + }); + + describe('side and subtype derivation', () => { + it('maps mark names to sides', () => { + expect(sideFromMarkName(TrackInsertMarkName)).toBe(SegmentSide.Inserted); + expect(sideFromMarkName(TrackDeleteMarkName)).toBe(SegmentSide.Deleted); + expect(sideFromMarkName(TrackFormatMarkName)).toBe(SegmentSide.Formatting); + expect(sideFromMarkName('other')).toBeNull(); + }); + it('maps change types to subtypes', () => { + expect(subtypeFromChangeType(CanonicalChangeType.Insertion)).toBe(ChangeSubtype.TextInsertion); + expect(subtypeFromChangeType(CanonicalChangeType.Deletion)).toBe(ChangeSubtype.TextDeletion); + expect(subtypeFromChangeType(CanonicalChangeType.Replacement)).toBe(ChangeSubtype.TextReplacement); + expect(subtypeFromChangeType(CanonicalChangeType.Formatting)).toBe(ChangeSubtype.RunFormatting); + expect(subtypeFromChangeType('weird')).toBeNull(); + }); + }); + + describe('readTrackedAttrs', () => { + it('infers from legacy attrs', () => { + const result = readTrackedAttrs({ + attrs: { id: 'c1', author: 'A', authorEmail: 'a@x' }, + type: { name: TrackInsertMarkName }, + }); + expect(result.id).toBe('c1'); + expect(result.changeType).toBe(CanonicalChangeType.Insertion); + expect(result.subtype).toBe(ChangeSubtype.TextInsertion); + expect(result.side).toBe(SegmentSide.Inserted); + expect(result.revisionGroupId).toBe('c1'); + expect(result.splitFromId).toBe(''); + expect(result.hasReviewMetadata).toBe(false); + }); + + it('honors explicit overlap metadata', () => { + const result = readTrackedAttrs( + { + attrs: { + id: 'frag1', + revisionGroupId: 'root1', + splitFromId: 'root1', + changeType: CanonicalChangeType.Replacement, + replacementGroupId: 'rep1', + replacementSideId: 'rep1#deleted', + overlapParentId: 'parentA', + sourceIds: { wordIdInsert: '99' }, + origin: 'word', + }, + type: { name: TrackInsertMarkName }, + }, + TrackInsertMarkName, + ); + expect(result.revisionGroupId).toBe('root1'); + expect(result.changeType).toBe(CanonicalChangeType.Replacement); + expect(result.subtype).toBe(ChangeSubtype.TextReplacement); + expect(result.replacementGroupId).toBe('rep1'); + expect(result.replacementSideId).toBe('rep1#deleted'); + expect(result.overlapParentId).toBe('parentA'); + expect(result.sourceIds).toEqual({ wordIdInsert: '99' }); + expect(result.hasReviewMetadata).toBe(true); + }); + + it('folds legacy sourceId into sourceIds under the mark-specific key', () => { + const insert = readTrackedAttrs({ attrs: { id: 'c1', sourceId: '42' }, type: { name: TrackInsertMarkName } }); + expect(insert.sourceIds).toEqual({ wordIdInsert: '42' }); + + const del = readTrackedAttrs({ attrs: { id: 'c1', sourceId: '7' }, type: { name: TrackDeleteMarkName } }); + expect(del.sourceIds).toEqual({ wordIdDelete: '7' }); + }); + }); + + describe('normalizedAttrsEqual', () => { + it('treats missing-vs-empty defaults as equal', () => { + const legacy = readTrackedAttrs({ attrs: { id: 'c1' }, type: { name: TrackInsertMarkName } }); + const explicit = readTrackedAttrs({ + attrs: { + id: 'c1', + revisionGroupId: 'c1', + splitFromId: '', + changeType: CanonicalChangeType.Insertion, + sourceIds: null, + }, + type: { name: TrackInsertMarkName }, + }); + expect(normalizedAttrsEqual(legacy, explicit)).toBe(true); + }); + + it('distinguishes mark type / id', () => { + const a = readTrackedAttrs({ attrs: { id: 'a' }, type: { name: TrackInsertMarkName } }); + const b = readTrackedAttrs({ attrs: { id: 'b' }, type: { name: TrackInsertMarkName } }); + expect(normalizedAttrsEqual(a, b)).toBe(false); + }); + }); + + describe('serializeSourceIds', () => { + it('returns "" for empty source ids', () => { + expect(serializeSourceIds({})).toBe(''); + }); + it('produces deterministic JSON', () => { + expect(serializeSourceIds({ b: '2', a: '1' })).toBe('{"a":"1","b":"2"}'); + }); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js new file mode 100644 index 0000000000..ced0855fed --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js @@ -0,0 +1,1366 @@ +// @ts-check +/** + * Overlap-aware tracked edit compiler. + * + * Single entry point used by tracked text mutation paths: + * + * - `trackedTransaction` (native UI text steps) + * - `replaceStep` (text insert/delete/replace step rewrites) + * - `addMarkStep` / `removeMarkStep` (tracked run formatting) + * - `insertTrackedChange` (document-api tracked writes) + * - the existing backspace-to-text-delete `ReplaceAroundStep` conversion + * - document-api `write-adapter` and tracked plan-engine text rewrites + * + * Callers MUST treat `ok: false` as an abort and MUST NOT fall through to + * applying the original untracked step. + * + * Compiler scope (this plan): text-insert, text-delete, text-replace, and + * run-formatting intents. Other intents fail closed with + * `CAPABILITY_UNAVAILABLE`. + */ + +import { Slice, Fragment } from 'prosemirror-model'; +import { ReplaceStep, Mapping } from 'prosemirror-transform'; +import { v4 as uuidv4 } from 'uuid'; +import { TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName, TrackedFormatMarkNames } from '../constants.js'; +import { buildReviewGraph, CanonicalChangeType, SegmentSide } from './review-graph.js'; +import { graphHasErrors } from './graph-invariants.js'; +import { + classifyOwnership, + getCurrentUserIdentity, + getChangeAuthorIdentity, + isSameUserHighConfidence, + matchesSameUserRefinement, + shouldCollapseNoEmailInsertion, +} from './identity.js'; +import { findMarkPosition } from '../trackChangesHelpers/documentHelpers.js'; +import { markInsertion } from '../trackChangesHelpers/markInsertion.js'; +import { + createMarkSnapshot, + getTypeName, + hasMatchingMark, + isTrackFormatNoOp, + markSnapshotMatchesStepMark, + upsertMarkSnapshotByType, +} from '../trackChangesHelpers/markSnapshotHelpers.js'; +import { getLiveInlineMarksInRange } from '../trackChangesHelpers/getLiveInlineMarksInRange.js'; + +/** + * @typedef {import('./edit-intent.js').TrackedEditIntent} TrackedEditIntent + */ + +/** + * @typedef {Object} SelectionHint + * @property {'near'|'exact-text'} kind + * @property {number} pos + * @property {-1|1} [bias] + */ + +/** + * @typedef {Object} GraphDiagnostic + * @property {string} code + * @property {'info'|'warning'|'error'} severity + * @property {string} message + * @property {string[]} [changeIds] + * @property {unknown} [details] + */ + +/** + * @typedef {Object} TrackedEditSuccess + * @property {true} ok + * @property {import('prosemirror-state').Transaction} tr + * @property {string[]} createdChangeIds + * @property {string[]} updatedChangeIds + * @property {string[]} removedChangeIds + * @property {Array<{ from: string, to: string }>} remappedChangeIds + * @property {SelectionHint} [selection] + * @property {GraphDiagnostic[]} [diagnostics] + * @property {import('prosemirror-model').Mark | null} [insertedMark] + * @property {import('prosemirror-model').Mark | null} [deletionMark] + * @property {import('prosemirror-model').Mark[]} [deletionMarks] + * @property {import('prosemirror-model').Mark[]} [formatMarks] + * @property {number} [insertedFrom] + * @property {number} [insertedTo] + * @property {number} [deletedFrom] + * @property {number} [deletedTo] + * @property {import('prosemirror-model').Node[]} [insertedNodes] + * @property {import('prosemirror-model').Node[]} [deletionNodes] + * @property {import('prosemirror-transform').ReplaceStep | null} [insertedStep] + */ + +/** + * @typedef {Object} TrackedEditFailure + * @property {false} ok + * @property {'CAPABILITY_UNAVAILABLE'|'INVALID_TARGET'|'PRECONDITION_FAILED'} code + * @property {string} message + * @property {unknown} [details] + */ + +/** + * @typedef {TrackedEditSuccess | TrackedEditFailure} TrackedEditResult + */ + +const SUPPORTED_KINDS = new Set(['text-insert', 'text-delete', 'text-replace', 'format-apply', 'format-remove']); +const EMPTY_STRUCTURAL_GAP_REFINEMENT_MAX_DISTANCE = 4; + +/** + * Compile a tracked edit against an accumulated transaction. + * + * The compiler mutates `tr` in place. Callers MUST inspect `result.ok` + * before dispatch; if `ok: false` the transaction has not been altered. + * + * @param {{ + * state: import('prosemirror-state').EditorState, + * tr: import('prosemirror-state').Transaction, + * intent: TrackedEditIntent, + * replacements?: 'paired'|'independent', + * }} input + * @returns {TrackedEditResult} + */ +export const compileTrackedEdit = ({ state, tr, intent, replacements = 'paired' }) => { + if (!intent || !SUPPORTED_KINDS.has(intent.kind)) { + return failure('CAPABILITY_UNAVAILABLE', `Unsupported tracked edit kind ${intent?.kind ?? 'unknown'}.`); + } + + const ctx = makeContext({ state, tr, intent, replacements }); + + // Pre-validate the graph state before allowing the compiler to write. + // graphHasErrors aborts the compile if the document is already in a state + // that would corrupt downstream decisions. + if (graphHasErrors(ctx.graph)) { + return failure('PRECONDITION_FAILED', 'Tracked review graph has invariant errors before edit.', { + diagnostics: ctx.graph.validate(), + }); + } + + try { + switch (intent.kind) { + case 'text-insert': + return compileTextInsert(ctx, intent); + case 'text-delete': + return compileTextDelete(ctx, intent); + case 'text-replace': + return compileTextReplace(ctx, intent); + case 'format-apply': + case 'format-remove': + return compileFormat(ctx, intent); + default: + return failure('CAPABILITY_UNAVAILABLE', 'Unsupported tracked edit kind.'); + } + } catch (error) { + return failure('PRECONDITION_FAILED', /** @type {Error} */ (error).message ?? 'compile failed.', { error }); + } +}; + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +const makeContext = ({ state, tr, intent, replacements }) => { + const schema = state.schema; + const graph = buildReviewGraph({ state: { doc: tr.doc }, replacementsMode: replacements }); + const currentIdentity = getCurrentUserIdentity({ options: { user: intent.user } }); + return { + state, + tr, + schema, + graph, + intent, + replacements, + currentIdentity, + /** @type {string[]} */ createdChangeIds: [], + /** @type {string[]} */ updatedChangeIds: [], + /** @type {string[]} */ removedChangeIds: [], + /** @type {Array<{from:string,to:string}>} */ remappedChangeIds: [], + /** @type {GraphDiagnostic[]} */ diagnostics: [], + }; +}; + +/** + * @param {'CAPABILITY_UNAVAILABLE'|'INVALID_TARGET'|'PRECONDITION_FAILED'} code + * @param {string} message + * @param {unknown} [details] + * @returns {TrackedEditFailure} + */ +const failure = (code, message, details) => ({ + ok: false, + code, + message, + ...(details !== undefined ? { details } : {}), +}); + +// --------------------------------------------------------------------------- +// Helpers — segments, ownership, marks +// --------------------------------------------------------------------------- + +/** + * Classify whether the segment is same-user-owned by the intent's user. + * + * @param {*} ctx + * @param {*} segment + * @returns {'same-user'|'different-user'} + */ +const classifySegment = (ctx, segment) => { + const classification = classifyOwnership({ + currentUser: ctx.currentIdentity, + change: getChangeAuthorIdentity(segment?.attrs ?? {}), + }); + return isSameUserHighConfidence(classification) ? 'same-user' : 'different-user'; +}; + +/** + * Permissive same-user check for refinement (extending the current user's + * own contiguous edit). Differs from the high-confidence `classifySegment` + * gate used for overlap parent decisions: refinement is allowed when the + * stored authorEmail matches the current user's normalized email — including + * when both sides have no email at all (default unidentified user typing). + * + * Permission ownership and overlap parent decisions still require the + * high-confidence `classifySegment` path; this helper is only for "is this + * the same logical author for the purpose of coalescing contiguous edits". + * + * @param {*} ctx + * @param {*} segment + * @returns {boolean} + */ +const isSameUserForRefinement = (ctx, segment) => { + return matchesSameUserRefinement({ + currentUser: ctx.currentIdentity, + change: getChangeAuthorIdentity(segment?.attrs ?? {}), + }); +}; + +const findSegmentAt = (ctx, pos) => { + // Prefer the segment that covers `pos` strictly (pos in [from, to)). When + // `pos` sits exactly at the right edge of a segment, also consider it as a + // boundary for "still inside the same logical change" so we can refine. + const hits = ctx.graph.overlapAt(pos); + if (hits.length) return hits[0]; + // Boundary fallback: a segment that ends exactly at `pos` is still + // adjacent. We do not extend into a segment that starts at `pos`, + // because the cursor is on the live side. + const left = ctx.graph.segments.find((s) => s.to === pos); + if (left) return left; + return null; +}; + +const findSegmentAcrossEmptyStructuralGap = (ctx, pos) => { + let nearest = null; + for (const segment of ctx.graph.segments) { + if (segment.to >= pos) continue; + const distance = pos - segment.to; + if (distance > EMPTY_STRUCTURAL_GAP_REFINEMENT_MAX_DISTANCE) continue; + if (!isEmptyStructuralGap(ctx, segment.to, pos)) continue; + if (!nearest || segment.to > nearest.to) nearest = segment; + } + return nearest; +}; + +const isEmptyStructuralGap = (ctx, from, to) => { + if (to <= from) return false; + if (!sharesTextblock(ctx.tr.doc, from, to)) return false; + if (ctx.graph.segmentsInRange(from, to).length) return false; + if (ctx.tr.doc.textBetween(from, to, '', '')) return false; + + let hasInlineLeaf = false; + ctx.tr.doc.nodesBetween(from, to, (node, pos) => { + if (pos < from || pos >= to) return; + if (node.isInline && node.isLeaf) { + hasInlineLeaf = true; + return false; + } + }); + return !hasInlineLeaf; +}; + +const sharesTextblock = (doc, from, to) => { + const left = textblockStart(doc, from); + const right = textblockStart(doc, to); + return left !== null && left === right; +}; + +const textblockStart = (doc, pos) => { + const resolved = doc.resolve(Math.max(0, Math.min(doc.content.size, pos))); + for (let depth = resolved.depth; depth > 0; depth -= 1) { + if (resolved.node(depth).isTextblock) return resolved.start(depth); + } + return null; +}; + +const segmentsInRange = (ctx, from, to) => ctx.graph.segmentsInRange(from, to); + +const insertSchema = (ctx) => ctx.schema.marks[TrackInsertMarkName]; +const deleteSchema = (ctx) => ctx.schema.marks[TrackDeleteMarkName]; + +const makeInsertMark = (ctx, { id, overlapParentId = '', replacementGroupId = '', replacementSideId = '' }) => { + const attrs = { + id, + author: ctx.intent.user.name || '', + authorId: ctx.intent.user.id || '', + authorEmail: ctx.intent.user.email || '', + authorImage: ctx.intent.user.image || '', + date: ctx.intent.date, + sourceId: '', + importedAuthor: '', + revisionGroupId: id, + splitFromId: '', + // When part of a paired replacement, both halves persist the canonical + // `replacement` changeType so the graph projects one logical change. + changeType: replacementGroupId ? CanonicalChangeType.Replacement : CanonicalChangeType.Insertion, + replacementGroupId, + replacementSideId, + overlapParentId, + sourceIds: null, + origin: '', + }; + return insertSchema(ctx).create(attrs); +}; + +const makeDeleteMark = (ctx, { id, overlapParentId = '', replacementGroupId = '', replacementSideId = '' }) => { + const attrs = { + id, + author: ctx.intent.user.name || '', + authorId: ctx.intent.user.id || '', + authorEmail: ctx.intent.user.email || '', + authorImage: ctx.intent.user.image || '', + date: ctx.intent.date, + sourceId: '', + importedAuthor: '', + revisionGroupId: id, + splitFromId: '', + changeType: replacementGroupId ? CanonicalChangeType.Replacement : CanonicalChangeType.Deletion, + replacementGroupId, + replacementSideId, + overlapParentId, + sourceIds: null, + origin: '', + }; + return deleteSchema(ctx).create(attrs); +}; + +/** + * Strip every tracked insert/delete mark from a slice's text nodes. The + * compiler always re-marks inserted content under its own logical id, so + * leftover marks from the caller would shadow that decision. + * + * @param {import('prosemirror-model').Slice} slice + * @param {*} schema + */ +const stripTrackedMarksFromSlice = (slice, schema) => { + if (!slice || slice === Slice.empty) return slice; + const trackedMarkNames = new Set([TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName]); + const stripFragment = (fragment) => { + /** @type {Array} */ + const children = []; + for (let i = 0; i < fragment.childCount; i += 1) { + const child = fragment.child(i); + if (child.isText) { + const filtered = child.marks.filter((mark) => !trackedMarkNames.has(mark.type.name)); + children.push(filtered.length === child.marks.length ? child : child.mark(filtered)); + } else if (child.content?.childCount) { + children.push(child.copy(stripFragment(child.content))); + } else { + children.push(child); + } + } + return Fragment.from(children); + }; + return new Slice(stripFragment(slice.content), slice.openStart, slice.openEnd); +}; + +// --------------------------------------------------------------------------- +// text-insert +// --------------------------------------------------------------------------- + +/** + * @param {*} ctx + * @param {TrackedEditIntent & { kind: 'text-insert' }} intent + * @returns {TrackedEditResult} + */ +const compileTextInsert = (ctx, intent) => { + const { at, content } = intent; + const docSize = ctx.tr.doc.content.size; + if (at < 0 || at > docSize) { + return failure('INVALID_TARGET', `text-insert position ${at} out of range [0, ${docSize}].`); + } + + const sanitizedSlice = stripTrackedMarksFromSlice(content ?? Slice.empty, ctx.schema); + if (!sanitizedSlice.content.size) { + return failure('INVALID_TARGET', 'text-insert requires non-empty content.'); + } + + // Resolve overlap context at the insertion point. + const containing = findContainingSegment(ctx, at); + const overlapParent = containing && containing.from < at && containing.to > at ? containing : null; + const boundaryAdjacent = + !overlapParent && containing && (containing.to === at || containing.from === at) ? containing : null; + const emptyGapAdjacent = !overlapParent && !boundaryAdjacent ? findSegmentAcrossEmptyStructuralGap(ctx, at) : null; + + // Same-user refinement targets: own insertion that strictly contains `at`, + // an own-insertion edge we are adjacent to, OR the same edge separated only + // by run-wrapper position gaps. Refinement uses the + // permissive `isSameUserForRefinement` check so contiguous typing by the + // default unidentified user (no email) still coalesces into one id — + // matching the legacy `findTrackedMarkBetween({ authorEmail: '' })` + // behavior. Permission and overlap-parent decisions still go through the + // high-confidence `classifySegment` gate. + const refinementTarget = + overlapParent && overlapParent.side === SegmentSide.Inserted && isSameUserForRefinement(ctx, overlapParent) + ? overlapParent + : boundaryAdjacent && + boundaryAdjacent.side === SegmentSide.Inserted && + isSameUserForRefinement(ctx, boundaryAdjacent) + ? boundaryAdjacent + : emptyGapAdjacent && + emptyGapAdjacent.side === SegmentSide.Inserted && + isSameUserForRefinement(ctx, emptyGapAdjacent) + ? emptyGapAdjacent + : null; + + if (refinementTarget) { + const refinedId = refinementTarget.changeId; + const insertedMark = makeInsertMark(ctx, { + id: refinedId, + overlapParentId: refinementTarget.attrs.overlapParentId || '', + replacementGroupId: refinementTarget.attrs.replacementGroupId || '', + replacementSideId: refinementTarget.attrs.replacementSideId || '', + }); + return applyInsert(ctx, at, sanitizedSlice, insertedMark, refinedId, { update: true }); + } + + // Different-user content (or any non-insertion parent): exact location, + // create a child insertion. For different-user inserted parent the rule is + // the same — refine semantically with a new id and `overlapParentId`. + if (overlapParent) { + const childId = intent.replacementGroupHint || uuidv4(); + const overlapParentId = overlapParent.changeId; + const insertedMark = makeInsertMark(ctx, { id: childId, overlapParentId }); + return applyInsert(ctx, at, sanitizedSlice, insertedMark, childId, { create: true }); + } + + // No overlap — fresh insertion at the cursor. + const newId = intent.replacementGroupHint || uuidv4(); + const insertedMark = makeInsertMark(ctx, { id: newId }); + return applyInsert(ctx, at, sanitizedSlice, insertedMark, newId, { create: true }); +}; + +/** + * @param {*} ctx + * @param {number} at + * @param {import('prosemirror-model').Slice} slice + * @param {import('prosemirror-model').Mark} insertMark + * @param {string} changeId + * @param {{ update?: boolean, create?: boolean }} flags + * @returns {TrackedEditResult} + */ +const applyInsert = (ctx, at, slice, insertMark, changeId, { update, create }) => { + const beforeSize = ctx.tr.doc.content.size; + try { + ctx.tr.replaceRange(at, at, slice); + } catch (error) { + return failure('INVALID_TARGET', /** @type {Error} */ (error).message ?? 'replaceRange failed.'); + } + const afterSize = ctx.tr.doc.content.size; + if (afterSize === beforeSize) { + return failure('INVALID_TARGET', 'text-insert did not change the document.'); + } + + const insertedFrom = at; + const insertedTo = at + (afterSize - beforeSize); + + // Re-apply tracked-insert mark over the inserted range. The slice + // contained no tracked marks (we stripped them), so this is the canonical + // marking. + ctx.tr.addMark(insertedFrom, insertedTo, insertMark); + + if (create) ctx.createdChangeIds.push(changeId); + else if (update) ctx.updatedChangeIds.push(changeId); + + /** @type {Array} */ + const insertedNodes = []; + ctx.tr.doc.nodesBetween(insertedFrom, insertedTo, (node) => { + if (node.isInline) insertedNodes.push(node); + }); + + return { + ok: true, + tr: ctx.tr, + createdChangeIds: ctx.createdChangeIds, + updatedChangeIds: ctx.updatedChangeIds, + removedChangeIds: ctx.removedChangeIds, + remappedChangeIds: ctx.remappedChangeIds, + selection: { kind: 'near', pos: insertedTo, bias: 1 }, + insertedMark: insertMark, + insertedFrom, + insertedTo, + insertedNodes, + }; +}; + +// --------------------------------------------------------------------------- +// text-delete +// --------------------------------------------------------------------------- + +/** + * Delete one inline byte range, decomposed by graph segments. + * + * Behavior per segment: + * - own-insertion (covered/partial) → collapse: remove the inserted slice + * - other-insertion → child trackDelete with overlapParentId + * - existing deletion → no-op (plain delete preserves existing review ids) + * - live content → trackDelete mark + * + * @param {*} ctx + * @param {import('./edit-intent.js').TrackedEditIntent & { kind: 'text-delete' }} intent + * @returns {TrackedEditResult} + */ +const compileTextDelete = (ctx, intent) => { + const docSize = ctx.tr.doc.content.size; + if (intent.from < 0 || intent.to > docSize) { + return failure('INVALID_TARGET', `text-delete range [${intent.from}, ${intent.to}] out of bounds.`); + } + if (intent.from === intent.to) { + return failure('INVALID_TARGET', 'text-delete requires a non-empty range.'); + } + + const result = applyTrackedDelete(ctx, intent.from, intent.to, { + replacementGroupId: '', + replacementSideId: '', + sharedDeletionId: intent.replacementGroupHint || null, + recordSharedDeletionId: Boolean(intent.replacementGroupHint), + }); + if (result.ok === false) return result; + + // Caret at original `from`: matches Word's behavior where the cursor sits + // at the left edge of a tracked deletion. + return { + ok: true, + tr: ctx.tr, + createdChangeIds: ctx.createdChangeIds, + updatedChangeIds: ctx.updatedChangeIds, + removedChangeIds: ctx.removedChangeIds, + remappedChangeIds: ctx.remappedChangeIds, + selection: { kind: 'near', pos: intent.from, bias: -1 }, + deletionMark: result.deletionMarks[0] || null, + deletionMarks: result.deletionMarks, + deletionNodes: result.deletionNodes, + }; +}; + +/** + * Apply tracked deletion semantics across [from, to], returning the marks + * we wrote so callers (text-replace) can reuse the id. + * + * @param {*} ctx + * @param {number} from + * @param {number} to + * @param {{ replacementGroupId: string, replacementSideId: string, sharedDeletionId: string | null, recordSharedDeletionId?: boolean, recordCollapsedIds?: boolean, reassignExistingDeletions?: boolean }} options + * @returns {{ ok: true, deletionMarks: import('prosemirror-model').Mark[], deletionNodes: import('prosemirror-model').Node[], deletionId: string, mintedThisCall: boolean } | TrackedEditFailure} + */ +const applyTrackedDelete = ( + ctx, + from, + to, + { + replacementGroupId, + replacementSideId, + sharedDeletionId, + recordSharedDeletionId = false, + recordCollapsedIds = true, + reassignExistingDeletions = false, + }, +) => { + /** @type {Array} */ + const deletionMarks = []; + /** @type {Array} */ + const deletionNodes = []; + // Walk inline leaf nodes and act per node. We never mutate while iterating + // — collect operations first, then apply in reverse position order so + // earlier positions remain stable. + /** @type {Array<{ kind: 'collapse'|'mark-delete'|'reassign'|'noop', from: number, to: number, node?: import('prosemirror-model').Node, changeId?: string, parentId?: string, parentSide?: string, parentReplacementGroupId?: string, existingDeleteMarks?: Array }>} */ + const ops = []; + + // Imported-insertion collapse rule (plan §4): no-email imported insertions + // collapse only when they are truly unattributed, or when their no-email + // display name matches the current user. Named different authors with no + // email remain protected review state. + const isImportedOwnInsertion = (mark) => { + if (!mark) return false; + return shouldCollapseNoEmailInsertion({ + currentUser: ctx.intent.user, + insertionAttrs: mark.attrs, + }); + }; + + ctx.tr.doc.nodesBetween(from, to, (node, pos) => { + if (!node.isInline || !node.isLeaf) return; + if (node.type.name.includes('table')) return; + const segFrom = Math.max(from, pos); + const segTo = Math.min(to, pos + node.nodeSize); + if (segFrom >= segTo) return; + + const insertMark = node.marks.find((m) => m.type.name === TrackInsertMarkName); + const existingDelete = node.marks.find((m) => m.type.name === TrackDeleteMarkName); + + if (insertMark) { + const segmentAtPos = ctx.graph.overlapAt(pos)[0] ?? null; + const classification = classifyOwnership({ + currentUser: ctx.currentIdentity, + change: getChangeAuthorIdentity(segmentAtPos?.attrs ?? insertMark.attrs), + }); + const ownership = isSameUserHighConfidence(classification) ? 'same-user' : 'different-user'; + const shouldCollapseOwnInsertion = + !ctx.intent.preserveExistingReviewState && (ownership === 'same-user' || isImportedOwnInsertion(insertMark)); + if (shouldCollapseOwnInsertion) { + // Own insertion → collapse (remove proposed content). + ops.push({ kind: 'collapse', from: segFrom, to: segTo, changeId: insertMark.attrs.id }); + return; + } + // Different-user inserted content → child trackDelete with overlapParentId. + const parentId = insertMark.attrs.id; + ops.push({ + kind: 'mark-delete', + from: segFrom, + to: segTo, + node, + parentId, + parentSide: SegmentSide.Inserted, + }); + return; + } + + if (existingDelete) { + const allExistingDeletes = node.marks.filter((m) => m.type.name === TrackDeleteMarkName); + if (reassignExistingDeletions) { + ops.push({ + kind: 'reassign', + from: segFrom, + to: segTo, + node, + existingDeleteMarks: allExistingDeletes, + }); + return; + } + // Plain delete inside any existing deletion is already represented by + // review state. Preserve the original mark ids and do not add a nested + // delete unless the replacement path explicitly asked to reassign them. + ops.push({ kind: 'noop', from: segFrom, to: segTo }); + return; + } + + // Live content. + ops.push({ kind: 'mark-delete', from: segFrom, to: segTo, node }); + }); + + if (!ops.length) { + return failure('CAPABILITY_UNAVAILABLE', `text-delete range [${from}, ${to}] has no inline text content to track.`); + } + + // Apply in reverse so earlier positions stay stable for `collapse` ops. + const sortedOps = [...ops].sort((a, b) => b.from - a.from); + // Allocate a deletion id. For paired replacement, the caller provides one. + const deletionId = sharedDeletionId ?? uuidv4(); + let mintedThisCall = false; + const collapsedIds = new Set(); + + for (const op of sortedOps) { + if (op.kind === 'collapse') { + try { + ctx.tr.replaceRange(op.from, op.to, Slice.empty); + } catch { + // Defensive — if PM refuses the deletion, fail closed. + return failure('INVALID_TARGET', `Cannot collapse own-insertion range [${op.from}, ${op.to}].`); + } + if (op.changeId) collapsedIds.add(op.changeId); + continue; + } + if (op.kind === 'noop') continue; + if (op.kind === 'reassign') { + // Replacement over existing deletion: reassign deletion id to the new + // deletion mark so the new replacement encloses the prior delete. + const mark = makeDeleteMark(ctx, { + id: deletionId, + overlapParentId: '', + replacementGroupId, + replacementSideId, + }); + try { + for (const m of op.existingDeleteMarks ?? []) ctx.tr.removeMark(op.from, op.to, m); + ctx.tr.addMark(op.from, op.to, mark); + deletionMarks.push(mark); + if (op.node) deletionNodes.push(op.node); + if (!mintedThisCall) { + if (!sharedDeletionId || recordSharedDeletionId) ctx.createdChangeIds.push(deletionId); + mintedThisCall = true; + } + } catch (error) { + return failure('INVALID_TARGET', /** @type {Error} */ (error).message ?? 'addMark failed.'); + } + continue; + } + if (op.kind === 'mark-delete') { + const mark = makeDeleteMark(ctx, { + id: deletionId, + overlapParentId: op.parentId || '', + replacementGroupId, + replacementSideId, + }); + try { + ctx.tr.addMark(op.from, op.to, mark); + deletionMarks.push(mark); + if (op.node) deletionNodes.push(op.node); + if (!mintedThisCall) { + if (!sharedDeletionId || recordSharedDeletionId) ctx.createdChangeIds.push(deletionId); + mintedThisCall = true; + } + } catch (error) { + return failure('INVALID_TARGET', /** @type {Error} */ (error).message ?? 'addMark failed.'); + } + } + } + + if (recordCollapsedIds && collapsedIds.size) { + const finalGraph = buildReviewGraph({ + state: { doc: ctx.tr.doc }, + replacementsMode: ctx.replacements, + }); + for (const id of collapsedIds) { + if (finalGraph.changes.has(id)) ctx.updatedChangeIds.push(id); + else ctx.removedChangeIds.push(id); + } + } + + return { ok: true, deletionMarks, deletionNodes, deletionId, mintedThisCall }; +}; + +// --------------------------------------------------------------------------- +// text-replace +// --------------------------------------------------------------------------- + +/** + * @param {*} ctx + * @param {TrackedEditIntent & { kind: 'text-replace' }} intent + * @returns {TrackedEditResult} + */ +const compileTextReplace = (ctx, intent) => { + const docSize = ctx.tr.doc.content.size; + if (intent.from < 0 || intent.to > docSize) { + return failure('INVALID_TARGET', `text-replace range [${intent.from}, ${intent.to}] out of bounds.`); + } + if (intent.from > intent.to) { + return failure('INVALID_TARGET', 'text-replace `from` must be <= `to`.'); + } + + const sanitizedSlice = stripTrackedMarksFromSlice(intent.content ?? Slice.empty, ctx.schema); + if (!sanitizedSlice.content.size && intent.from === intent.to) { + return failure('INVALID_TARGET', 'text-replace requires a non-empty replacement or non-empty range.'); + } + + const segments = segmentsInRange(ctx, intent.from, intent.to); + + // Replacing text inside own insertion/replacement-inserted side refines + // that inserted side. Rejecting the original insertion must still remove + // all proposed content, including the replacement text. + const ownInsertedTarget = getSingleFullyCoveringOwnInsertedSegment(ctx, segments, intent.from, intent.to); + if (ownInsertedTarget && !intent.preserveExistingReviewState) { + const deleteResult = applyTrackedDelete(ctx, intent.from, intent.to, { + replacementGroupId: '', + replacementSideId: '', + sharedDeletionId: null, + recordCollapsedIds: false, + }); + if (deleteResult.ok === false) return deleteResult; + + if (!sanitizedSlice.content.size) { + ctx.updatedChangeIds.push(ownInsertedTarget.changeId); + return { + ok: true, + tr: ctx.tr, + createdChangeIds: ctx.createdChangeIds, + updatedChangeIds: ctx.updatedChangeIds, + removedChangeIds: ctx.removedChangeIds, + remappedChangeIds: ctx.remappedChangeIds, + selection: { kind: 'near', pos: intent.from, bias: -1 }, + }; + } + + const insertMark = makeInsertMark(ctx, { + id: ownInsertedTarget.changeId, + overlapParentId: ownInsertedTarget.attrs.overlapParentId || '', + replacementGroupId: ownInsertedTarget.attrs.replacementGroupId || '', + replacementSideId: ownInsertedTarget.attrs.replacementSideId || '', + }); + return applyInsert( + ctx, + clampToDocSize(ctx.tr.doc.content.size, intent.from), + sanitizedSlice, + insertMark, + ownInsertedTarget.changeId, + { + update: true, + }, + ); + } + + // SD-2335: replacing text inside own deletion preserves the deletion and + // creates an insertion at the exact edit point. We detect this case by + // looking at the segments in the original range before any mutation. + const ownDeletionFullyCovers = + segments.length > 0 && + segments.every( + (s) => + s.side === SegmentSide.Deleted && + classifySegment(ctx, s) === 'same-user' && + s.from <= intent.from && + s.to >= intent.to, + ); + + if (ownDeletionFullyCovers && sanitizedSlice.content.size) { + // Insertion at exact `from` — preserve original deletion as-is. + const insertId = uuidv4(); + const insertMark = makeInsertMark(ctx, { id: insertId }); + return applyInsert(ctx, intent.from, sanitizedSlice, insertMark, insertId, { create: true }); + } + + // Different-user nested case: the replacement happens inside another author's + // open review item. Each side must remain independently reviewable, so use + // distinct ids and the exact edit location for the insertion. + const replacementParentId = getReplacementParentId(ctx, segments); + + // Ordinary live replacement: use the proven "insert after deleted range" + // algorithm so existing product behavior is preserved (paragraph order on + // multi-paragraph replacements, SD-3044 shared-anchor rewrites, + // accept-side text identity). The compiler is the single semantic center; + // markInsertion / markDeletion are used as low-level primitives. + return compileOrdinaryTextReplace(ctx, intent, sanitizedSlice, replacementParentId); +}; + +/** + * Ordinary text replacement (no own-inserted refinement, no own-deletion + * preservation). Mirrors the legacy `replaceStep` algorithm verbatim and is + * the only ordinary-replacement implementation: there is no dual semantic + * path. The compiler owns the decision tree; the legacy markInsertion / + * markDeletion helpers are imported as low-level primitives. + * + * Order of operations: + * 1. Optionally probe for an adjacent tracked-delete span (single-step + * user replace only; multi-step transactions don't probe). + * 2. In a throwaway temp transaction, insert the original slice at the + * chosen position. Fall back to Slice.maxOpen on failure to make + * paste-into-textblock cases merge inline. + * 3. Mark the inserted range with the insertion mark (refining same-user + * adjacent ids if present) in the temp tr. + * 4. Extract the marked slice and apply it as a single condensed + * ReplaceStep on ctx.tr (so the tracked-transaction stays single-step). + * 5. Run applyTrackedDelete on the original range to mark deletion (this + * may collapse own insertions, reassign existing deletions, etc.). + * 6. Map insertedTo through the delete-induced mapping so the selection / + * meta still points after the inserted content. + * + * @param {*} ctx + * @param {TrackedEditIntent & { kind: 'text-replace' }} intent + * @param {import('prosemirror-model').Slice} sanitizedSlice + * @param {string} replacementParentId + * @returns {TrackedEditResult} + */ +const compileOrdinaryTextReplace = (ctx, intent, sanitizedSlice, replacementParentId) => { + // In paired mode share one id between insert/delete sides so a top-level + // replacement projects as one logical graph change. A replacement nested + // inside another author's open review item must keep each side separately + // reviewable, so those child sides intentionally use distinct ids even when + // the caller's default replacement mode is paired. + const shouldPairReplacement = intent.replacements === 'paired' && !replacementParentId; + const sharedId = shouldPairReplacement ? intent.replacementGroupHint || uuidv4() : null; + const replacementGroupId = sharedId ?? ''; + + // 1. Probe for adjacent tracked-delete span at intent.to - 1 (legacy + // behavior). Only applies for single-step user actions — plan-engine + // multi-step rewrites must not probe. + let positionTo = intent.to; + if (intent.from !== intent.to && intent.probeForDeletionSpan) { + const probePos = Math.max(intent.from, intent.to - 1); + const deletionSpan = findMarkPosition(ctx.tr.doc, probePos, TrackDeleteMarkName); + if (deletionSpan && deletionSpan.to > positionTo) positionTo = deletionSpan.to; + } + + // 2. Build a temp insertion in a throwaway transaction so we can read the + // inserted positions and the marked slice. We then condense the result + // into a single ReplaceStep on ctx.tr. + const baseParentIsTextblock = ctx.tr.doc.resolve(positionTo).parent?.isTextblock; + const shouldPreferInlineInsertion = intent.from === intent.to && baseParentIsTextblock; + + const tryTempInsert = (slice) => { + const tempTr = ctx.state.apply(ctx.tr).tr; + const isEmptySlice = !slice || slice.content.size === 0; + try { + tempTr.replaceRange(positionTo, positionTo, slice ?? Slice.empty); + } catch { + return null; + } + if (!tempTr.docChanged && !isEmptySlice) return null; + const insertedFrom = tempTr.mapping.map(positionTo, -1); + const insertedTo = tempTr.mapping.map(positionTo, 1); + if (insertedFrom === insertedTo) return { tempTr, insertedFrom, insertedTo }; + if (shouldPreferInlineInsertion && !tempTr.doc.resolve(insertedFrom).parent?.isTextblock) return null; + return { tempTr, insertedFrom, insertedTo }; + }; + + let insertion = null; + if (sanitizedSlice.content.size) { + const openSlice = Slice.maxOpen(sanitizedSlice.content, true); + insertion = tryTempInsert(sanitizedSlice) || tryTempInsert(openSlice); + if (!insertion) { + return failure('CAPABILITY_UNAVAILABLE', 'replacement slice could not be inserted into the document.'); + } + } + + /** @type {import('prosemirror-model').Mark | null} */ + let insertedMark = null; + /** @type {import('prosemirror-model').Slice} */ + let trackedInsertedSlice = Slice.empty; + /** @type {Array} */ + const insertedNodes = []; + + if (insertion && insertion.insertedFrom !== insertion.insertedTo) { + const { tempTr, insertedFrom, insertedTo } = insertion; + // Use the legacy markInsertion primitive so id reuse / refinement matches + // existing behavior exactly. Compiler-specific overlap fields + // (overlapParentId, replacementGroupId, replacementSideId) are layered on + // afterward. + const forcedInsertId = sharedId || (replacementParentId ? uuidv4() : undefined); + insertedMark = markInsertion({ + tr: tempTr, + from: insertedFrom, + to: insertedTo, + user: ctx.intent.user, + date: ctx.intent.date, + id: forcedInsertId, + }); + if (!insertedMark) { + return failure('PRECONDITION_FAILED', 'Failed to create tracked insertion mark for replacement.'); + } + if (replacementParentId || replacementGroupId) { + const overlayMark = makeInsertMark(ctx, { + id: insertedMark.attrs.id, + overlapParentId: replacementParentId, + replacementGroupId, + replacementSideId: sharedId ? `${sharedId}#inserted` : '', + }); + tempTr.removeMark(insertedFrom, insertedTo, insertedMark); + tempTr.addMark(insertedFrom, insertedTo, overlayMark); + insertedMark = overlayMark; + } + const insertId = /** @type {import('prosemirror-model').Mark} */ (insertedMark).attrs.id; + if (!ctx.createdChangeIds.includes(insertId) && !ctx.updatedChangeIds.includes(insertId)) { + ctx.createdChangeIds.push(insertId); + } + trackedInsertedSlice = tempTr.doc.slice(insertedFrom, insertedTo); + tempTr.doc.nodesBetween(insertedFrom, insertedTo, (node) => { + if (node.isInline) insertedNodes.push(node); + }); + } + + // 3. Apply the condensed insertion step to ctx.tr. + let insertedFromAbs = positionTo; + let insertedToAbs = positionTo; + let insertedLength = 0; + /** @type {import('prosemirror-transform').ReplaceStep | null} */ + let condensedStep = null; + if (trackedInsertedSlice && trackedInsertedSlice.content.size) { + const stepIndexBeforeCondensed = ctx.tr.steps.length; + condensedStep = new ReplaceStep(positionTo, positionTo, trackedInsertedSlice, false); + if (ctx.tr.maybeStep(condensedStep).failed) { + return failure('INVALID_TARGET', 'condensed insertion step failed to apply.'); + } + // Record the actual inserted range using just the condensed step's map. + const condensedMap = ctx.tr.steps[stepIndexBeforeCondensed].getMap(); + insertedFromAbs = condensedMap.map(positionTo, -1); + insertedToAbs = condensedMap.map(positionTo, 1); + insertedLength = insertedToAbs - insertedFromAbs; + } + + // 4. Apply tracked delete on the original range. The range positions are + // unaffected by the insertion (insertion happened at positionTo which is + // >= intent.to). The delete may collapse own insertions inside the + // range, shifting the doc — we map the inserted position through the + // delete-induced map after. + /** @type {Array} */ + let deletionMarks = []; + /** @type {Array} */ + let deletionNodes = []; + /** @type {import('prosemirror-model').Mark | null} */ + let deletionMark = null; + + if (intent.from !== intent.to) { + const stepsBefore = ctx.tr.steps.length; + const delResult = applyTrackedDelete(ctx, intent.from, intent.to, { + replacementGroupId, + replacementSideId: sharedId ? `${sharedId}#deleted` : '', + sharedDeletionId: sharedId, + reassignExistingDeletions: Boolean(sharedId), + }); + if (delResult.ok === false) return delResult; + deletionMarks = delResult.deletionMarks; + deletionMark = delResult.deletionMarks[0] || null; + deletionNodes = delResult.deletionNodes; + // Map inserted positions through delete steps so collapses don't strand + // them past stale offsets. + if (insertedLength > 0) { + const delMapping = new Mapping(); + for (let i = stepsBefore; i < ctx.tr.steps.length; i += 1) { + delMapping.appendMap(ctx.tr.steps[i].getMap()); + } + insertedFromAbs = delMapping.map(insertedFromAbs, 1); + insertedToAbs = delMapping.map(insertedToAbs, 1); + } + } + + /** @type {SelectionHint} */ + const selection = + insertedLength > 0 ? { kind: 'near', pos: insertedToAbs, bias: 1 } : { kind: 'near', pos: intent.from, bias: -1 }; + + return { + ok: true, + tr: ctx.tr, + createdChangeIds: ctx.createdChangeIds, + updatedChangeIds: ctx.updatedChangeIds, + removedChangeIds: ctx.removedChangeIds, + remappedChangeIds: ctx.remappedChangeIds, + selection, + insertedMark, + insertedFrom: insertedFromAbs, + insertedTo: insertedToAbs, + insertedNodes, + insertedStep: condensedStep, + deletionMark, + deletionMarks, + deletionNodes, + }; +}; + +const clampToDocSize = (size, pos) => Math.max(0, Math.min(size, pos)); + +const getSingleFullyCoveringOwnInsertedSegment = (ctx, segments, from, to) => { + if (!segments.length) return null; + const inserted = segments.filter( + (s) => s.side === SegmentSide.Inserted && classifySegment(ctx, s) === 'same-user' && s.from <= from && s.to >= to, + ); + if (inserted.length !== segments.length) return null; + const [first] = inserted; + if (!first) return null; + return inserted.every((s) => s.changeId === first.changeId) ? first : null; +}; + +const getReplacementParentId = (ctx, segments) => { + for (const segment of segments) { + if (classifySegment(ctx, segment) !== 'different-user') continue; + if (segment.attrs?.overlapParentId) return segment.attrs.overlapParentId; + return segment.changeId || ''; + } + return ''; +}; + +// --------------------------------------------------------------------------- +// format-apply / format-remove +// --------------------------------------------------------------------------- + +/** + * @param {*} ctx + * @param {TrackedEditIntent & { kind: 'format-apply'|'format-remove' }} intent + * @returns {TrackedEditResult} + */ +const compileFormat = (ctx, intent) => { + if (!TrackedFormatMarkNames.includes(intent.mark.type.name)) { + return failure('CAPABILITY_UNAVAILABLE', `Mark ${intent.mark.type.name} is not a tracked formatting mark.`); + } + + const subranges = computeFormatLeafRanges(ctx, intent.from, intent.to); + if (!subranges) return failure('CAPABILITY_UNAVAILABLE', 'format range crosses tracked-deleted content.'); + + const trackFormatType = ctx.schema.marks[TrackFormatMarkName]; + const needsTrackFormat = subranges.some((range) => !range.fold); + if (!trackFormatType && needsTrackFormat) { + return failure('CAPABILITY_UNAVAILABLE', 'schema is missing trackFormat mark.'); + } + + /** @type {Array} */ + const formatMarks = []; + /** @type {string | null} */ + let sharedWid = null; + + for (const range of subranges) { + if (range.fold) { + if (intent.kind === 'format-apply') ctx.tr.addMark(range.from, range.to, intent.mark); + else ctx.tr.removeMark(range.from, range.to, intent.mark); + continue; + } + + const result = applyTrackedFormatRange({ + ctx, + intent, + range, + trackFormatType, + sharedWid, + }); + sharedWid = result.sharedWid; + if (result.formatMark) { + formatMarks.push(result.formatMark); + if (!ctx.createdChangeIds.includes(result.formatMark.attrs.id)) { + ctx.createdChangeIds.push(result.formatMark.attrs.id); + } + } + } + + return { + ok: true, + tr: ctx.tr, + createdChangeIds: ctx.createdChangeIds, + updatedChangeIds: ctx.updatedChangeIds, + removedChangeIds: ctx.removedChangeIds, + remappedChangeIds: ctx.remappedChangeIds, + formatMarks, + }; +}; + +/** + * Collect leaf inline formatting ranges. Same-user inserted ranges fold the + * live mark directly into the insertion; all other live/other-user inserted + * ranges use the normal trackFormat snapshot model. Tracked-deleted content + * fails closed because applying visual formatting there is not safely + * representable as an independent review action. + * + * @returns {Array<{ from: number, to: number, fold: boolean, parentId?: string, node: import('prosemirror-model').Node }> | null} + */ +const computeFormatLeafRanges = (ctx, from, to) => { + /** @type {Array<{ from: number, to: number, fold: boolean, parentId?: string, node: import('prosemirror-model').Node }>} */ + const ranges = []; + let touchesDeletion = false; + + ctx.tr.doc.nodesBetween(from, to, (node, pos) => { + if (touchesDeletion) return false; + if (!node.isInline || node.type.name === 'run') return; + + const segFrom = Math.max(from, pos); + const segTo = Math.min(to, pos + node.nodeSize); + if (segFrom >= segTo) return; + + const deleteMark = node.marks.find((m) => m.type.name === TrackDeleteMarkName); + if (deleteMark) { + touchesDeletion = true; + return false; + } + + const insertMark = node.marks.find((m) => m.type.name === TrackInsertMarkName); + if (insertMark) { + const ownership = classifySegment(ctx, { attrs: insertMark.attrs }); + ranges.push({ + from: segFrom, + to: segTo, + fold: ownership === 'same-user', + ...(ownership === 'same-user' ? {} : { parentId: insertMark.attrs.id }), + node, + }); + return; + } + + ranges.push({ from: segFrom, to: segTo, fold: false, node }); + }); + + if (touchesDeletion) return null; + return ranges; +}; + +/** + * Apply ordinary tracked formatting semantics for one leaf range, preserving + * the previous snapshot behavior while allowing overlap-parent metadata when + * formatting another user's inserted text. + * + * @param {{ + * ctx: *, + * intent: TrackedEditIntent & { kind: 'format-apply'|'format-remove' }, + * range: { from: number, to: number, parentId?: string, node: import('prosemirror-model').Node }, + * trackFormatType: import('prosemirror-model').MarkType, + * sharedWid: string | null, + * }} input + * @returns {{ sharedWid: string | null, formatMark: import('prosemirror-model').Mark | null }} + */ +const applyTrackedFormatRange = ({ ctx, intent, range, trackFormatType, sharedWid }) => { + if (intent.kind === 'format-apply') { + return applyTrackedFormatAdd({ ctx, intent, range, trackFormatType, sharedWid }); + } + return applyTrackedFormatRemove({ ctx, intent, range, trackFormatType, sharedWid }); +}; + +const applyTrackedFormatAdd = ({ ctx, intent, range, trackFormatType, sharedWid }) => { + const liveMarks = getLiveInlineMarksInRange({ + doc: ctx.tr.doc, + from: range.from, + to: range.to, + }); + const existingChangeMark = liveMarks.find((mark) => + [TrackDeleteMarkName, TrackFormatMarkName].includes(mark.type.name), + ); + const wid = existingChangeMark ? existingChangeMark.attrs.id : (sharedWid ?? uuidv4()); + + ctx.tr.addMark(range.from, range.to, intent.mark); + + if (!hasMatchingMark(liveMarks, intent.mark)) { + const formatChangeMark = liveMarks.find((mark) => mark.type.name === TrackFormatMarkName); + let after = []; + let before = []; + + if (formatChangeMark) { + const beforeSnapshots = Array.isArray(formatChangeMark.attrs.before) ? formatChangeMark.attrs.before : []; + const afterSnapshots = Array.isArray(formatChangeMark.attrs.after) ? formatChangeMark.attrs.after : []; + const foundBefore = beforeSnapshots.find((mark) => markSnapshotMatchesStepMark(mark, intent.mark, true)); + + if (foundBefore) { + before = beforeSnapshots.filter((mark) => !markSnapshotMatchesStepMark(mark, intent.mark, true)); + after = afterSnapshots.filter((mark) => getTypeName(mark) !== intent.mark.type.name); + } else { + before = [...beforeSnapshots]; + after = upsertMarkSnapshotByType(afterSnapshots, { + type: intent.mark.type.name, + attrs: intent.mark.attrs, + }); + } + } else { + const existingMarkOfSameType = liveMarks.find( + (mark) => + mark.type.name === intent.mark.type.name && + ![TrackDeleteMarkName, TrackFormatMarkName].includes(mark.type.name), + ); + before = existingMarkOfSameType + ? [createMarkSnapshot(existingMarkOfSameType.type.name, existingMarkOfSameType.attrs)] + : []; + after = [createMarkSnapshot(intent.mark.type.name, intent.mark.attrs)]; + } + + if (isTrackFormatNoOp(before, after)) { + if (formatChangeMark) ctx.tr.removeMark(range.from, range.to, formatChangeMark); + return { sharedWid: wid, formatMark: null }; + } + + if (after.length || before.length) { + const formatMark = createTrackFormatMark({ + ctx, + trackFormatType, + id: wid, + before, + after, + parentId: range.parentId || '', + existingFormatMark: formatChangeMark, + }); + ctx.tr.addMark(range.from, range.to, formatMark); + return { sharedWid: wid, formatMark }; + } + + if (formatChangeMark) ctx.tr.removeMark(range.from, range.to, formatChangeMark); + } + + return { sharedWid: wid, formatMark: null }; +}; + +const applyTrackedFormatRemove = ({ ctx, intent, range, trackFormatType, sharedWid }) => { + const liveMarksBeforeRemove = getLiveInlineMarksInRange({ + doc: ctx.tr.doc, + from: range.from, + to: range.to, + }); + ctx.tr.removeMark(range.from, range.to, intent.mark); + + if (!hasMatchingMark(liveMarksBeforeRemove, intent.mark)) { + return { sharedWid, formatMark: null }; + } + + const formatChangeMark = liveMarksBeforeRemove.find((mark) => mark.type.name === TrackFormatMarkName); + let after = []; + let before = []; + + if (formatChangeMark) { + const afterSnapshots = Array.isArray(formatChangeMark.attrs.after) ? formatChangeMark.attrs.after : []; + const beforeSnapshots = Array.isArray(formatChangeMark.attrs.before) ? formatChangeMark.attrs.before : []; + const foundAfter = afterSnapshots.find((mark) => markSnapshotMatchesStepMark(mark, intent.mark, true)); + + if (foundAfter) { + after = afterSnapshots.filter((mark) => !markSnapshotMatchesStepMark(mark, intent.mark, true)); + if (after.length === 0) { + const remainingFormatMarks = liveMarksBeforeRemove.filter( + (m) => + ![TrackDeleteMarkName, TrackFormatMarkName].includes(m.type.name) && m.type.name !== intent.mark.type.name, + ); + const isNoop = beforeSnapshots.every((snapshot) => + remainingFormatMarks.some((m) => markSnapshotMatchesStepMark(snapshot, m, true)), + ); + if (isNoop) { + ctx.tr.removeMark(range.from, range.to, formatChangeMark); + return { sharedWid: formatChangeMark.attrs.id || sharedWid, formatMark: null }; + } + } + before = [...beforeSnapshots]; + } else { + after = [...afterSnapshots]; + before = upsertMarkSnapshotByType(beforeSnapshots, { + type: intent.mark.type.name, + attrs: intent.mark.attrs, + }); + } + } else { + after = []; + const existingMark = range.node.marks.find((mark) => mark.type === intent.mark.type); + before = existingMark ? [createMarkSnapshot(intent.mark.type.name, existingMark.attrs)] : []; + } + + if (after.length || before.length) { + const wid = formatChangeMark ? formatChangeMark.attrs.id : (sharedWid ?? uuidv4()); + const formatMark = createTrackFormatMark({ + ctx, + trackFormatType, + id: wid, + before, + after, + parentId: range.parentId || '', + existingFormatMark: formatChangeMark, + }); + ctx.tr.addMark(range.from, range.to, formatMark); + return { sharedWid: wid, formatMark }; + } + + if (formatChangeMark) ctx.tr.removeMark(range.from, range.to, formatChangeMark); + return { sharedWid: formatChangeMark?.attrs?.id || sharedWid, formatMark: null }; +}; + +const createTrackFormatMark = ({ ctx, trackFormatType, id, before, after, parentId, existingFormatMark }) => + trackFormatType.create({ + id, + sourceId: existingFormatMark?.attrs?.sourceId || '', + author: ctx.intent.user.name || '', + authorId: ctx.intent.user.id || '', + authorEmail: ctx.intent.user.email || '', + authorImage: ctx.intent.user.image || '', + date: ctx.intent.date, + before, + after, + importedAuthor: existingFormatMark?.attrs?.importedAuthor || '', + revisionGroupId: existingFormatMark?.attrs?.revisionGroupId || id, + splitFromId: existingFormatMark?.attrs?.splitFromId || '', + changeType: CanonicalChangeType.Formatting, + replacementGroupId: '', + replacementSideId: '', + overlapParentId: parentId || existingFormatMark?.attrs?.overlapParentId || '', + sourceIds: existingFormatMark?.attrs?.sourceIds ?? null, + origin: existingFormatMark?.attrs?.origin || '', + }); + +/** + * Find the segment that strictly contains `pos` if any. When no segment + * strictly contains the position, returns the segment whose right edge is at + * `pos` (for adjacent same-user refinement detection). + */ +const findContainingSegment = (ctx, pos) => findSegmentAt(ctx, pos); + +// --------------------------------------------------------------------------- +// Diagnostics surfaced for telemetry/tests. +// --------------------------------------------------------------------------- + +export const compilerInternalsForTest = { stripTrackedMarksFromSlice, classifySegment }; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js new file mode 100644 index 0000000000..b74633eb24 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js @@ -0,0 +1,1005 @@ +// @ts-check +import { describe, expect, it } from 'vitest'; +import { Slice, Fragment } from 'prosemirror-model'; +import { EditorState } from 'prosemirror-state'; +import { TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName } from '../constants.js'; +import { compileTrackedEdit } from './overlap-compiler.js'; +import { + makeTextInsertIntent, + makeTextDeleteIntent, + makeTextReplaceIntent, + makeFormatIntent, + sliceFromText, +} from './edit-intent.js'; +import { createReviewGraphTestSchema, markAttrs, stateFromTrackedSpans } from './test-fixtures.js'; +import { buildReviewGraph, CanonicalChangeType, SegmentSide } from './review-graph.js'; + +// The review-graph test schema does not carry bold/italic/etc. mark types. +// For format tests we extend the standard fixture schema with a `bold` mark. +import { Schema } from 'prosemirror-model'; + +const ALICE = { name: 'Alice', email: 'alice@example.com' }; +const BOB = { name: 'Bob', email: 'bob@example.com' }; +const NO_EMAIL = { name: 'Anon', email: '' }; +const SAME_EMAIL_ALICE = { id: 'alice-id', name: 'Alice', email: 'shared@example.com' }; +const SAME_EMAIL_BOB = { id: 'bob-id', name: 'Bob', email: 'shared@example.com' }; + +const FIXED_DATE = '2026-05-21T00:00:00.000Z'; + +const schema = createReviewGraphTestSchema(); +const createReviewGraphRunTestSchema = () => { + const baseSchema = createReviewGraphTestSchema(); + return new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { + content: 'inline*', + group: 'block', + parseDOM: [{ tag: 'p' }], + toDOM: () => ['p', 0], + }, + run: { + content: 'text*', + group: 'inline', + inline: true, + selectable: false, + parseDOM: [{ tag: 'span[data-run]' }], + toDOM: () => ['span', { 'data-run': '1' }, 0], + }, + text: { group: 'inline' }, + }, + marks: baseSchema.spec.marks.toObject(), + }); +}; + +const insertMark = (attrs) => ({ markType: TrackInsertMarkName, attrs: markAttrs(attrs) }); +const deleteMark = (attrs) => ({ markType: TrackDeleteMarkName, attrs: markAttrs(attrs) }); + +const runCompile = ({ state, intent, replacements = 'paired' }) => + compileTrackedEdit({ + state, + tr: state.tr, + intent, + replacements, + }); + +const textOf = (tr) => tr.doc.textContent; + +describe('overlap-compiler: text-insert fresh content', () => { + it('marks live-content insertion with a new logical id', () => { + const { state } = stateFromTrackedSpans({ schema, spans: [{ text: 'hello' }] }); + const intent = makeTextInsertIntent({ + at: 3, + content: sliceFromText(schema, 'X'), + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(textOf(result.tr)).toBe('heXllo'); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + expect(graph.changes.size).toBe(1); + const change = Array.from(graph.changes.values())[0]; + expect(change.type).toBe(CanonicalChangeType.Insertion); + expect(change.authorEmail).toBe(ALICE.email); + expect(result.createdChangeIds).toHaveLength(1); + }); + + it('honors a provided logical id hint for document-api inserts', () => { + const providedId = 'api-insert-1'; + const { state } = stateFromTrackedSpans({ schema, spans: [{ text: 'hello' }] }); + const intent = makeTextInsertIntent({ + at: 3, + content: sliceFromText(schema, 'X'), + user: ALICE, + date: FIXED_DATE, + source: 'document-api', + replacementGroupHint: providedId, + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + expect(graph.changes.get(providedId)).toBeDefined(); + expect(result.createdChangeIds).toEqual([providedId]); + }); +}); + +describe('overlap-compiler: same-user own-insertion refinement (SD-486-adjacent / refinement matrix row)', () => { + it('extends the same logical id when inserting inside own insertion', () => { + const id = 'ins-alice'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hi ' }, + { + text: 'world', + marks: [ + insertMark({ + id, + author: ALICE.name, + authorEmail: ALICE.email, + date: FIXED_DATE, + changeType: CanonicalChangeType.Insertion, + }), + ], + }, + ], + }); + // Insert "great " at position 5 — inside "world" (after "wo"). + const intent = makeTextInsertIntent({ + at: 6, + content: sliceFromText(schema, 'great '), + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(textOf(result.tr)).toBe('Hi wogreat rld'); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + // Still one logical change with one id — id of the existing insertion. + expect(graph.changes.size).toBe(1); + expect(graph.changes.get(id)).toBeDefined(); + expect(result.updatedChangeIds).toContain(id); + }); + + it('refines own insertion when caret sits at the right edge', () => { + const id = 'ins-alice'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hi ' }, + { text: 'world', marks: [insertMark({ id, authorEmail: ALICE.email, date: FIXED_DATE })] }, + ], + }); + // Right edge of "world" is position 9. Insert "!" → refine same id. + const intent = makeTextInsertIntent({ + at: 9, + content: sliceFromText(schema, '!'), + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(textOf(result.tr)).toBe('Hi world!'); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + expect(graph.changes.size).toBe(1); + expect(graph.changes.get(id)).toBeDefined(); + }); + + it('refines own insertion across an empty run-wrapper position gap', () => { + const runSchema = createReviewGraphRunTestSchema(); + const id = 'ins-alice'; + const mark = runSchema.marks[TrackInsertMarkName].create( + markAttrs({ id, authorEmail: ALICE.email, date: FIXED_DATE }), + ); + const run = runSchema.nodes.run.create({}, [runSchema.text('a', [mark])]); + const doc = runSchema.nodes.doc.create({}, [runSchema.nodes.paragraph.create({}, [run])]); + const state = EditorState.create({ schema: runSchema, doc }); + + let trackedTextEnd = null; + let runEnd = null; + doc.descendants((node, pos) => { + if (node.isText && node.text === 'a') trackedTextEnd = pos + node.nodeSize; + if (node.type.name === 'run') runEnd = pos + node.nodeSize; + }); + expect(runEnd).toBeGreaterThan(trackedTextEnd); + + const intent = makeTextInsertIntent({ + at: runEnd, + content: sliceFromText(runSchema, 'b'), + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(result.createdChangeIds).toHaveLength(0); + expect(result.updatedChangeIds).toContain(id); + + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + expect(graph.changes.size).toBe(1); + expect( + graph.changes + .get(id) + ?.insertedSegments.map((segment) => segment.text) + .join(''), + ).toBe('ab'); + }); + + it('does not refine own insertion across live text', () => { + const runSchema = createReviewGraphRunTestSchema(); + const id = 'ins-alice'; + const mark = runSchema.marks[TrackInsertMarkName].create( + markAttrs({ id, authorEmail: ALICE.email, date: FIXED_DATE }), + ); + const run = runSchema.nodes.run.create({}, [runSchema.text('a', [mark])]); + const liveText = runSchema.text('x'); + const doc = runSchema.nodes.doc.create({}, [runSchema.nodes.paragraph.create({}, [run, liveText])]); + const state = EditorState.create({ schema: runSchema, doc }); + + let liveTextEnd = null; + doc.descendants((node, pos) => { + if (node.isText && node.text === 'x') liveTextEnd = pos + node.nodeSize; + }); + + const intent = makeTextInsertIntent({ + at: liveTextEnd, + content: sliceFromText(runSchema, 'b'), + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(result.createdChangeIds).toHaveLength(1); + expect(result.updatedChangeIds).not.toContain(id); + + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + expect(graph.changes.size).toBe(2); + }); + + it('replaces inside own insertion while preserving the existing insertion id', () => { + const id = 'ins-alice'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hi ' }, + { text: 'world', marks: [insertMark({ id, authorEmail: ALICE.email, date: FIXED_DATE })] }, + ], + }); + const intent = makeTextReplaceIntent({ + from: 5, + to: 7, + content: sliceFromText(schema, 'AR'), + replacements: 'paired', + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(textOf(result.tr)).toBe('Hi wARld'); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + expect(graph.changes.size).toBe(1); + const change = graph.changes.get(id); + expect(change).toBeDefined(); + expect(change.type).toBe(CanonicalChangeType.Insertion); + expect(result.updatedChangeIds).toContain(id); + }); +}); + +describe('overlap-compiler: different-user child insertion inside other-user insertion', () => { + it('mints a child id with overlapParentId set to the parent insertion id', () => { + const parentId = 'ins-bob'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hi ' }, + { text: 'world', marks: [insertMark({ id: parentId, authorEmail: BOB.email, date: FIXED_DATE })] }, + ], + }); + // Alice inserts "X" at position 6 inside Bob's insertion. + const intent = makeTextInsertIntent({ + at: 6, + content: sliceFromText(schema, 'X'), + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(textOf(result.tr)).toBe('Hi woXrld'); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + // Two logical changes now: parent and child. + expect(graph.changes.size).toBe(2); + const childChange = Array.from(graph.changes.values()).find((c) => c.id !== parentId); + expect(childChange).toBeDefined(); + expect(childChange.insertedSegments[0].attrs.overlapParentId).toBe(parentId); + expect(childChange.authorEmail).toBe(ALICE.email); + }); +}); + +describe('overlap-compiler: text-insert at exact location inside other-user deletion (SD-3210)', () => { + it('places child insertion at the cursor offset, not at end of deletion span', () => { + const parentId = 'del-bob'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hi ' }, + { text: 'gone', marks: [deleteMark({ id: parentId, authorEmail: BOB.email, date: FIXED_DATE })] }, + { text: ' rest' }, + ], + }); + // Alice's cursor lands at position 5 (between "g" and "o"). + const intent = makeTextInsertIntent({ + at: 5, + content: sliceFromText(schema, 'INS'), + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + // Inserted exactly between the original "g" and "o" of the deleted run. + expect(textOf(result.tr)).toBe('Hi gINSone rest'); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + const insertion = Array.from(graph.changes.values()).find((c) => c.type === CanonicalChangeType.Insertion); + expect(insertion).toBeDefined(); + expect(insertion.insertedSegments[0].attrs.overlapParentId).toBe(parentId); + }); +}); + +describe('overlap-compiler: text-replace inside own deletion (SD-2335)', () => { + it('preserves the original deletion and creates insertion at the edit point', () => { + const delId = 'del-alice'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'pre ' }, + { text: 'old', marks: [deleteMark({ id: delId, authorEmail: ALICE.email, date: FIXED_DATE })] }, + { text: ' post' }, + ], + }); + // Alice "types" "new" while her selection covers her own deletion of "old". + const intent = makeTextReplaceIntent({ + from: 5, + to: 8, + content: sliceFromText(schema, 'new'), + replacements: 'paired', + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + // Insertion appears at the edit point; original deletion preserved. + expect(textOf(result.tr)).toBe('pre newold post'); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + // One deletion (preserved) plus one new insertion = two logical changes. + expect(graph.changes.size).toBe(2); + const insertion = Array.from(graph.changes.values()).find((c) => c.type === CanonicalChangeType.Insertion); + const deletion = Array.from(graph.changes.values()).find((c) => c.type === CanonicalChangeType.Deletion); + expect(insertion).toBeDefined(); + expect(deletion).toBeDefined(); + expect(deletion.id).toBe(delId); + }); +}); + +describe('overlap-compiler: text-delete', () => { + it('marks live text as tracked deletion', () => { + const { state } = stateFromTrackedSpans({ schema, spans: [{ text: 'hello world' }] }); + const intent = makeTextDeleteIntent({ from: 7, to: 12, user: ALICE, date: FIXED_DATE, source: 'native' }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(textOf(result.tr)).toBe('hello world'); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + expect(graph.changes.size).toBe(1); + const change = Array.from(graph.changes.values())[0]; + expect(change.type).toBe(CanonicalChangeType.Deletion); + }); + + it('honors a provided logical id hint for document-api deletions', () => { + const providedId = 'api-delete-1'; + const { state } = stateFromTrackedSpans({ schema, spans: [{ text: 'hello world' }] }); + const intent = makeTextDeleteIntent({ + from: 7, + to: 12, + user: ALICE, + date: FIXED_DATE, + source: 'document-api', + replacementGroupHint: providedId, + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + expect(graph.changes.get(providedId)).toBeDefined(); + expect(result.createdChangeIds).toEqual([providedId]); + }); + + it('collapses own insertion when deleting it', () => { + const id = 'ins-alice'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hi ' }, + { text: 'new', marks: [insertMark({ id, authorEmail: ALICE.email, date: FIXED_DATE })] }, + { text: ' world' }, + ], + }); + // Alice deletes her own insertion "new" at [4, 7]. + const intent = makeTextDeleteIntent({ from: 4, to: 7, user: ALICE, date: FIXED_DATE, source: 'native' }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(textOf(result.tr)).toBe('Hi world'); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + expect(graph.changes.size).toBe(0); + expect(result.removedChangeIds).toEqual([id]); + }); + + it('creates child deletion inside other-user insertion', () => { + const parentId = 'ins-bob'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hi ' }, + { text: 'world', marks: [insertMark({ id: parentId, authorEmail: BOB.email, date: FIXED_DATE })] }, + ], + }); + // Alice deletes "or" inside Bob's insertion. + const intent = makeTextDeleteIntent({ from: 5, to: 7, user: ALICE, date: FIXED_DATE, source: 'native' }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(textOf(result.tr)).toBe('Hi world'); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + const aliceChange = Array.from(graph.changes.values()).find((c) => c.authorEmail === ALICE.email); + expect(aliceChange).toBeDefined(); + expect(aliceChange.type).toBe(CanonicalChangeType.Deletion); + expect(aliceChange.deletedSegments[0].attrs.overlapParentId).toBe(parentId); + }); + + it('treats same-email different-id collaborators as different users', () => { + const parentId = 'ins-shared'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hi ' }, + { + text: 'world', + marks: [ + insertMark({ + id: parentId, + author: SAME_EMAIL_ALICE.name, + authorId: SAME_EMAIL_ALICE.id, + authorEmail: SAME_EMAIL_ALICE.email, + date: FIXED_DATE, + }), + ], + }, + ], + }); + + const intent = makeTextDeleteIntent({ + from: 5, + to: 7, + user: SAME_EMAIL_BOB, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + + expect(result.ok).toBe(true); + expect(textOf(result.tr)).toBe('Hi world'); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + const bobChange = Array.from(graph.changes.values()).find((c) => c.authorId === SAME_EMAIL_BOB.id); + expect(bobChange).toBeDefined(); + expect(bobChange.type).toBe(CanonicalChangeType.Deletion); + expect(bobChange.deletedSegments[0].attrs.overlapParentId).toBe(parentId); + }); + + it('protects named no-email insertion as different-user state when deleting inside it', () => { + const parentId = 'ins-alice-no-email'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { + text: 'lazy ', + marks: [ + insertMark({ + id: parentId, + author: '', + importedAuthor: 'Alice Reviewer (imported)', + authorEmail: '', + date: FIXED_DATE, + }), + ], + }, + ], + }); + const intent = makeTextDeleteIntent({ + from: 1, + to: 5, + user: { name: 'CLI', email: '' }, + date: FIXED_DATE, + source: 'document-api', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(textOf(result.tr)).toBe('lazy '); + + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + expect(graph.changes.size).toBe(2); + const parent = graph.changes.get(parentId); + expect(parent).toBeDefined(); + expect(parent.type).toBe(CanonicalChangeType.Insertion); + const child = Array.from(graph.changes.values()).find((change) => change.id !== parentId); + expect(child).toBeDefined(); + expect(child.type).toBe(CanonicalChangeType.Deletion); + expect(child.deletedSegments[0].text).toBe('lazy'); + expect(child.deletedSegments[0].attrs.overlapParentId).toBe(parentId); + }); + + it('collapses truly unattributed no-email insertion when deleting it', () => { + const parentId = 'ins-unattributed'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { + text: 'draft', + marks: [insertMark({ id: parentId, author: '', authorEmail: '', sourceId: '1', date: FIXED_DATE })], + }, + ], + }); + const intent = makeTextDeleteIntent({ from: 1, to: 6, user: BOB, date: FIXED_DATE, source: 'document-api' }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(textOf(result.tr)).toBe(''); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + expect(graph.changes.size).toBe(0); + expect(result.removedChangeIds).toEqual([parentId]); + }); + + it('creates a child deletion inside a live anonymous no-email insertion', () => { + const parentId = 'ins-live-anonymous'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { + text: 'live-review-comment', + marks: [insertMark({ id: parentId, author: '', authorEmail: '', sourceId: '', date: FIXED_DATE })], + }, + ], + }); + const intent = makeTextDeleteIntent({ + from: 6, + to: 12, + user: { name: '', email: '' }, + date: FIXED_DATE, + source: 'document-api', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(textOf(result.tr)).toBe('live-review-comment'); + + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + expect(graph.changes.size).toBe(2); + const parent = graph.changes.get(parentId); + expect(parent).toBeDefined(); + expect(parent.type).toBe(CanonicalChangeType.Insertion); + expect(parent.insertedSegments.map((segment) => segment.text).join('')).toBe('live-review-comment'); + + const child = Array.from(graph.changes.values()).find((change) => change.id !== parentId); + expect(child).toBeDefined(); + expect(child.type).toBe(CanonicalChangeType.Deletion); + expect(child.deletedSegments[0].text).toBe('review'); + expect(child.deletedSegments[0].attrs.overlapParentId).toBe(parentId); + }); + + it('no-ops when deleting inside own deletion', () => { + const delId = 'del-alice'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hi ' }, + { text: 'gone', marks: [deleteMark({ id: delId, authorEmail: ALICE.email, date: FIXED_DATE })] }, + ], + }); + const intent = makeTextDeleteIntent({ from: 4, to: 6, user: ALICE, date: FIXED_DATE, source: 'native' }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + // Only the original deletion remains. + expect(graph.changes.size).toBe(1); + expect(Array.from(graph.changes.values())[0].id).toBe(delId); + }); + + it('preserves another user deletion when plain-deleting through it', () => { + const delId = 'del-bob'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hi ' }, + { text: 'gone', marks: [deleteMark({ id: delId, authorEmail: BOB.email, date: FIXED_DATE })] }, + { text: ' live' }, + ], + }); + const intent = makeTextDeleteIntent({ from: 4, to: 10, user: ALICE, date: FIXED_DATE, source: 'native' }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + const deletedChanges = Array.from(graph.changes.values()).filter( + (change) => change.type === CanonicalChangeType.Deletion, + ); + expect(deletedChanges.map((change) => change.id).sort()).toEqual([delId, result.deletionMarks[0].attrs.id].sort()); + expect( + graph.changes + .get(delId) + ?.deletedSegments.map((segment) => segment.text) + .join(''), + ).toBe('gone'); + }); +}); + +describe('overlap-compiler: text-replace inside named no-email insertion', () => { + it('preserves parent insertion and creates child replacement sides', () => { + const parentId = 'ins-alice-no-email'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { + text: 'lazy ', + marks: [ + insertMark({ + id: parentId, + author: '', + importedAuthor: 'Alice Reviewer (imported)', + authorEmail: '', + date: FIXED_DATE, + }), + ], + }, + ], + }); + const intent = makeTextReplaceIntent({ + from: 1, + to: 5, + content: sliceFromText(schema, 'quickly'), + replacements: 'paired', + user: { name: 'CLI', email: '' }, + date: FIXED_DATE, + source: 'document-api', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(textOf(result.tr)).toBe('lazyquickly '); + + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + expect(graph.changes.size).toBe(3); + const parent = graph.changes.get(parentId); + expect(parent).toBeDefined(); + expect(parent.type).toBe(CanonicalChangeType.Insertion); + const childDelete = Array.from(graph.changes.values()).find( + (change) => change.type === CanonicalChangeType.Deletion, + ); + const childInsert = Array.from(graph.changes.values()).find( + (change) => change.type === CanonicalChangeType.Insertion && change.id !== parentId, + ); + expect(childDelete).toBeDefined(); + expect(childDelete.deletedSegments[0].text).toBe('lazy'); + expect(childDelete.deletedSegments[0].attrs.overlapParentId).toBe(parentId); + expect(childInsert).toBeDefined(); + expect(childInsert.insertedSegments.map((segment) => segment.text).join('')).toBe('quickly'); + expect(childInsert.insertedSegments[0].attrs.overlapParentId).toBe(parentId); + }); +}); + +describe('overlap-compiler: weak-identity routes through different-user path', () => { + it('missing author email on parent insertion forces different-user behavior', () => { + const parentId = 'ins-anon'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hi ' }, + { text: 'world', marks: [insertMark({ id: parentId, authorEmail: '', date: FIXED_DATE })] }, + ], + }); + const intent = makeTextInsertIntent({ + at: 6, + content: sliceFromText(schema, 'X'), + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + // Two changes: weak-identity parent stays, Alice's child insertion is new. + expect(graph.changes.size).toBe(2); + const childChange = Array.from(graph.changes.values()).find((c) => c.id !== parentId); + expect(childChange).toBeDefined(); + expect(childChange.insertedSegments[0].attrs.overlapParentId).toBe(parentId); + }); + + it('missing current-user email forces different-user behavior', () => { + const parentId = 'ins-alice'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hi ' }, + { text: 'world', marks: [insertMark({ id: parentId, authorEmail: ALICE.email, date: FIXED_DATE })] }, + ], + }); + // Current user has no email — should not refine ALICE's insertion. + const intent = makeTextInsertIntent({ + at: 6, + content: sliceFromText(schema, 'X'), + user: NO_EMAIL, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + const graph = buildReviewGraph({ state: { doc: result.tr.doc } }); + // Parent + child = 2 changes. + expect(graph.changes.size).toBe(2); + }); +}); + +describe('overlap-compiler: text-replace produces paired replacement metadata', () => { + it('paired mode marks shared logical id across delete + insert', () => { + const { state } = stateFromTrackedSpans({ schema, spans: [{ text: 'hello world' }] }); + const intent = makeTextReplaceIntent({ + from: 1, + to: 6, + content: sliceFromText(schema, 'HELLO'), + replacements: 'paired', + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + const graph = buildReviewGraph({ state: { doc: result.tr.doc }, replacementsMode: 'paired' }); + // One logical change since paired. + expect(graph.changes.size).toBe(1); + const change = Array.from(graph.changes.values())[0]; + expect(change.type).toBe(CanonicalChangeType.Replacement); + expect(change.replacement?.inserted.length).toBeGreaterThan(0); + expect(change.replacement?.deleted.length).toBeGreaterThan(0); + }); + + it('keeps both sides of a child replacement separately reviewable under an other-user insertion parent', () => { + const parentId = 'ins-bob'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hi ' }, + { text: 'world', marks: [insertMark({ id: parentId, authorEmail: BOB.email, date: FIXED_DATE })] }, + ], + }); + const intent = makeTextReplaceIntent({ + from: 5, + to: 7, + content: sliceFromText(schema, 'AR'), + replacements: 'paired', + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + const graph = buildReviewGraph({ state: { doc: result.tr.doc }, replacementsMode: 'paired' }); + const children = Array.from(graph.changes.values()).filter((change) => change.parent === parentId); + expect(children).toHaveLength(2); + expect(children.map((change) => change.type).sort()).toEqual([ + CanonicalChangeType.Deletion, + CanonicalChangeType.Insertion, + ]); + expect( + children.every((change) => change.segments.every((segment) => segment.attrs.overlapParentId === parentId)), + ).toBe(true); + }); + + it('honors a provided logical id hint for paired document-api replacements', () => { + const providedId = 'api-replace-1'; + const { state } = stateFromTrackedSpans({ schema, spans: [{ text: 'hello world' }] }); + const intent = makeTextReplaceIntent({ + from: 1, + to: 6, + content: sliceFromText(schema, 'HELLO'), + replacements: 'paired', + user: ALICE, + date: FIXED_DATE, + source: 'document-api', + replacementGroupHint: providedId, + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + const graph = buildReviewGraph({ state: { doc: result.tr.doc }, replacementsMode: 'paired' }); + const change = graph.changes.get(providedId); + expect(change).toBeDefined(); + expect(change.type).toBe(CanonicalChangeType.Replacement); + expect(result.createdChangeIds).toEqual([providedId]); + }); +}); + +describe('overlap-compiler: format folding (SD-486)', () => { + // Build a richer schema that includes a bold mark for these tests. + const schemaWithBold = (() => { + const baseSchema = createReviewGraphTestSchema(); + return new Schema({ + nodes: baseSchema.spec.nodes, + marks: { + ...baseSchema.spec.marks.toObject(), + bold: { parseDOM: [{ tag: 'strong' }], toDOM: () => ['strong'] }, + }, + }); + })(); + + const makeBoldedDoc = (spans) => stateFromTrackedSpans({ schema: schemaWithBold, spans }); + + it('folds bold into same-user own insertion without creating trackFormat', () => { + const id = 'ins-alice'; + const { state } = makeBoldedDoc([ + { text: 'Hi ' }, + { text: 'world', marks: [insertMark({ id, authorEmail: ALICE.email, date: FIXED_DATE })] }, + ]); + const boldMark = schemaWithBold.marks.bold.create(); + const intent = makeFormatIntent({ + kind: 'format-apply', + from: 4, + to: 9, + mark: boldMark, + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + // No trackFormat created. + expect(result.createdChangeIds).toHaveLength(0); + // The inserted run should now carry bold. + let hasBold = false; + result.tr.doc.descendants((node) => { + if (!node.isText) return; + if (node.marks.some((m) => m.type.name === 'bold')) hasBold = true; + }); + expect(hasBold).toBe(true); + }); + + it('folds same-user insertion formatting while tracking adjacent live text in the same operation', () => { + const id = 'ins-alice'; + const { state } = makeBoldedDoc([ + { text: 'Hi ' }, + { text: 'world', marks: [insertMark({ id, authorEmail: ALICE.email, date: FIXED_DATE })] }, + { text: ' tail' }, + ]); + const boldMark = schemaWithBold.marks.bold.create(); + const intent = makeFormatIntent({ + kind: 'format-apply', + from: 4, + to: 14, + mark: boldMark, + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(result.createdChangeIds).toHaveLength(1); + expect(result.formatMarks).toHaveLength(1); + + let insertionHasBold = false; + let insertionHasTrackFormat = false; + let liveHasBold = false; + let liveHasTrackFormat = false; + result.tr.doc.descendants((node) => { + if (!node.isText) return; + if (node.text.includes('world')) { + insertionHasBold = node.marks.some((m) => m.type.name === 'bold'); + insertionHasTrackFormat = node.marks.some((m) => m.type.name === TrackFormatMarkName); + } + if (node.text.includes(' tail')) { + liveHasBold = node.marks.some((m) => m.type.name === 'bold'); + liveHasTrackFormat = node.marks.some((m) => m.type.name === TrackFormatMarkName); + } + }); + + expect(insertionHasBold).toBe(true); + expect(insertionHasTrackFormat).toBe(false); + expect(liveHasBold).toBe(true); + expect(liveHasTrackFormat).toBe(true); + }); + + it('creates a trackFormat over different-user inserted content', () => { + const parentId = 'ins-bob'; + const { state } = makeBoldedDoc([ + { text: 'Hi ' }, + { text: 'world', marks: [insertMark({ id: parentId, authorEmail: BOB.email, date: FIXED_DATE })] }, + ]); + const boldMark = schemaWithBold.marks.bold.create(); + const intent = makeFormatIntent({ + kind: 'format-apply', + from: 4, + to: 9, + mark: boldMark, + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(true); + expect(result.createdChangeIds).toHaveLength(1); + expect(result.formatMarks).toHaveLength(1); + expect(result.formatMarks[0].attrs.overlapParentId).toBe(parentId); + }); +}); + +describe('overlap-compiler: typed failures', () => { + it('returns INVALID_TARGET for out-of-range insert', () => { + const { state } = stateFromTrackedSpans({ schema, spans: [{ text: 'hello' }] }); + const intent = makeTextInsertIntent({ + at: 99, + content: sliceFromText(schema, 'X'), + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(false); + expect(result.code).toBe('INVALID_TARGET'); + }); + + it('returns INVALID_TARGET for empty text-insert content', () => { + const { state } = stateFromTrackedSpans({ schema, spans: [{ text: 'hello' }] }); + const intent = { + kind: 'text-insert', + at: 1, + content: Slice.empty, + user: ALICE, + date: FIXED_DATE, + source: 'native', + }; + const result = runCompile({ state, intent }); + expect(result.ok).toBe(false); + expect(result.code).toBe('INVALID_TARGET'); + }); + + it('returns INVALID_TARGET for collapsed text-delete', () => { + const { state } = stateFromTrackedSpans({ schema, spans: [{ text: 'hello' }] }); + const intent = makeTextDeleteIntent({ from: 2, to: 2, user: ALICE, date: FIXED_DATE, source: 'native' }); + const result = runCompile({ state, intent }); + expect(result.ok).toBe(false); + expect(result.code).toBe('INVALID_TARGET'); + }); + + it('returns CAPABILITY_UNAVAILABLE for non-tracked format marks', () => { + const baseSchema = createReviewGraphTestSchema(); + const augmented = new Schema({ + nodes: baseSchema.spec.nodes, + marks: { + ...baseSchema.spec.marks.toObject(), + someStructural: { parseDOM: [], toDOM: () => ['x'] }, + }, + }); + const { state } = stateFromTrackedSpans({ schema: augmented, spans: [{ text: 'hello' }] }); + const intent = makeFormatIntent({ + kind: 'format-apply', + from: 1, + to: 4, + mark: augmented.marks.someStructural.create(), + user: ALICE, + date: FIXED_DATE, + source: 'native', + }); + const result = compileTrackedEdit({ state, tr: state.tr, intent }); + expect(result.ok).toBe(false); + expect(result.code).toBe('CAPABILITY_UNAVAILABLE'); + }); +}); + +describe('overlap-compiler: insertTrackedChange / document-api parity', () => { + it('produces the same graph from document-api intent as from native', () => { + const buildIntent = (source) => + makeTextInsertIntent({ + at: 3, + content: sliceFromText(schema, 'X'), + user: ALICE, + date: FIXED_DATE, + source, + }); + const { state: nativeState } = stateFromTrackedSpans({ schema, spans: [{ text: 'hello' }] }); + const { state: apiState } = stateFromTrackedSpans({ schema, spans: [{ text: 'hello' }] }); + const nativeResult = runCompile({ state: nativeState, intent: buildIntent('native') }); + const apiResult = runCompile({ state: apiState, intent: buildIntent('document-api') }); + expect(nativeResult.ok).toBe(true); + expect(apiResult.ok).toBe(true); + expect(textOf(nativeResult.tr)).toBe(textOf(apiResult.tr)); + const nativeGraph = buildReviewGraph({ state: { doc: nativeResult.tr.doc } }); + const apiGraph = buildReviewGraph({ state: { doc: apiResult.tr.doc } }); + expect(nativeGraph.changes.size).toBe(apiGraph.changes.size); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.js new file mode 100644 index 0000000000..4ce6600194 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.js @@ -0,0 +1,782 @@ +// @ts-check +/** + * TrackedReviewGraph builder. + * + * Plan: v1-3220 / phase0-002 ("Graph Types"). + * + * The graph is rebuilt from the current PM document whenever a tracked-change + * operation needs semantic answers. It MUST be fully reconstructible from + * marks; the cache below is an optional memoization layer keyed by editor + + * doc identity that callers may discard at any time. + * + * Boundaries (phase0-001 "Non-Negotiable Boundaries"): + * - the graph layer must not import document-api adapters, converter + * translators, comments UI, or PresentationEditor. + * - consumers may call the graph; the graph remains below those adapters. + */ + +import { TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName } from '../constants.js'; +import { enumerateTrackedMarkSpans } from './segment-index.js'; +import { + CanonicalChangeType, + ChangeSubtype, + SegmentSide, + readTrackedAttrs, + normalizedAttrsEqual, + subtypeFromChangeType, + deterministicJson, +} from './mark-metadata.js'; +import { BODY_STORY, buildStoryKey } from './story-locator.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * @typedef {Object} TrackedMarkRun + * @property {number} from + * @property {number} to + * @property {import('prosemirror-model').Mark} mark + */ + +/** + * @typedef {Object} TrackedSegment + * @property {string} segmentId + * @property {string} changeId + * @property {string} markType + * @property {string} side 'inserted' | 'deleted' | 'formatting'. + * @property {number} from + * @property {number} to + * @property {string} text + * @property {import('prosemirror-model').Mark} mark + * @property {Array} markRuns + * @property {import('./mark-metadata.js').NormalizedTrackedAttrs} attrs + * @property {string} parentId + * @property {string} parentSide + * @property {'parent'|'child'|'standalone'} overlapRole + * @property {Array} [nodePath] optional diagnostics nodePath. + */ + +/** + * @typedef {Object} LogicalReplacementProjection + * @property {string} groupId + * @property {Array} inserted + * @property {Array} deleted + * @property {string} insertedSideId + * @property {string} deletedSideId + */ + +/** + * @typedef {Object} LogicalTrackedChange + * @property {string} id + * @property {string} type + * @property {string} subtype + * @property {'open'} state + * @property {Array} segments + * @property {Array} coverageSegments + * @property {Array} insertedSegments + * @property {Array} deletedSegments + * @property {Array} formattingSegments + * @property {LogicalReplacementProjection | null} replacement + * @property {string} author + * @property {string} authorId + * @property {string} authorEmail + * @property {string} authorImage + * @property {string} date + * @property {Record} sourceIds + * @property {string} revisionGroupId + * @property {string} splitFromId + * @property {string} sourcePlatform + * @property {import('./story-locator.js').StoryLocator} story + * @property {string|null} parent + * @property {Array} children + * @property {Array} before + * @property {Array} after + * @property {string} excerpt + */ + +/** + * @typedef {Object} GraphDiagnostic + * @property {string} code + * @property {'info'|'warning'|'error'} severity + * @property {string} message + * @property {string[]} [changeIds] + * @property {unknown} [details] + */ + +/** + * @typedef {Object} TrackedReviewGraph + * @property {Map} changes + * @property {Array} segments + * @property {Map} bySegmentId + * @property {Map>} byRevisionGroupId + * @property {Map>} byReplacementGroupId + * @property {Map>} byParentId + * @property {import('./story-locator.js').StoryLocator} story + * @property {(pos: number) => Array} overlapAt + * @property {(from: number, to: number) => Array} segmentsInRange + * @property {(from: number, to: number) => Array} changesInRange + * @property {() => Array} validate + */ + +/** + * Build (or fetch a cached) graph for one story editor. + * + * @param {{ + * state: import('prosemirror-state').EditorState | { doc?: import('prosemirror-model').Node }, + * story?: import('./story-locator.js').StoryLocator, + * replacementsMode?: 'paired'|'independent', + * }} input + * @returns {TrackedReviewGraph} + */ +export const buildReviewGraph = ({ state, story = BODY_STORY, replacementsMode = 'paired' }) => { + const spans = enumerateTrackedMarkSpans(state); + return buildGraphFromSpans({ spans, doc: state?.doc ?? null, story, replacementsMode }); +}; + +// --------------------------------------------------------------------------- +// Memoization +// --------------------------------------------------------------------------- + +const graphCache = new WeakMap(); +const NULL_DOC_KEY = '__nullDoc__'; + +/** + * Memoized graph build keyed on editor identity + doc identity + story key. + * The cache is discardable; callers can call `buildReviewGraph` directly. + * + * @param {{ + * editor: object, + * state?: import('prosemirror-state').EditorState | { doc?: import('prosemirror-model').Node }, + * story?: import('./story-locator.js').StoryLocator, + * replacementsMode?: 'paired'|'independent', + * }} input + * @returns {TrackedReviewGraph} + */ +export const getOrBuildReviewGraph = ({ editor, state, story = BODY_STORY, replacementsMode = 'paired' }) => { + const effectiveState = state ?? /** @type {*} */ (editor)?.state ?? null; + const storyKey = buildStoryKey(story); + const doc = effectiveState?.doc ?? null; + const docKey = doc ?? NULL_DOC_KEY; + const cacheKey = `${storyKey}|${replacementsMode}`; + + let perEditor = graphCache.get(editor); + if (!perEditor) { + perEditor = new Map(); + graphCache.set(editor, perEditor); + } + + const cached = perEditor.get(cacheKey); + if (cached && cached.docKey === docKey) { + return cached.graph; + } + + const graph = buildReviewGraph({ state: effectiveState ?? { doc: null }, story, replacementsMode }); + perEditor.set(cacheKey, { docKey, graph }); + return graph; +}; + +/** + * Discard any cached graphs for an editor. Tests and consumers can call this + * to prove the graph is always reconstructible from marks. + * + * @param {object} editor + */ +export const invalidateReviewGraphCache = (editor) => { + graphCache.delete(editor); +}; + +// --------------------------------------------------------------------------- +// Internal builder +// --------------------------------------------------------------------------- + +const buildGraphFromSpans = ({ spans, doc, story, replacementsMode }) => { + /** @type {Array<{ attrs: import('./mark-metadata.js').NormalizedTrackedAttrs, span: import('./segment-index.js').TrackedMarkSpan }>} */ + const normalized = spans.map((span) => ({ + attrs: readTrackedAttrs(span.mark, span.mark.type.name), + span, + })); + + // 1. Merge adjacent equivalent mark spans into TrackedSegments. + const mergedSegments = mergeAdjacentSpans(normalized); + hydrateSegmentText({ segments: mergedSegments, doc }); + + // 2. Compute side ownership per logical id, then derive replacement + // groupings (paired) when the same id has both inserted and deleted + // segments, or when explicit replacementGroupId metadata says so. + const segmentsByChangeId = groupBy(mergedSegments, (s) => s.changeId); + + // 3. Build the LogicalTrackedChange map. This pass also normalizes + // `changeType` so a paired id with both inserted and deleted segments + // projects as `replacement` even if the legacy marks omit changeType. + const changes = new Map(); + const byRevisionGroupId = new Map(); + const byReplacementGroupId = new Map(); + const byParentId = new Map(); + + for (const [changeId, segs] of segmentsByChangeId) { + const logical = buildLogicalChange({ changeId, segments: segs, doc, story, replacementsMode }); + changes.set(changeId, logical); + + appendToMap(byRevisionGroupId, logical.revisionGroupId, changeId); + + if (logical.replacement) { + appendToMap(byReplacementGroupId, logical.replacement.groupId, changeId); + } + // Also index by any explicit replacementGroupId from segment attrs. + for (const seg of segs) { + if (seg.attrs.replacementGroupId) { + appendToMap(byReplacementGroupId, seg.attrs.replacementGroupId, changeId); + } + } + } + + // 4. Resolve parent/child relationships and stamp `overlapRole`/`parentSide`. + for (const [id, logical] of changes) { + let resolvedParentId = ''; + for (const seg of logical.segments) { + const parentId = seg.attrs.overlapParentId; + if (!parentId || parentId === id) continue; + const parentLogical = changes.get(parentId); + seg.parentId = parentId; + seg.parentSide = parentLogical + ? parentLogical.type === CanonicalChangeType.Replacement + ? '' + : (parentLogical.segments[0]?.side ?? '') + : ''; + seg.overlapRole = 'child'; + if (!resolvedParentId) resolvedParentId = parentId; + appendToMap(byParentId, parentId, id); + } + if (resolvedParentId) logical.parent = resolvedParentId; + } + + // 5. Fill children arrays. + for (const [parentId, childIds] of byParentId) { + const parent = changes.get(parentId); + if (parent) { + parent.children = unique(childIds); + const childCoverageSegments = parent.children + .flatMap((childId) => changes.get(childId)?.segments ?? []) + .filter((seg) => shouldContributeToParentCoverage(seg)); + parent.coverageSegments = uniqueSegments([...parent.coverageSegments, ...childCoverageSegments]); + } + } + + // 6. Build segment-id index. + const bySegmentId = new Map(); + for (const segs of segmentsByChangeId.values()) { + for (const seg of segs) bySegmentId.set(seg.segmentId, seg); + } + + // 7. Flat ordered segment list. + const segments = mergedSegments; + + /** @type {TrackedReviewGraph} */ + const graph = { + changes, + segments, + bySegmentId, + byRevisionGroupId, + byReplacementGroupId, + byParentId, + story, + overlapAt: (pos) => segments.filter((seg) => pos >= seg.from && pos < seg.to), + segmentsInRange: (from, to) => { + if (from > to) [from, to] = [to, from]; + return segments.filter((seg) => seg.to > from && seg.from < to); + }, + changesInRange: (from, to) => { + const ids = new Set(); + for (const seg of segments) { + if (seg.to <= from || seg.from >= to) continue; + ids.add(seg.changeId); + } + return Array.from(ids) + .map((id) => changes.get(id)) + .filter(Boolean); + }, + validate: () => validateGraph(graph), + }; + + Object.defineProperty(graph, 'replacementsMode', { + value: replacementsMode, + enumerable: false, + }); + + return graph; +}; + +// --------------------------------------------------------------------------- +// Mark span merge +// --------------------------------------------------------------------------- + +const mergeAdjacentSpans = (normalized) => { + // Spans come from inline node traversal; sort by `from` then `to` to be + // safe in case of zero-width quirks. Stability is preserved by the + // secondary key. + const sorted = [...normalized].sort((a, b) => { + if (a.span.from !== b.span.from) return a.span.from - b.span.from; + return a.span.to - b.span.to; + }); + + /** @type {Array} */ + const merged = []; + // Track per-change stable ordinal for deterministic segment ids. + const ordinalByChange = new Map(); + + for (const { attrs, span } of sorted) { + const last = merged.length ? merged[merged.length - 1] : null; + const canMerge = + last && + last.changeId === attrs.id && + last.markType === attrs.markType && + last.to === span.from && + normalizedAttrsEqual(last.attrs, attrs); + + if (canMerge) { + last.to = span.to; + last.markRuns.push({ from: span.from, to: span.to, mark: span.mark }); + continue; + } + + const ordinal = ordinalByChange.get(attrs.id) ?? 0; + ordinalByChange.set(attrs.id, ordinal + 1); + + const side = attrs.side || sideFromMarkNameSafe(attrs.markType); + const segmentId = makeSegmentId(attrs.id, side, span.from, span.to, ordinal); + + /** @type {TrackedSegment} */ + const seg = { + segmentId, + changeId: attrs.id, + markType: attrs.markType, + side, + from: span.from, + to: span.to, + text: '', + mark: span.mark, + markRuns: [{ from: span.from, to: span.to, mark: span.mark }], + attrs, + parentId: attrs.overlapParentId || '', + parentSide: '', + overlapRole: attrs.overlapParentId ? 'child' : 'standalone', + }; + merged.push(seg); + } + + return merged; +}; + +const hydrateSegmentText = ({ segments, doc }) => { + if (!doc) return; + for (const seg of segments) { + try { + seg.text = doc.textBetween(seg.from, seg.to, ' ', ''); + } catch { + seg.text = ''; + } + } +}; + +const shouldContributeToParentCoverage = (childSegment) => { + // A child insertion inside a parent revision is newly proposed content and + // should not extend the parent's original coverage. Child deletion and + // formatting segments, however, may carry text that still belongs to the + // parent's logical saved structure after same-type overlap decomposition. + return childSegment.side !== SegmentSide.Inserted; +}; + +const sideFromMarkNameSafe = (markName) => { + if (markName === TrackInsertMarkName) return SegmentSide.Inserted; + if (markName === TrackDeleteMarkName) return SegmentSide.Deleted; + if (markName === TrackFormatMarkName) return SegmentSide.Formatting; + return ''; +}; + +const makeSegmentId = (changeId, side, from, to, ordinal) => `${changeId}:${side}:${from}:${to}:${ordinal}`; + +// --------------------------------------------------------------------------- +// Logical change projection +// --------------------------------------------------------------------------- + +const buildLogicalChange = ({ changeId, segments, doc, story, replacementsMode }) => { + const inserted = segments.filter((s) => s.side === SegmentSide.Inserted); + const deleted = segments.filter((s) => s.side === SegmentSide.Deleted); + const formatting = segments.filter((s) => s.side === SegmentSide.Formatting); + + // Determine canonical change type. + // - an *explicitly persisted* changeType attr on any segment wins. The + // inferred changeType from readTrackedAttrs is NOT used here, because + // inferred values reflect mark type only — they would shadow the paired + // ins+del replacement detection below. + // - paired (default): both inserted and deleted under one id => replacement. + // - independent: keep separate sides as separate logical changes — but the + // builder still groups by id, so independent-mode replacements must + // already have been minted with distinct ids by the compiler. If we see + // both sides under one id in independent mode, we still project as + // replacement (defensive — better than silently dropping a side). + const explicitType = segments.find((s) => s.attrs.explicitChangeType)?.attrs.explicitChangeType ?? ''; + + let type; + if (explicitType) { + type = explicitType; + } else if (inserted.length && deleted.length) { + type = CanonicalChangeType.Replacement; + } else if (inserted.length) { + type = CanonicalChangeType.Insertion; + } else if (deleted.length) { + type = CanonicalChangeType.Deletion; + } else if (formatting.length) { + type = CanonicalChangeType.Formatting; + } else { + type = ''; + } + + const subtype = subtypeFromChangeType(type) ?? ''; + const primary = segments[0]?.attrs ?? null; + + /** @type {LogicalReplacementProjection | null} */ + let replacement = null; + if (type === CanonicalChangeType.Replacement) { + const insertedSideId = inserted[0]?.attrs.replacementSideId || `${changeId}#inserted`; + const deletedSideId = deleted[0]?.attrs.replacementSideId || `${changeId}#deleted`; + const explicitGroupId = segments.find((s) => s.attrs.replacementGroupId)?.attrs.replacementGroupId ?? ''; + replacement = { + groupId: explicitGroupId || changeId, + inserted, + deleted, + insertedSideId, + deletedSideId, + }; + } + + // Aggregate sourceIds across segments (e.g. paired replacement carries + // wordIdInsert + wordIdDelete). Deterministic merge order: sort by side + // then segment ordinal so output is stable across rebuilds. + const aggregatedSourceIds = aggregateSourceIds(segments); + + const sourcePlatform = derivePlatform(segments, primary); + + // before/after carriers from trackFormat segments — kept as raw arrays so + // downstream decision/export code can interpret them. + const before = formatting.length ? /** @type {*} */ (formatting[0]?.mark?.attrs?.before ?? []) : []; + const after = formatting.length ? /** @type {*} */ (formatting[0]?.mark?.attrs?.after ?? []) : []; + + // Coverage segments: by default the segments themselves cover the logical + // change. Child segments inside a parent deletion are tracked here so + // accept/reject can use coverage rather than only parent-owned persisted + // segments. The compiler in plan 003 will write explicit coverage; the + // graph layer just exposes the structure. + const coverageSegments = [...segments]; + + const excerpt = doc ? extractExcerpt(doc, segments) : ''; + + /** @type {LogicalTrackedChange} */ + const logical = { + id: changeId, + type, + subtype, + state: 'open', + segments, + coverageSegments, + insertedSegments: inserted, + deletedSegments: deleted, + formattingSegments: formatting, + replacement, + author: primary?.author ?? '', + authorId: primary?.authorId ?? '', + authorEmail: primary?.authorEmail ?? '', + authorImage: primary?.authorImage ?? '', + date: primary?.date ?? '', + sourceIds: aggregatedSourceIds, + revisionGroupId: primary?.revisionGroupId || changeId, + splitFromId: primary?.splitFromId ?? '', + sourcePlatform, + story, + parent: null, + children: [], + before, + after, + excerpt, + }; + + // replacementsMode is informational here; the compiler (plan 003) uses it + // when minting new replacement ids. The graph stores it on the change for + // observability/tests. + Object.defineProperty(logical, 'replacementsMode', { + value: replacementsMode, + enumerable: false, + }); + + return logical; +}; + +const aggregateSourceIds = (segments) => { + /** @type {Record} */ + const out = {}; + // Sort by side then by ordinal-in-id to be deterministic. + const sorted = [...segments].sort((a, b) => { + if (a.side !== b.side) return a.side < b.side ? -1 : 1; + return a.from - b.from; + }); + for (const seg of sorted) { + for (const [k, v] of Object.entries(seg.attrs.sourceIds || {})) { + if (v == null || v === '') continue; + if (out[k] == null) out[k] = v; + } + } + return out; +}; + +const derivePlatform = (segments, primary) => { + if (primary?.origin) return primary.origin; + // Heuristic: a sourceIds object with wordId* hints at a Word import. + const sourceIds = primary?.sourceIds || {}; + if (sourceIds.wordIdInsert || sourceIds.wordIdDelete || sourceIds.wordIdFormat) return 'word'; + if (primary?.sourceId) return 'word'; + return ''; +}; + +const extractExcerpt = (doc, segments) => { + if (!segments.length || !doc) return ''; + // Use first inserted segment's range as the canonical excerpt source; + // fall back to first segment if there is no inserted side. + const target = segments.find((s) => s.side === SegmentSide.Inserted) ?? segments[0]; + try { + const text = doc.textBetween(target.from, target.to, ' ', ''); + return text.length > 200 ? `${text.slice(0, 200)}…` : text; + } catch { + return ''; + } +}; + +// --------------------------------------------------------------------------- +// Validation / invariants +// --------------------------------------------------------------------------- + +/** + * Run all graph invariants from phase0-002 "Graph Invariants". + * + * Severity: + * - `error`: hard invariant violation. Decision/compiler paths must abort. + * - `warning`: structural anomaly that the graph still represents (e.g. + * replacement with empty deleted side mid-transaction). + * - `info`: telemetry only. + * + * @param {TrackedReviewGraph} graph + * @returns {Array} + */ +export const validateGraph = (graph) => { + /** @type {GraphDiagnostic[]} */ + const diagnostics = []; + + // 1. Every tracked mark has an id. + for (const seg of graph.segments) { + if (!seg.changeId) { + diagnostics.push({ + code: 'INV_MARK_MISSING_ID', + severity: 'error', + message: 'tracked mark span lacks an id', + details: { from: seg.from, to: seg.to, markType: seg.markType }, + }); + } + } + + for (const [id, change] of graph.changes) { + // 2. Every open logical change has at least one segment. + if (!change.segments.length) { + diagnostics.push({ + code: 'INV_OPEN_CHANGE_NO_SEGMENTS', + severity: 'error', + message: 'logical change has no segments', + changeIds: [id], + }); + continue; + } + + // 3. Every segment range is non-empty. + for (const seg of change.segments) { + if (seg.from >= seg.to) { + diagnostics.push({ + code: 'INV_EMPTY_SEGMENT_RANGE', + severity: 'error', + message: 'tracked segment has an empty range', + changeIds: [id], + details: { segmentId: seg.segmentId, from: seg.from, to: seg.to }, + }); + } + } + + // 4. Replacement has at least one inserted and one deleted side. + if (change.type === CanonicalChangeType.Replacement) { + if (!change.insertedSegments.length || !change.deletedSegments.length) { + diagnostics.push({ + code: 'INV_REPLACEMENT_MISSING_SIDE', + severity: 'warning', + message: 'replacement is missing one side', + changeIds: [id], + details: { + inserted: change.insertedSegments.length, + deleted: change.deletedSegments.length, + }, + }); + } + // 5. Replacement side metadata matches the logical change. + if ( + change.replacement && + change.replacement.groupId !== id && + !graph.byReplacementGroupId.has(change.replacement.groupId) + ) { + diagnostics.push({ + code: 'INV_REPLACEMENT_GROUP_MISMATCH', + severity: 'warning', + message: 'replacementGroupId not indexed', + changeIds: [id], + }); + } + } + + // 6. splitFromId never equals id. + if (change.splitFromId && change.splitFromId === id) { + diagnostics.push({ + code: 'INV_SPLIT_FROM_SELF', + severity: 'error', + message: 'splitFromId equals own id', + changeIds: [id], + }); + } + + // 7. overlapParentId points to an existing parent. + for (const seg of change.segments) { + if (seg.attrs.overlapParentId && !graph.changes.has(seg.attrs.overlapParentId)) { + diagnostics.push({ + code: 'INV_CHILD_MISSING_PARENT', + severity: 'warning', + message: 'segment references a missing overlap parent', + changeIds: [id], + details: { segmentId: seg.segmentId, parentId: seg.attrs.overlapParentId }, + }); + } + } + + // 9. revisionGroupId is stable across fragments. + const revisionGroups = unique(change.segments.map((s) => s.attrs.revisionGroupId || id)); + if (revisionGroups.length > 1) { + diagnostics.push({ + code: 'INV_REVISION_GROUP_INCONSISTENT', + severity: 'warning', + message: 'segments of one logical change reference multiple revisionGroupIds', + changeIds: [id], + details: { revisionGroups }, + }); + } + + // 11. Derived fields match persisted mark type. + for (const seg of change.segments) { + const expectedSide = sideFromMarkNameSafe(seg.markType); + if (expectedSide && seg.side !== expectedSide) { + diagnostics.push({ + code: 'INV_SIDE_DERIVATION_MISMATCH', + severity: 'error', + message: 'segment side does not match mark type', + changeIds: [id], + details: { segmentId: seg.segmentId, side: seg.side, markType: seg.markType }, + }); + } + } + } + + // 8. Same-type overlap on one character — checked by scanning for two + // segments of the same side+markType covering an identical [from,to]. + // The merge pass already collapses adjacent same-id, same-attrs segments, + // so any remaining same-type stacked segment is an integrity error. + const segmentsByPosition = new Map(); + for (const seg of graph.segments) { + const key = `${seg.markType}:${seg.from}:${seg.to}`; + if (segmentsByPosition.has(key)) { + diagnostics.push({ + code: 'INV_SAME_TYPE_OVERLAP', + severity: 'error', + message: 'same-type tracked marks occupy identical range', + changeIds: [seg.changeId, segmentsByPosition.get(key).changeId], + details: { range: { from: seg.from, to: seg.to }, markType: seg.markType }, + }); + } else { + segmentsByPosition.set(key, seg); + } + } + + return diagnostics; +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const groupBy = (arr, fn) => { + /** @type {Map>} */ + const out = new Map(); + for (const item of arr) { + const key = fn(item); + const list = out.get(key); + if (list) list.push(item); + else out.set(key, [item]); + } + return out; +}; + +const appendToMap = (map, key, value) => { + const list = map.get(key); + if (list) { + if (!list.includes(value)) list.push(value); + } else { + map.set(key, [value]); + } +}; + +const unique = (arr) => Array.from(new Set(arr)); + +const uniqueSegments = (segments) => { + const seen = new Set(); + const out = []; + for (const seg of segments) { + if (seen.has(seg.segmentId)) continue; + seen.add(seg.segmentId); + out.push(seg); + } + out.sort((a, b) => { + if (a.from !== b.from) return a.from - b.from; + if (a.to !== b.to) return a.to - b.to; + return a.segmentId.localeCompare(b.segmentId); + }); + return out; +}; + +// --------------------------------------------------------------------------- +// Public helpers used by future consumers (plan 003/004) +// --------------------------------------------------------------------------- + +/** + * Deterministic signature for a logical change that downstream code can use + * to detect graph-equivalent rebuilds (e.g. collaboration replay). Stable + * across PM position drift because it includes the revisionGroupId and side + * counts but not absolute positions. + * + * @param {LogicalTrackedChange} change + * @returns {string} + */ +export const signatureOf = (change) => { + return deterministicJson({ + id: change.id, + type: change.type, + revisionGroupId: change.revisionGroupId, + inserted: change.insertedSegments.length, + deleted: change.deletedSegments.length, + formatting: change.formattingSegments.length, + children: change.children, + sourceIds: change.sourceIds, + }); +}; + +export { CanonicalChangeType, ChangeSubtype, SegmentSide }; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.perf.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.perf.test.js new file mode 100644 index 0000000000..51214c70c1 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.perf.test.js @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { TrackInsertMarkName } from '../constants.js'; +import { buildReviewGraph } from './review-graph.js'; +import { createReviewGraphTestSchema, markAttrs, stateFromTrackedSpans } from './test-fixtures.js'; + +const schema = createReviewGraphTestSchema(); + +const insertMark = (attrs) => ({ markType: TrackInsertMarkName, attrs: markAttrs(attrs) }); + +describe('review-graph: performance', () => { + // Phase0-002 "Graph Invariants": "Graph rebuild must be O(N) in tracked-mark + // spans. Add a performance test or benchmark around a document with at + // least 5,000 tracked spans and a key-repeat edit inside an existing + // tracked insertion." + // + // The threshold here is intentionally generous so CI variance does not + // flake the suite. The assertion exists to catch a regression that turns + // the build accidentally O(N^2): on a healthy machine the build for 5k + // distinct-id spans finishes well under 100ms; we allow 2.5s. + it('builds a graph with at least 5000 distinct tracked-mark spans within budget', () => { + const N = 5000; + const spans = []; + for (let i = 0; i < N; i++) { + // Each span gets a distinct id so the merge pass cannot collapse them. + // Interleave with one untracked space so the spans stay as discrete + // mark spans rather than being merged into one inline node. + spans.push({ text: `x`, marks: [insertMark({ id: `id-${i}` })] }); + spans.push({ text: ' ', marks: [] }); + } + + const { state } = stateFromTrackedSpans({ schema, spans }); + + const start = Date.now(); + const graph = buildReviewGraph({ state }); + const elapsed = Date.now() - start; + + expect(graph.changes.size).toBe(N); + expect(elapsed).toBeLessThan(2500); + }); + + // Same shape with one shared id: this is what a "long, key-repeat-style + // refinement inside a single tracked insertion" looks like once the + // compiler folds adjacent same-id segments. The merge pass should collapse + // them, so this test asserts on segment count rather than time. + it('merges 5000 adjacent same-id insertion segments into one segment', () => { + const N = 5000; + const spans = []; + for (let i = 0; i < N; i++) { + spans.push({ text: 'x', marks: [insertMark({ id: 'one' })] }); + } + const { state } = stateFromTrackedSpans({ schema, spans }); + const graph = buildReviewGraph({ state }); + expect(graph.changes.size).toBe(1); + // Even though each character is a distinct PM text node, the merge pass + // should produce one segment because positions are adjacent and attrs + // are identical. + expect(graph.changes.get('one').segments).toHaveLength(1); + expect(graph.changes.get('one').segments[0].to - graph.changes.get('one').segments[0].from).toBe(N); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.test.js new file mode 100644 index 0000000000..9273d63822 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.test.js @@ -0,0 +1,403 @@ +import { describe, expect, it } from 'vitest'; +import { TrackDeleteMarkName, TrackFormatMarkName, TrackInsertMarkName } from '../constants.js'; +import { + buildReviewGraph, + getOrBuildReviewGraph, + invalidateReviewGraphCache, + CanonicalChangeType, + SegmentSide, + signatureOf, +} from './review-graph.js'; +import { runGraphInvariants, graphHasErrors } from './graph-invariants.js'; +import { BODY_STORY } from './story-locator.js'; +import { createReviewGraphTestSchema, markAttrs, stateFromTrackedSpans } from './test-fixtures.js'; + +const schema = createReviewGraphTestSchema(); + +const ALICE = { name: 'Alice', email: 'alice@example.com' }; +const BOB = { name: 'Bob', email: 'bob@example.com' }; + +const insertMark = (attrs) => ({ markType: TrackInsertMarkName, attrs: markAttrs(attrs) }); +const deleteMark = (attrs) => ({ markType: TrackDeleteMarkName, attrs: markAttrs(attrs) }); +const formatMark = (attrs) => ({ markType: TrackFormatMarkName, attrs: markAttrs(attrs) }); + +describe('review-graph: legacy mark inference', () => { + it('builds an insertion logical change from a legacy trackInsert mark', () => { + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hello, ', marks: [] }, + { + text: 'world', + marks: [insertMark({ id: 'c1', author: ALICE.name, authorEmail: ALICE.email, date: '2026-01-01' })], + }, + ], + }); + const graph = buildReviewGraph({ state, story: BODY_STORY }); + expect(graph.changes.size).toBe(1); + const change = graph.changes.get('c1'); + expect(change.type).toBe(CanonicalChangeType.Insertion); + expect(change.subtype).toBe('text-insertion'); + expect(change.segments).toHaveLength(1); + expect(change.insertedSegments[0].text).toBe('world'); + expect(change.excerpt).toBe('world'); + expect(change.replacement).toBeNull(); + expect(change.author).toBe('Alice'); + expect(change.authorEmail).toBe(ALICE.email); + expect(change.revisionGroupId).toBe('c1'); + expect(graph.validate()).toEqual([]); + }); + + it('builds a deletion logical change from a legacy trackDelete mark', () => { + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'Hello, ', marks: [] }, + { text: 'world', marks: [deleteMark({ id: 'c2', author: ALICE.name, authorEmail: ALICE.email })] }, + ], + }); + const graph = buildReviewGraph({ state }); + const change = graph.changes.get('c2'); + expect(change.type).toBe(CanonicalChangeType.Deletion); + expect(change.subtype).toBe('text-deletion'); + expect(change.deletedSegments).toHaveLength(1); + expect(change.insertedSegments).toHaveLength(0); + }); + + it('projects paired ins+del sharing one id as one replacement', () => { + const sharedId = 'rep1'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'old', marks: [deleteMark({ id: sharedId, authorEmail: ALICE.email })] }, + { text: 'new', marks: [insertMark({ id: sharedId, authorEmail: ALICE.email })] }, + ], + }); + const graph = buildReviewGraph({ state, replacementsMode: 'paired' }); + expect(graph.changes.size).toBe(1); + const change = graph.changes.get(sharedId); + expect(change.type).toBe(CanonicalChangeType.Replacement); + expect(change.subtype).toBe('text-replacement'); + expect(change.replacement).not.toBeNull(); + expect(change.replacement.inserted).toHaveLength(1); + expect(change.replacement.deleted).toHaveLength(1); + expect(change.replacement.groupId).toBe(sharedId); + expect(change.replacement.insertedSideId).toBe(`${sharedId}#inserted`); + expect(change.replacement.deletedSideId).toBe(`${sharedId}#deleted`); + }); + + it('keeps ins+del under distinct ids as two logical changes (independent mode shape)', () => { + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'old', marks: [deleteMark({ id: 'd1' })] }, + { text: 'new', marks: [insertMark({ id: 'i1' })] }, + ], + }); + const graph = buildReviewGraph({ state, replacementsMode: 'independent' }); + expect(graph.changes.size).toBe(2); + expect(graph.changes.get('d1').type).toBe(CanonicalChangeType.Deletion); + expect(graph.changes.get('i1').type).toBe(CanonicalChangeType.Insertion); + }); +}); + +describe('review-graph: multi-segment changes', () => { + it('merges adjacent equivalent insertion spans into one segment', () => { + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'aa', marks: [insertMark({ id: 'i1' })] }, + { text: 'bb', marks: [insertMark({ id: 'i1' })] }, + ], + }); + const graph = buildReviewGraph({ state }); + const change = graph.changes.get('i1'); + expect(change.segments).toHaveLength(1); + expect(change.segments[0].to - change.segments[0].from).toBe(4); + }); + + it('keeps two same-id parent deletion segments when split by a child', () => { + const parent = 'p1'; + const child = 'c1'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'ab', marks: [deleteMark({ id: parent, authorEmail: ALICE.email })] }, + { + text: 'cd', + marks: [ + deleteMark({ + id: child, + authorEmail: BOB.email, + overlapParentId: parent, + }), + ], + }, + { text: 'ef', marks: [deleteMark({ id: parent, authorEmail: ALICE.email })] }, + ], + }); + const graph = buildReviewGraph({ state }); + const parentChange = graph.changes.get(parent); + expect(parentChange.segments).toHaveLength(2); + expect(parentChange.coverageSegments.map((seg) => seg.text)).toEqual(['ab', 'cd', 'ef']); + expect(parentChange.type).toBe(CanonicalChangeType.Deletion); + expect(parentChange.children).toEqual([child]); + + const childChange = graph.changes.get(child); + expect(childChange.parent).toBe(parent); + expect(childChange.segments[0].overlapRole).toBe('child'); + }); +}); + +describe('review-graph: child overlap relationships', () => { + it('tracks child insertion inside a parent deletion via overlapParentId', () => { + const parent = 'pd1'; + const child = 'ci1'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'abc', marks: [deleteMark({ id: parent })] }, + { text: 'new', marks: [insertMark({ id: child, overlapParentId: parent })] }, + { text: 'def', marks: [deleteMark({ id: parent })] }, + ], + }); + const graph = buildReviewGraph({ state }); + expect(graph.byParentId.get(parent)).toEqual([child]); + expect(graph.changes.get(child).parent).toBe(parent); + expect(graph.changes.get(parent).children).toEqual([child]); + }); + + it('tracks child deletion inside a parent insertion', () => { + const parent = 'pi1'; + const child = 'cd1'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'abc', marks: [insertMark({ id: parent, authorEmail: ALICE.email })] }, + { text: 'mid', marks: [deleteMark({ id: child, authorEmail: BOB.email, overlapParentId: parent })] }, + { text: 'def', marks: [insertMark({ id: parent, authorEmail: ALICE.email })] }, + ], + }); + const graph = buildReviewGraph({ state }); + expect(graph.changes.get(child).parent).toBe(parent); + expect(graph.changes.get(parent).children).toEqual([child]); + }); +}); + +describe('review-graph: source-id projection', () => { + it('folds legacy sourceId into sourceIds with the mark-side-specific key', () => { + const { state } = stateFromTrackedSpans({ + schema, + spans: [{ text: 'ins', marks: [insertMark({ id: 's1', sourceId: 'wid-42' })] }], + }); + const graph = buildReviewGraph({ state }); + expect(graph.changes.get('s1').sourceIds).toEqual({ wordIdInsert: 'wid-42' }); + }); + + it('aggregates inserted and deleted sourceIds onto one replacement', () => { + const sharedId = 'r1'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'old', marks: [deleteMark({ id: sharedId, sourceId: 'del-7' })] }, + { text: 'new', marks: [insertMark({ id: sharedId, sourceId: 'ins-9' })] }, + ], + }); + const graph = buildReviewGraph({ state }); + expect(graph.changes.get(sharedId).sourceIds).toEqual({ wordIdDelete: 'del-7', wordIdInsert: 'ins-9' }); + }); +}); + +describe('review-graph: invariants', () => { + it('reports no diagnostics on a clean document', () => { + const { state } = stateFromTrackedSpans({ + schema, + spans: [{ text: 'hi', marks: [insertMark({ id: 'ok1' })] }], + }); + const graph = buildReviewGraph({ state }); + const result = runGraphInvariants(graph); + expect(result.errors).toEqual([]); + expect(graphHasErrors(graph)).toBe(false); + }); + + it('detects splitFromId === id as an error', () => { + const { state } = stateFromTrackedSpans({ + schema, + spans: [{ text: 'x', marks: [insertMark({ id: 'bad', splitFromId: 'bad' })] }], + }); + const graph = buildReviewGraph({ state }); + const errors = runGraphInvariants(graph).errors; + expect(errors.some((d) => d.code === 'INV_SPLIT_FROM_SELF')).toBe(true); + expect(graphHasErrors(graph)).toBe(true); + }); + + it('reports tracked marks without ids instead of dropping them before validation', () => { + const { state } = stateFromTrackedSpans({ + schema, + spans: [{ text: 'x', marks: [{ markType: TrackInsertMarkName, attrs: { authorEmail: ALICE.email } }] }], + }); + const graph = buildReviewGraph({ state }); + const errors = runGraphInvariants(graph).errors; + expect(errors.some((d) => d.code === 'INV_MARK_MISSING_ID')).toBe(true); + expect(graph.segments).toHaveLength(1); + }); + + it('warns when child references a missing parent', () => { + const { state } = stateFromTrackedSpans({ + schema, + spans: [{ text: 'hi', marks: [insertMark({ id: 'orphan', overlapParentId: 'ghost' })] }], + }); + const graph = buildReviewGraph({ state }); + const warnings = runGraphInvariants(graph).warnings; + expect(warnings.some((d) => d.code === 'INV_CHILD_MISSING_PARENT')).toBe(true); + }); + + it('warns when a replacement is missing one side', () => { + // Force a "replacement" type with only an inserted side by setting + // explicit changeType on the mark. + const { state } = stateFromTrackedSpans({ + schema, + spans: [{ text: 'incomplete', marks: [insertMark({ id: 'r2', changeType: CanonicalChangeType.Replacement })] }], + }); + const graph = buildReviewGraph({ state }); + const warnings = runGraphInvariants(graph).warnings; + expect(warnings.some((d) => d.code === 'INV_REPLACEMENT_MISSING_SIDE')).toBe(true); + }); +}); + +describe('review-graph: mixed legacy + overlap metadata', () => { + it('builds correctly when one segment carries explicit metadata and another does not', () => { + const sharedId = 'mixed'; + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + // Legacy insertion, no overlap attrs. + { text: 'aa', marks: [insertMark({ id: sharedId })] }, + // Same-id segment with explicit overlap metadata. + { + text: 'bb', + marks: [ + insertMark({ + id: sharedId, + revisionGroupId: sharedId, + changeType: CanonicalChangeType.Insertion, + }), + ], + }, + ], + }); + const graph = buildReviewGraph({ state }); + const change = graph.changes.get(sharedId); + // Mixed metadata should still produce one logical change. + expect(change.type).toBe(CanonicalChangeType.Insertion); + expect(change.segments.length).toBeGreaterThanOrEqual(1); + // No revisionGroupId inconsistency: both segments resolve to sharedId. + const warns = runGraphInvariants(graph).warnings.filter((d) => d.code === 'INV_REVISION_GROUP_INCONSISTENT'); + expect(warns).toEqual([]); + }); +}); + +describe('review-graph: attr normalization for adjacent merge', () => { + it('merges adjacent identical attrs even when one omits default fields', () => { + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'aa', marks: [insertMark({ id: 'n1', authorEmail: ALICE.email })] }, + { + text: 'bb', + marks: [ + insertMark({ + id: 'n1', + authorEmail: ALICE.email, + revisionGroupId: 'n1', + splitFromId: '', + changeType: CanonicalChangeType.Insertion, + }), + ], + }, + ], + }); + const graph = buildReviewGraph({ state }); + const change = graph.changes.get('n1'); + expect(change.segments).toHaveLength(1); + }); +}); + +describe('review-graph: story scope', () => { + it('carries the story locator on every logical change', () => { + const story = { kind: 'story', storyType: 'footnote', noteId: 'fn1' }; + const { state } = stateFromTrackedSpans({ + schema, + spans: [{ text: 'note', marks: [insertMark({ id: 's-fn1' })] }], + }); + const graph = buildReviewGraph({ state, story }); + expect(graph.story).toBe(story); + expect(graph.changes.get('s-fn1').story).toBe(story); + }); +}); + +describe('review-graph: caching', () => { + it('returns the same graph instance for the same doc identity', () => { + const { state } = stateFromTrackedSpans({ + schema, + spans: [{ text: 'x', marks: [insertMark({ id: 'c1' })] }], + }); + const editor = { state }; + const a = getOrBuildReviewGraph({ editor, state }); + const b = getOrBuildReviewGraph({ editor, state }); + expect(a).toBe(b); + }); + + it('rebuilds after invalidate', () => { + const { state } = stateFromTrackedSpans({ + schema, + spans: [{ text: 'x', marks: [insertMark({ id: 'c1' })] }], + }); + const editor = { state }; + const a = getOrBuildReviewGraph({ editor, state }); + invalidateReviewGraphCache(editor); + const b = getOrBuildReviewGraph({ editor, state }); + expect(a).not.toBe(b); + }); +}); + +describe('review-graph: signature', () => { + it('produces deterministic JSON across rebuilds', () => { + const { state } = stateFromTrackedSpans({ + schema, + spans: [{ text: 'x', marks: [insertMark({ id: 'sig1' })] }], + }); + const g1 = buildReviewGraph({ state }); + const g2 = buildReviewGraph({ state }); + expect(signatureOf(g1.changes.get('sig1'))).toBe(signatureOf(g2.changes.get('sig1'))); + }); +}); + +describe('review-graph: range and overlap queries', () => { + it('overlapAt finds segments covering a position', () => { + const { state, paragraphStart } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'hello ', marks: [] }, + { text: 'world', marks: [insertMark({ id: 'q1' })] }, + ], + }); + // 'world' starts at paragraphStart + 'hello '.length = 1 + 6 = 7. + const graph = buildReviewGraph({ state }); + const at = graph.overlapAt(paragraphStart + 7); + expect(at).toHaveLength(1); + expect(at[0].changeId).toBe('q1'); + }); + it('segmentsInRange returns intersecting segments', () => { + const { state } = stateFromTrackedSpans({ + schema, + spans: [ + { text: 'hello ', marks: [] }, + { text: 'world', marks: [insertMark({ id: 'q2' })] }, + ], + }); + const graph = buildReviewGraph({ state }); + expect(graph.segmentsInRange(0, 100).length).toBe(1); + expect(graph.segmentsInRange(0, 1).length).toBe(0); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/segment-index.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/segment-index.js new file mode 100644 index 0000000000..52e233cf4b --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/segment-index.js @@ -0,0 +1,58 @@ +// @ts-check +/** + * Low-level tracked-mark span enumerator. + * + * Plan: v1-3220 / phase0-002 ("Consumers To Migrate"). + * + * The legacy raw mark scan can remain as a primitive inside this module. + * `getTrackChanges` is preserved as a low-level mark-span enumerator for + * tests and compatibility, but it MUST NOT define logical behavior for + * list/get/decide/bubbles/permissions. That is the job of the graph + * (review-graph.js), which is built on top of this primitive. + * + * This file deliberately re-exports the existing helper rather than + * duplicating its body, so any future bugfix in `getTrackChanges` keeps the + * graph and the legacy raw-scan consumers in sync. + */ + +import { TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName } from '../constants.js'; +import { findInlineNodes } from '../trackChangesHelpers/documentHelpers.js'; + +const TRACKED_MARK_SET = new Set([TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName]); + +/** + * @typedef {Object} TrackedMarkSpan + * @property {import('prosemirror-model').Mark} mark + * @property {number} from + * @property {number} to + */ + +/** + * Enumerate every tracked-mark span in document order. One inline leaf node + * can carry more than one tracked mark (e.g. trackInsert + trackFormat + * stacked) — every mark is yielded as its own span. + * + * Tolerates a missing or partially-initialized state and returns [] instead + * of throwing, matching `getTrackChanges`. Comment-import bootstrap can call + * this through a setTimeout(0) before the editor's PM state is attached. + * + * @param {import('prosemirror-state').EditorState | { doc?: import('prosemirror-model').Node } | null | undefined} state + * @returns {TrackedMarkSpan[]} + */ +export const enumerateTrackedMarkSpans = (state) => { + const out = []; + if (!state?.doc) return out; + const inlineNodes = findInlineNodes(state.doc); + if (!inlineNodes.length) return out; + + for (const { node, pos } of inlineNodes) { + const marks = node?.marks ?? []; + if (!marks.length) continue; + for (const mark of marks) { + if (!TRACKED_MARK_SET.has(mark.type.name)) continue; + out.push({ mark, from: pos, to: pos + node.nodeSize }); + } + } + + return out; +}; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/story-locator.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/story-locator.js new file mode 100644 index 0000000000..ffa8322a25 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/story-locator.js @@ -0,0 +1,69 @@ +// @ts-check +/** + * Story locator helpers for the review graph. + * + * A review graph is built per story editor. The body, headers, footers, + * footnotes, and endnotes each keep their own PM coordinate space and their + * own raw Word-id namespaces; the graph never crosses those boundaries. + * + * `TrackedChangeIndexImpl` remains the host-level aggregator that composes + * per-story graph projections (`packages/super-editor/.../document-api- + * adapters/tracked-changes/tracked-change-index.ts`). This module owns the + * locator type used by the graph itself so the graph stays decoupled from + * the document-api adapter layer. + */ + +/** + * Public-style story locator that mirrors the `StoryLocator` exported from + * `@superdoc/document-api`. Duplicated as a local typedef so this module + * does not import from document-api adapters (the graph must stay below the + * adapter layer per phase0-001 boundaries). + * + * @typedef {Object} StoryLocator + * @property {'story'} kind + * @property {'body'|'header'|'footer'|'headerFooterPart'|'footnote'|'endnote'} storyType + * @property {string} [refId] + * @property {string} [noteId] + */ + +/** @type {StoryLocator} */ +export const BODY_STORY = Object.freeze({ kind: 'story', storyType: 'body' }); + +/** + * Deterministic stable key for a story locator. Used as the cache key for + * graph snapshots. Mirrors the same shape as the document-api adapter's + * `buildStoryKey` so cache lookups stay consistent across layers without a + * cross-layer import. + * + * @param {StoryLocator | null | undefined} locator + * @returns {string} + */ +export const buildStoryKey = (locator) => { + if (!locator) return 'body'; + const { storyType, refId, noteId } = locator; + switch (storyType) { + case 'body': + return 'body'; + case 'header': + return `header:${refId ?? ''}`; + case 'footer': + return `footer:${refId ?? ''}`; + case 'headerFooterPart': + return `hf:${refId ?? ''}`; + case 'footnote': + return `fn:${noteId ?? ''}`; + case 'endnote': + return `en:${noteId ?? ''}`; + default: + return `unknown:${storyType ?? ''}`; + } +}; + +/** + * Compare two locators for equality. + * + * @param {StoryLocator | null | undefined} a + * @param {StoryLocator | null | undefined} b + * @returns {boolean} + */ +export const storyLocatorsEqual = (a, b) => buildStoryKey(a) === buildStoryKey(b); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/test-fixtures.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/test-fixtures.js new file mode 100644 index 0000000000..0eee3a748e --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/test-fixtures.js @@ -0,0 +1,130 @@ +// @ts-check +/** + * Test fixture helpers for the review graph. + * + * Plan: v1-3220 / phase0-002 ("Tests"). These helpers exist so unit tests + * can build tracked mark configurations against a real PM + * schema without each test re-inventing the boilerplate. + * + * Not exported from the public package surface — they are internal test + * affordances. The eventual cross-feature fixture corpus owned by + * overlap fixture coverage lives under `extensions/track-changes/fixtures/`. + */ + +import { Schema } from 'prosemirror-model'; +import { EditorState } from 'prosemirror-state'; +import { TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName } from '../constants.js'; + +/** @type {Record} */ +const NODES = { + doc: { content: 'block+' }, + paragraph: { + content: 'inline*', + group: 'block', + parseDOM: [{ tag: 'p' }], + toDOM: () => ['p', 0], + }, + text: { group: 'inline' }, +}; + +const MARK_DEFS_WITH_GRAPH_ATTRS = { + id: { default: '' }, + author: { default: '' }, + authorId: { default: '' }, + authorEmail: { default: '' }, + authorImage: { default: '' }, + date: { default: '' }, + sourceId: { default: '' }, + importedAuthor: { default: '' }, + revisionGroupId: { default: '' }, + splitFromId: { default: '' }, + changeType: { default: '' }, + replacementGroupId: { default: '' }, + replacementSideId: { default: '' }, + overlapParentId: { default: '' }, + sourceIds: { default: null }, + origin: { default: '' }, +}; + +const MARKS = { + [TrackInsertMarkName]: { + inclusive: false, + attrs: MARK_DEFS_WITH_GRAPH_ATTRS, + }, + [TrackDeleteMarkName]: { + inclusive: false, + attrs: MARK_DEFS_WITH_GRAPH_ATTRS, + }, + [TrackFormatMarkName]: { + inclusive: false, + attrs: { + ...MARK_DEFS_WITH_GRAPH_ATTRS, + before: { default: [] }, + after: { default: [] }, + }, + }, +}; + +/** + * A minimal PM schema sufficient for review-graph unit tests. Mirrors the + * tracked-change mark shape used in production; consumers needing a richer + * schema can use `initTestEditor` from the package tests helpers instead. + */ +export const createReviewGraphTestSchema = () => new Schema({ nodes: NODES, marks: MARKS }); + +/** + * @typedef {Object} TextSpanSpec + * @property {string} text + * @property {Array<{ markType: 'trackInsert'|'trackDelete'|'trackFormat', attrs: Record }>} [marks] + */ + +/** + * Build an EditorState containing one paragraph composed of the given + * tracked text spans. Positions inside the resulting doc are stable and + * documented: + * + * pos 0 = before doc + * pos 1 = inside paragraph, before first inline content + * pos 1 + offset = inside paragraph at character offset + * + * @param {{ schema: Schema, spans: TextSpanSpec[] }} input + * @returns {{ state: EditorState, schema: Schema, paragraphStart: number }} + */ +export const stateFromTrackedSpans = ({ schema, spans }) => { + const inlineNodes = spans.map(({ text, marks = [] }) => { + const pmMarks = marks.map(({ markType, attrs }) => schema.marks[markType].create(attrs)); + return schema.text(text, pmMarks); + }); + + const paragraph = schema.nodes.paragraph.create({}, inlineNodes); + const doc = schema.nodes.doc.create({}, [paragraph]); + const state = EditorState.create({ schema, doc }); + return { state, schema, paragraphStart: 1 }; +}; + +/** + * Build a tracked-mark attrs blob with sensible defaults so test + * declarations stay short. + * + * @param {Partial> & { id: string }} attrs + * @returns {Record} + */ +export const markAttrs = (attrs) => ({ + id: '', + author: '', + authorId: '', + authorEmail: '', + authorImage: '', + date: '', + sourceId: '', + importedAuthor: '', + revisionGroupId: '', + splitFromId: '', + changeType: '', + replacementGroupId: '', + replacementSideId: '', + overlapParentId: '', + sourceIds: null, + origin: '', + ...attrs, +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/word-id-allocator.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/word-id-allocator.js new file mode 100644 index 0000000000..d4e623c7b3 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/word-id-allocator.js @@ -0,0 +1,179 @@ +// @ts-check +/** + * Word revision id allocator for DOCX export. + * + * Word stores tracked-change revision ids (`w:id` on ``, ``, + * ``) as decimal strings whose namespace is per-part: document, + * each header, each footer, footnotes, and endnotes are independent. The + * allocator preserves imported raw Word ids (carried on the SuperDoc mark as + * `sourceId`) and mints fresh part-local decimal ids for native revisions and + * successor fragments whose `sourceId` is empty or non-decimal. + * + * The allocator only assigns `w:id` values. It does NOT replace the internal + * SuperDoc logical id (`id`, a UUID) — internal graph metadata stays on PM + * marks; only the Word-native `w:id` attribute is decimal. + * + * Two phases: + * 1) `reserveAll(allMarks)` walks every tracked mark and reserves every + * decimal `sourceId` value found. + * 2) `allocate({ partPath, sourceId, logicalId })` returns the `w:id` to + * write into OOXML for a single mark. Same `logicalId` returns the same + * `w:id` within the same part, so paired replacement halves stay linked. + * + * @typedef {{ + * reserve: (partPath: string, sourceId: string | number | null | undefined) => void, + * reserveAll: (entries: Iterable<{ partPath: string, sourceId: string | number | null | undefined }>) => void, + * allocate: (input: { partPath: string, sourceId?: string | number | null, logicalId?: string | null }) => string, + * isDecimal: (value: unknown) => boolean, + * getSourceIdMap: () => Record>, + * __snapshot: () => Record, + * }} WordIdAllocator + * + * @typedef {{ + * reservedDecimal: Set, + * nextDecimal: number, + * assignedByLogicalId: Map, + * sourceIdByWordId: Map, + * }} PartWordIdState + */ + +const DECIMAL = /^\d+$/; + +export const TRACKED_CHANGE_SOURCE_ID_MAP_PROPERTY = 'SuperdocTrackedChangeSourceIds'; + +/** + * Returns true when the given value, after coercion to a trimmed string, is + * a base-10 integer Word would accept as `w:id`. + * + * @param {unknown} value + * @returns {boolean} + */ +export function isDecimalWordId(value) { + if (value == null) return false; + const str = String(value).trim(); + if (!str) return false; + return DECIMAL.test(str); +} + +/** + * @returns {WordIdAllocator} + */ +export function createWordIdAllocator() { + /** @type {Map} */ + const stateByPart = new Map(); + + /** + * @param {string} partPath + * @returns {PartWordIdState} + */ + const ensureState = (partPath) => { + const key = typeof partPath === 'string' && partPath.length > 0 ? partPath : 'word/document.xml'; + let state = stateByPart.get(key); + if (!state) { + state = { + reservedDecimal: new Set(), + nextDecimal: 1, + assignedByLogicalId: new Map(), + sourceIdByWordId: new Map(), + }; + stateByPart.set(key, state); + } + return state; + }; + + /** + * @param {PartWordIdState} state + * @param {string | number | null | undefined} sourceId + * @param {number} wordId + */ + const recordSourceIdRewrite = (state, sourceId, wordId) => { + if (sourceId == null) return; + const source = String(sourceId).trim(); + if (!source || isDecimalWordId(source)) return; + state.sourceIdByWordId.set(String(wordId), source); + }; + + /** @type {WordIdAllocator['reserve']} */ + const reserve = (partPath, sourceId) => { + if (!isDecimalWordId(sourceId)) return; + const state = ensureState(partPath); + const n = Number(String(sourceId).trim()); + if (!Number.isFinite(n) || n < 0) return; + state.reservedDecimal.add(n); + }; + + /** @type {WordIdAllocator['reserveAll']} */ + const reserveAll = (entries) => { + if (!entries) return; + for (const entry of entries) { + reserve(entry?.partPath, entry?.sourceId); + } + }; + + /** @type {WordIdAllocator['allocate']} */ + const allocate = ({ partPath, sourceId, logicalId }) => { + const state = ensureState(partPath); + + // Preserve imported decimal w:id values verbatim. Word reuses tracked- + // change ids across the document, so we trust the imported value over + // any allocator state. + if (isDecimalWordId(sourceId)) { + const asString = String(sourceId).trim(); + const n = Number(asString); + state.reservedDecimal.add(n); + if (logicalId) state.assignedByLogicalId.set(logicalId, n); + return asString; + } + + // Repeat hits for the same logical id within the same part share the + // newly-minted id. Paired replacement halves carry the same logical id + // so both sides emit the same `w:id` on export, matching Word's pairing + // convention. + if (logicalId && state.assignedByLogicalId.has(logicalId)) { + const assigned = state.assignedByLogicalId.get(logicalId); + if (typeof assigned === 'number') { + recordSourceIdRewrite(state, sourceId, assigned); + return String(assigned); + } + } + + let n = state.nextDecimal; + while (state.reservedDecimal.has(n)) n++; + state.reservedDecimal.add(n); + state.nextDecimal = n + 1; + if (logicalId) state.assignedByLogicalId.set(logicalId, n); + recordSourceIdRewrite(state, sourceId, n); + return String(n); + }; + + const getSourceIdMap = () => { + /** @type {Record>} */ + const out = {}; + for (const [part, state] of stateByPart.entries()) { + if (state.sourceIdByWordId.size === 0) continue; + out[part] = Object.fromEntries([...state.sourceIdByWordId.entries()].sort(([a], [b]) => a.localeCompare(b))); + } + return out; + }; + + const __snapshot = () => { + /** @type {Record} */ + const out = {}; + for (const [part, state] of stateByPart.entries()) { + out[part] = { + reservedDecimal: [...state.reservedDecimal].sort((a, b) => a - b), + nextDecimal: state.nextDecimal, + }; + } + return out; + }; + + return { + reserve, + reserveAll, + allocate, + isDecimal: isDecimalWordId, + getSourceIdMap, + __snapshot, + }; +} diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/word-id-allocator.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/word-id-allocator.test.js new file mode 100644 index 0000000000..185ddfe19d --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/word-id-allocator.test.js @@ -0,0 +1,137 @@ +// @ts-check +import { describe, it, expect } from 'vitest'; +import { createWordIdAllocator, isDecimalWordId } from './word-id-allocator.js'; + +describe('isDecimalWordId', () => { + it.each([ + ['0', true], + ['1', true], + ['12345', true], + ['-3', false], + ['', false], + ['abc', false], + ['1a', false], + ['1.0', false], + [null, false], + [undefined, false], + [42, true], + ])('classifies %p as %p', (value, expected) => { + expect(isDecimalWordId(value)).toBe(expected); + }); +}); + +describe('createWordIdAllocator', () => { + it('mints sequential decimal ids per part', () => { + const alloc = createWordIdAllocator(); + expect(alloc.allocate({ partPath: 'word/document.xml', logicalId: 'a' })).toBe('1'); + expect(alloc.allocate({ partPath: 'word/document.xml', logicalId: 'b' })).toBe('2'); + expect(alloc.allocate({ partPath: 'word/document.xml', logicalId: 'c' })).toBe('3'); + }); + + it('keeps allocations isolated per part', () => { + const alloc = createWordIdAllocator(); + alloc.allocate({ partPath: 'word/document.xml', logicalId: 'a' }); + alloc.allocate({ partPath: 'word/header1.xml', logicalId: 'a' }); + alloc.allocate({ partPath: 'word/footer1.xml', logicalId: 'b' }); + + const snap = alloc.__snapshot(); + expect(snap['word/document.xml'].nextDecimal).toBe(2); + expect(snap['word/header1.xml'].nextDecimal).toBe(2); + expect(snap['word/footer1.xml'].nextDecimal).toBe(2); + }); + + it('preserves a decimal sourceId verbatim and reserves it', () => { + const alloc = createWordIdAllocator(); + expect(alloc.allocate({ partPath: 'word/document.xml', sourceId: '42', logicalId: 'imported' })).toBe('42'); + // The mint counter must skip the reserved value. + expect(alloc.allocate({ partPath: 'word/document.xml', logicalId: 'next' })).toBe('1'); + expect(alloc.allocate({ partPath: 'word/document.xml', logicalId: 'next2' })).toBe('2'); + }); + + it('skips minted ids that collide with reserved sourceIds', () => { + const alloc = createWordIdAllocator(); + alloc.reserveAll([ + { partPath: 'word/document.xml', sourceId: '1' }, + { partPath: 'word/document.xml', sourceId: '2' }, + { partPath: 'word/document.xml', sourceId: '4' }, + ]); + + expect(alloc.allocate({ partPath: 'word/document.xml', logicalId: 'a' })).toBe('3'); + expect(alloc.allocate({ partPath: 'word/document.xml', logicalId: 'b' })).toBe('5'); + }); + + it('returns the same id for the same logical id within a part', () => { + const alloc = createWordIdAllocator(); + const first = alloc.allocate({ partPath: 'word/document.xml', logicalId: 'shared' }); + const second = alloc.allocate({ partPath: 'word/document.xml', logicalId: 'shared' }); + expect(first).toBe(second); + }); + + it('returns different ids for the same logical id across parts', () => { + const alloc = createWordIdAllocator(); + const body = alloc.allocate({ partPath: 'word/document.xml', logicalId: 'shared' }); + const header = alloc.allocate({ partPath: 'word/header1.xml', logicalId: 'shared' }); + expect(body).toBe('1'); + expect(header).toBe('1'); + }); + + it('ignores non-decimal sourceIds when allocating', () => { + const alloc = createWordIdAllocator(); + // UUID-like sourceIds (some upstream tools embed non-decimal strings) are + // treated as missing — the allocator mints a fresh decimal id instead. + expect(alloc.allocate({ partPath: 'word/document.xml', sourceId: 'aaaa-bbbb', logicalId: 'x' })).toBe('1'); + expect(alloc.allocate({ partPath: 'word/document.xml', sourceId: '-3', logicalId: 'y' })).toBe('2'); + }); + + it('records non-decimal sourceId rewrites for reopen import', () => { + const alloc = createWordIdAllocator(); + + expect(alloc.allocate({ partPath: 'word/document.xml', sourceId: 'uuid-a', logicalId: 'a' })).toBe('1'); + expect(alloc.allocate({ partPath: 'word/document.xml', sourceId: '9', logicalId: 'word-import' })).toBe('9'); + expect(alloc.allocate({ partPath: 'word/header1.xml', sourceId: 'uuid-h', logicalId: 'h' })).toBe('1'); + expect(alloc.allocate({ partPath: 'word/document.xml', sourceId: 'uuid-a', logicalId: 'a' })).toBe('1'); + + expect(alloc.getSourceIdMap()).toEqual({ + 'word/document.xml': { 1: 'uuid-a' }, + 'word/header1.xml': { 1: 'uuid-h' }, + }); + }); + + it('handles missing partPath by routing to document.xml', () => { + const alloc = createWordIdAllocator(); + expect(alloc.allocate({ partPath: '', logicalId: 'x' })).toBe('1'); + const snap = alloc.__snapshot(); + expect(Object.keys(snap)).toEqual(['word/document.xml']); + }); + + it('reserve() is a no-op for non-decimal values', () => { + const alloc = createWordIdAllocator(); + alloc.reserve('word/document.xml', 'not-a-number'); + alloc.reserve('word/document.xml', ''); + alloc.reserve('word/document.xml', null); + alloc.reserve('word/document.xml', undefined); + expect(alloc.allocate({ partPath: 'word/document.xml', logicalId: 'a' })).toBe('1'); + }); + + it('successor fragments get fresh part-local ids after preserved imports', () => { + const alloc = createWordIdAllocator(); + // Imagine document.xml originally had w:id 1, 3, 7. + alloc.reserveAll([ + { partPath: 'word/document.xml', sourceId: '1' }, + { partPath: 'word/document.xml', sourceId: '3' }, + { partPath: 'word/document.xml', sourceId: '7' }, + ]); + + // Preserve original ids on re-export. + expect(alloc.allocate({ partPath: 'word/document.xml', sourceId: '1', logicalId: 'imp-1' })).toBe('1'); + expect(alloc.allocate({ partPath: 'word/document.xml', sourceId: '3', logicalId: 'imp-3' })).toBe('3'); + expect(alloc.allocate({ partPath: 'word/document.xml', sourceId: '7', logicalId: 'imp-7' })).toBe('7'); + + // Successor fragments mint fresh ids that avoid the reserved set. + expect(alloc.allocate({ partPath: 'word/document.xml', logicalId: 'frag-a' })).toBe('2'); + expect(alloc.allocate({ partPath: 'word/document.xml', logicalId: 'frag-b' })).toBe('4'); + expect(alloc.allocate({ partPath: 'word/document.xml', logicalId: 'frag-c' })).toBe('5'); + expect(alloc.allocate({ partPath: 'word/document.xml', logicalId: 'frag-d' })).toBe('6'); + expect(alloc.allocate({ partPath: 'word/document.xml', logicalId: 'frag-e' })).toBe('8'); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js index 581296ba52..7310c06407 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js @@ -799,29 +799,29 @@ describe('TrackChanges extension commands', () => { const doc = schema.nodes.doc.create(null, paragraph); const state = createState(doc); - const acceptSpy = vi.fn().mockReturnValue(true); - const tr = state.tr; + let nextState = state; + const editor = { options: { user: { name: 'Reviewer', email: 'reviewer@example.com' } } }; + const dispatch = (tr) => { + nextState = nextState.apply(tr); + }; const result = commands.acceptTrackedChangeById('ins-id')({ state, - tr, - commands: { acceptTrackedChangesBetween: acceptSpy }, + tr: state.tr, + dispatch, + editor, + commands: {}, }); - expect(result).toBe(true); - // Call one time not multiple - expect(acceptSpy).toHaveBeenCalledTimes(1); - expect(acceptSpy).toHaveBeenCalledWith(2, 3); - - const rejectSpy = vi.fn().mockReturnValue(true); - const rejectResult = commands.rejectTrackedChangeById('ins-id')({ - state, - tr, - commands: { rejectTrackedChangesBetween: rejectSpy }, + // The target's "B" is accepted (no longer tracked-inserted), but the + // unrelated "prev" insertion ("A") remains tracked. + const insertIds = new Set(); + nextState.doc.descendants((node) => { + if (!node.isText) return; + const mark = node.marks.find((m) => m.type.name === TrackInsertMarkName); + if (mark?.attrs?.id) insertIds.add(mark.attrs.id); }); - expect(rejectResult).toBe(true); - // Call one time not multiple - expect(rejectSpy).toHaveBeenCalledTimes(1); - expect(rejectSpy).toHaveBeenCalledWith(2, 3); + expect(insertIds.has('ins-id')).toBe(false); + expect(insertIds.has('prev')).toBe(true); }); it('interaction: color suggestion reject removes inline color styling from DOM', () => { @@ -1233,231 +1233,178 @@ describe('TrackChanges extension commands', () => { } }); - it('acceptTrackedChangeById links contiguous insertion segments sharing an id across formatting', () => { + // The by-id tests below assert product-visible outcomes (final doc text + + // remaining tracked marks) instead of internal delegation to the range + // command. The decision engine is the single accept/reject path; how it + // groups same-id segments internally is an implementation detail and not a + // contract callers depend on. + + const runByIdDecision = ({ decision, id, doc }) => { + const state = createState(doc); + let nextState = state; + const editor = { options: { user: { name: 'Reviewer', email: 'reviewer@example.com' } } }; + const command = decision === 'accept' ? commands.acceptTrackedChangeById(id) : commands.rejectTrackedChangeById(id); + const result = command({ + state, + tr: state.tr, + dispatch: (tr) => { + nextState = state.apply(tr); + }, + editor, + commands: { + // Provide range-command fallbacks so the underlying command stays + // functional if it ever needs to compose them. These are not the path + // under test — the decision engine handles by-id natively. + acceptTrackedChangesBetween: (from, to) => { + const tr = nextState.tr; + nextState.doc.nodesBetween(from, to, (node, pos) => { + const mark = node.marks.find((m) => TRACKED_MARK_NAMES.has(m.type.name)); + if (!mark) return; + const mFrom = Math.max(pos, from); + const mTo = Math.min(pos + node.nodeSize, to); + if (mark.type.name === TrackDeleteMarkName) tr.replace(mFrom, mTo); + else tr.removeMark(mFrom, mTo, mark); + }); + nextState = nextState.apply(tr); + return true; + }, + rejectTrackedChangesBetween: (from, to) => { + const tr = nextState.tr; + nextState.doc.nodesBetween(from, to, (node, pos) => { + const mark = node.marks.find((m) => TRACKED_MARK_NAMES.has(m.type.name)); + if (!mark) return; + const mFrom = Math.max(pos, from); + const mTo = Math.min(pos + node.nodeSize, to); + if (mark.type.name === TrackInsertMarkName) tr.replace(mFrom, mTo); + else tr.removeMark(mFrom, mTo, mark); + }); + nextState = nextState.apply(tr); + return true; + }, + }, + }); + return { result, nextState }; + }; + + const TRACKED_MARK_NAMES = new Set([TrackInsertMarkName, TrackDeleteMarkName]); + + it('acceptTrackedChangeById resolves contiguous insertion segments sharing an id (across inline formatting)', () => { const italicMark = schema.marks.italic.create(); const insertionId = 'ins-multi'; - const firstSegmentMark = schema.marks[TrackInsertMarkName].create({ id: insertionId }); - const secondSegmentMark = schema.marks[TrackInsertMarkName].create({ id: insertionId }); - const thirdSegmentMark = schema.marks[TrackInsertMarkName].create({ id: insertionId }); const paragraph = schema.nodes.paragraph.create(null, [ - schema.text('A', [firstSegmentMark]), - schema.text('B', [italicMark, secondSegmentMark]), - schema.text('C', [thirdSegmentMark]), + schema.text('A', [schema.marks[TrackInsertMarkName].create({ id: insertionId })]), + schema.text('B', [italicMark, schema.marks[TrackInsertMarkName].create({ id: insertionId })]), + schema.text('C', [schema.marks[TrackInsertMarkName].create({ id: insertionId })]), ]); const doc = schema.nodes.doc.create(null, paragraph); - const state = createState(doc); - - const acceptSpy = vi.fn().mockReturnValue(true); - const tr = state.tr; - const result = commands.acceptTrackedChangeById(insertionId)({ - state, - tr, - commands: { acceptTrackedChangesBetween: acceptSpy }, - }); + const { result, nextState } = runByIdDecision({ decision: 'accept', id: insertionId, doc }); expect(result).toBe(true); - expect(acceptSpy).toHaveBeenCalledTimes(3); - expect(acceptSpy).toHaveBeenNthCalledWith(1, 1, 2); - expect(acceptSpy).toHaveBeenNthCalledWith(2, 2, 3); - expect(acceptSpy).toHaveBeenNthCalledWith(3, 3, 4); - - const rejectSpy = vi.fn().mockReturnValue(true); - const rejectResult = commands.rejectTrackedChangeById(insertionId)({ - state, - tr, - commands: { rejectTrackedChangesBetween: rejectSpy }, - }); - - expect(rejectResult).toBe(true); - expect(rejectSpy).toHaveBeenCalledTimes(3); - expect(rejectSpy).toHaveBeenNthCalledWith(1, 1, 2); - expect(rejectSpy).toHaveBeenNthCalledWith(2, 2, 3); - expect(rejectSpy).toHaveBeenNthCalledWith(3, 3, 4); + expect(nextState.doc.textContent).toBe('ABC'); + expect(hasAnyMark(nextState.doc, TrackInsertMarkName)).toBe(false); }); - it('acceptTrackedChangeById resolves contiguous same-id insertions without pulling in adjacent different-id deletions', () => { + it('rejectTrackedChangeById removes inserted content across formatting splits', () => { const italicMark = schema.marks.italic.create(); - const deletionMark = schema.marks[TrackDeleteMarkName].create({ id: 'del-id' }); - const insertionId = 'shared-id'; - const firstSegmentMark = schema.marks[TrackInsertMarkName].create({ id: insertionId }); - const secondSegmentMark = schema.marks[TrackInsertMarkName].create({ id: insertionId }); + const insertionId = 'ins-multi-reject'; const paragraph = schema.nodes.paragraph.create(null, [ - schema.text('old', [deletionMark]), - schema.text('A', [firstSegmentMark]), - schema.text('B', [italicMark, secondSegmentMark]), + schema.text('A', [schema.marks[TrackInsertMarkName].create({ id: insertionId })]), + schema.text('B', [italicMark, schema.marks[TrackInsertMarkName].create({ id: insertionId })]), + schema.text('C', [schema.marks[TrackInsertMarkName].create({ id: insertionId })]), ]); const doc = schema.nodes.doc.create(null, paragraph); - const state = createState(doc); - - const acceptSpy = vi.fn().mockReturnValue(true); - const tr = state.tr; - const result = commands.acceptTrackedChangeById(insertionId)({ - state, - tr, - commands: { acceptTrackedChangesBetween: acceptSpy }, - }); + const { result, nextState } = runByIdDecision({ decision: 'reject', id: insertionId, doc }); expect(result).toBe(true); - expect(acceptSpy).toHaveBeenCalledTimes(2); - expect(acceptSpy).toHaveBeenNthCalledWith(1, 4, 5); - expect(acceptSpy).toHaveBeenNthCalledWith(2, 5, 6); + expect(nextState.doc.textContent).toBe(''); + }); - const rejectSpy = vi.fn().mockReturnValue(true); - const rejectResult = commands.rejectTrackedChangeById(insertionId)({ - state, - tr, - commands: { rejectTrackedChangesBetween: rejectSpy }, - }); + it('acceptTrackedChangeById does not pull in adjacent different-id deletions', () => { + const insertionId = 'shared-id'; + const paragraph = schema.nodes.paragraph.create(null, [ + schema.text('old', [schema.marks[TrackDeleteMarkName].create({ id: 'del-id' })]), + schema.text('AB', [schema.marks[TrackInsertMarkName].create({ id: insertionId })]), + ]); + const doc = schema.nodes.doc.create(null, paragraph); - expect(rejectResult).toBe(true); - expect(rejectSpy).toHaveBeenCalledTimes(2); - expect(rejectSpy).toHaveBeenNthCalledWith(1, 4, 5); - expect(rejectSpy).toHaveBeenNthCalledWith(2, 5, 6); + const { result, nextState } = runByIdDecision({ decision: 'accept', id: insertionId, doc }); + expect(result).toBe(true); + expect(nextState.doc.textContent).toBe('oldAB'); + // The unrelated deletion is still tracked-deleted. + expect(hasAnyMark(nextState.doc, TrackDeleteMarkName)).toBe(true); + // Our insertion is accepted (no longer tracked-inserted). + expect(hasAnyMark(nextState.doc, TrackInsertMarkName)).toBe(false); }); - it('acceptTrackedChangeById and rejectTrackedChangeById should NOT link adjacent deletion-insertion pairs with different ids', () => { - const deletionMark = schema.marks[TrackDeleteMarkName].create({ id: 'del-id' }); - const insertionMark = schema.marks[TrackInsertMarkName].create({ id: 'ins-id' }); + it('by-id decisions on adjacent del+ins pairs with different ids resolve only the target id', () => { const paragraph = schema.nodes.paragraph.create(null, [ - schema.text('old', [deletionMark]), - schema.text('new', [insertionMark]), + schema.text('old', [schema.marks[TrackDeleteMarkName].create({ id: 'del-id' })]), + schema.text('new', [schema.marks[TrackInsertMarkName].create({ id: 'ins-id' })]), ]); const doc = schema.nodes.doc.create(null, paragraph); - const state = createState(doc); - - const acceptSpy = vi.fn().mockReturnValue(true); - const tr = state.tr; - const result = commands.acceptTrackedChangeById('ins-id')({ - state, - tr, - commands: { acceptTrackedChangesBetween: acceptSpy }, - }); + const { result, nextState } = runByIdDecision({ decision: 'accept', id: 'ins-id', doc }); expect(result).toBe(true); - expect(acceptSpy).toHaveBeenCalledTimes(1); - expect(acceptSpy).toHaveBeenCalledWith(4, 7); - - const rejectSpy = vi.fn().mockReturnValue(true); - const rejectResult = commands.rejectTrackedChangeById('ins-id')({ - state, - tr, - commands: { rejectTrackedChangesBetween: rejectSpy }, - }); - expect(rejectResult).toBe(true); - expect(rejectSpy).toHaveBeenCalledTimes(1); - expect(rejectSpy).toHaveBeenCalledWith(4, 7); + expect(hasAnyMark(nextState.doc, TrackInsertMarkName)).toBe(false); + expect(hasAnyMark(nextState.doc, TrackDeleteMarkName)).toBe(true); }); - it('acceptTrackedChangeById and rejectTrackedChangeById should still link adjacent deletion-insertion pairs with the same id', () => { + it('by-id decisions on adjacent del+ins pairs with the same id resolve the paired replacement together', () => { const sharedId = 'replace-id'; - const deletionMark = schema.marks[TrackDeleteMarkName].create({ id: sharedId }); - const insertionMark = schema.marks[TrackInsertMarkName].create({ id: sharedId }); const paragraph = schema.nodes.paragraph.create(null, [ - schema.text('old', [deletionMark]), - schema.text('new', [insertionMark]), + schema.text('old', [schema.marks[TrackDeleteMarkName].create({ id: sharedId })]), + schema.text('new', [schema.marks[TrackInsertMarkName].create({ id: sharedId })]), ]); const doc = schema.nodes.doc.create(null, paragraph); - const state = createState(doc); - - const acceptSpy = vi.fn().mockReturnValue(true); - const tr = state.tr; - const result = commands.acceptTrackedChangeById(sharedId)({ - state, - tr, - commands: { acceptTrackedChangesBetween: acceptSpy }, - }); + const { result, nextState } = runByIdDecision({ decision: 'accept', id: sharedId, doc }); expect(result).toBe(true); - expect(acceptSpy).toHaveBeenCalledTimes(2); - expect(acceptSpy).toHaveBeenNthCalledWith(1, 1, 4); - expect(acceptSpy).toHaveBeenNthCalledWith(2, 4, 7); - - const rejectSpy = vi.fn().mockReturnValue(true); - const rejectResult = commands.rejectTrackedChangeById(sharedId)({ - state, - tr, - commands: { rejectTrackedChangesBetween: rejectSpy }, - }); - expect(rejectResult).toBe(true); - expect(rejectSpy).toHaveBeenCalledTimes(2); - expect(rejectSpy).toHaveBeenNthCalledWith(1, 1, 4); - expect(rejectSpy).toHaveBeenNthCalledWith(2, 4, 7); + expect(nextState.doc.textContent).toBe('new'); + expect(hasAnyMark(nextState.doc, TrackInsertMarkName)).toBe(false); + expect(hasAnyMark(nextState.doc, TrackDeleteMarkName)).toBe(false); }); - it('should NOT link changes separated by untracked content', () => { - const deletionMark = schema.marks[TrackDeleteMarkName].create({ id: 'del-id' }); - const insertionMark = schema.marks[TrackInsertMarkName].create({ id: 'ins-id' }); + it('by-id decisions do not resolve unrelated tracked changes separated by untracked content', () => { const paragraph = schema.nodes.paragraph.create(null, [ - schema.text('deleted', [deletionMark]), - schema.text(' '), // Untracked space between - schema.text('inserted', [insertionMark]), + schema.text('deleted', [schema.marks[TrackDeleteMarkName].create({ id: 'del-id' })]), + schema.text(' '), + schema.text('inserted', [schema.marks[TrackInsertMarkName].create({ id: 'ins-id' })]), ]); const doc = schema.nodes.doc.create(null, paragraph); - const state = createState(doc); - - const acceptSpy = vi.fn().mockReturnValue(true); - const tr = state.tr; - const result = commands.acceptTrackedChangeById('ins-id')({ - state, - tr, - commands: { acceptTrackedChangesBetween: acceptSpy }, - }); + const { result, nextState } = runByIdDecision({ decision: 'accept', id: 'ins-id', doc }); expect(result).toBe(true); - // Should only resolve the insertion, not the deletion - expect(acceptSpy).toHaveBeenCalledTimes(1); - expect(acceptSpy).toHaveBeenCalledWith(9, 17); + expect(hasAnyMark(nextState.doc, TrackInsertMarkName)).toBe(false); + expect(hasAnyMark(nextState.doc, TrackDeleteMarkName)).toBe(true); }); - it('acceptTrackedChangesById should link changes sharing the same id even if they are not directly connected', () => { + it('by-id decisions resolve same-id changes even when they are not directly adjacent', () => { const id = 'shared-id'; - const deletionMark = schema.marks[TrackDeleteMarkName].create({ id }); - const insertionMark = schema.marks[TrackInsertMarkName].create({ id }); const paragraph = schema.nodes.paragraph.create(null, [ - schema.text('deleted', [deletionMark]), - schema.text(' '), // Untracked space between - schema.text('inserted', [insertionMark]), + schema.text('deleted', [schema.marks[TrackDeleteMarkName].create({ id })]), + schema.text(' '), + schema.text('inserted', [schema.marks[TrackInsertMarkName].create({ id })]), ]); - const doc = schema.nodes.doc.create(null, paragraph); - const state = createState(doc); - - const acceptSpy = vi.fn().mockReturnValue(true); - const tr = state.tr; - const result = commands.acceptTrackedChangeById(id)({ - state, - tr, - commands: { acceptTrackedChangesBetween: acceptSpy }, - }); + const { result, nextState } = runByIdDecision({ decision: 'accept', id, doc }); expect(result).toBe(true); - expect(acceptSpy).toHaveBeenCalledTimes(2); - expect(acceptSpy).toHaveBeenNthCalledWith(1, 1, 8); - expect(acceptSpy).toHaveBeenNthCalledWith(2, 9, 17); + expect(nextState.doc.textContent).toBe(' inserted'); + expect(hasAnyMark(nextState.doc, TrackInsertMarkName)).toBe(false); + expect(hasAnyMark(nextState.doc, TrackDeleteMarkName)).toBe(false); }); - it('should NOT link two deletions', () => { - const deletionMark1 = schema.marks[TrackDeleteMarkName].create({ id: 'del-1' }); - const deletionMark2 = schema.marks[TrackDeleteMarkName].create({ id: 'del-2' }); + it('by-id decisions resolve only the target deletion, leaving sibling deletions intact', () => { const paragraph = schema.nodes.paragraph.create(null, [ - schema.text('first', [deletionMark1]), - schema.text('second', [deletionMark2]), + schema.text('first', [schema.marks[TrackDeleteMarkName].create({ id: 'del-1' })]), + schema.text('second', [schema.marks[TrackDeleteMarkName].create({ id: 'del-2' })]), ]); const doc = schema.nodes.doc.create(null, paragraph); - const state = createState(doc); - - const acceptSpy = vi.fn().mockReturnValue(true); - const tr = state.tr; - const result = commands.acceptTrackedChangeById('del-2')({ - state, - tr, - commands: { acceptTrackedChangesBetween: acceptSpy }, - }); + const { result, nextState } = runByIdDecision({ decision: 'accept', id: 'del-2', doc }); expect(result).toBe(true); - // Should only resolve the target deletion, not the previous deletion - expect(acceptSpy).toHaveBeenCalledTimes(1); - expect(acceptSpy).toHaveBeenCalledWith(6, 12); + expect(nextState.doc.textContent).toBe('first'); + expect(hasAnyMark(nextState.doc, TrackDeleteMarkName)).toBe(true); }); it('toggle and enable commands set plugin metadata', () => { @@ -1533,6 +1480,10 @@ describe('TrackChanges extension commands', () => { }); it('wrapper commands delegate to range-based handlers', () => { + // Single-target wrappers still delegate to the range commands because the + // wrappers compose them. The All wrappers go through the unified decision + // engine instead (see "acceptAllTrackedChanges resolves every tracked + // change in the document" below). const rangeCommand = vi.fn().mockReturnValue(true); const trackedChange = { start: 5, end: 9 }; @@ -1570,27 +1521,41 @@ describe('TrackChanges extension commands', () => { }), ).toBe(true); expect(rejectSelection).toHaveBeenCalledWith(1, 4); + }); - const doc = createDoc('All the things'); + it('acceptAllTrackedChanges resolves every tracked change in the document', () => { + const doc = createDoc('All the things', [schema.marks[TrackInsertMarkName].create({ id: 'all-accept' })]); const state = createState(doc); - const acceptAll = vi.fn().mockReturnValue(true); - const rejectAll = vi.fn().mockReturnValue(true); - expect( - commands.acceptAllTrackedChanges()({ - state, - commands: { acceptTrackedChangesBetween: acceptAll }, - }), - ).toBe(true); - expect(acceptAll).toHaveBeenCalledWith(0, doc.content.size); + let nextState; + commands.acceptAllTrackedChanges()({ + state, + dispatch: (tr) => { + nextState = state.apply(tr); + }, + editor: { options: { user: { name: 'Reviewer', email: 'reviewer@example.com' } } }, + }); - expect( - commands.rejectAllTrackedChanges()({ - state, - commands: { rejectTrackedChangesBetween: rejectAll }, - }), - ).toBe(true); - expect(rejectAll).toHaveBeenCalledWith(0, doc.content.size); + expect(nextState).toBeDefined(); + expect(nextState.doc.textContent).toBe('All the things'); + expect(hasAnyMark(nextState.doc, TrackInsertMarkName)).toBe(false); + }); + + it('rejectAllTrackedChanges removes every tracked insertion from the document', () => { + const doc = createDoc('Hello world', [schema.marks[TrackInsertMarkName].create({ id: 'all-reject' })]); + const state = createState(doc); + + let nextState; + commands.rejectAllTrackedChanges()({ + state, + dispatch: (tr) => { + nextState = state.apply(tr); + }, + editor: { options: { user: { name: 'Reviewer', email: 'reviewer@example.com' } } }, + }); + + expect(nextState).toBeDefined(); + expect(nextState.doc.textContent).toBe(''); }); describe('insertTrackedChange', () => { diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-overlap-decisions.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-overlap-decisions.test.js new file mode 100644 index 0000000000..7f2df0ebda --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-overlap-decisions.test.js @@ -0,0 +1,107 @@ +// @ts-check +/** + * Integration tests for overlap-aware decision engine paths. + * + * Verifies that v1 commands route through the decision engine and leave the + * document in the expected logical state. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { initTestEditor } from '@tests/helpers/helpers.js'; +import { buildReviewGraph } from './review-model/review-graph.js'; + +const ALICE = { name: 'Alice', email: 'alice@example.com' }; + +const setup = () => { + const { editor } = initTestEditor({ + mode: 'text', + content: '

Hello world

', + user: ALICE, + trackedChanges: {}, + }); + editor.commands.enableTrackChanges(); + return editor; +}; + +const graphFor = (editor) => buildReviewGraph({ state: editor.state }); + +describe('overlap wired decision engine (phase0-004)', () => { + let editor; + afterEach(() => { + editor?.destroy(); + editor = null; + }); + + it('acceptAllTrackedChanges via overlap retires tracked insertions', () => { + editor = setup(); + editor.commands.insertTrackedChange({ from: 6, to: 6, text: 'BIG ', user: ALICE }); + const graphBefore = graphFor(editor); + expect(graphBefore.changes.size).toBe(1); + + const textBefore = editor.state.doc.textContent; + const applied = editor.commands.acceptAllTrackedChanges(); + expect(applied).toBe(true); + const graphAfter = graphFor(editor); + expect(graphAfter.changes.size).toBe(0); + // Accepted insertion keeps the inserted content; document text is unchanged. + expect(editor.state.doc.textContent).toBe(textBefore); + }); + + it('rejectAllTrackedChanges via overlap removes inserted content', () => { + editor = setup(); + editor.commands.insertTrackedChange({ from: 6, to: 6, text: 'BAD ', user: ALICE }); + const applied = editor.commands.rejectAllTrackedChanges(); + expect(applied).toBe(true); + expect(editor.state.doc.textContent).toBe('Hello world'); + expect(graphFor(editor).changes.size).toBe(0); + }); + + it('acceptTrackedChangeById routes through the decision engine', () => { + editor = setup(); + editor.commands.insertTrackedChange({ from: 6, to: 6, text: 'X', user: ALICE, id: 'ins-by-id' }); + const textBefore = editor.state.doc.textContent; + const applied = editor.commands.acceptTrackedChangeById('ins-by-id'); + expect(applied).toBe(true); + // Accepting an insertion preserves text. + expect(editor.state.doc.textContent).toBe(textBefore); + expect(graphFor(editor).changes.has('ins-by-id')).toBe(false); + }); + + it('rejectTrackedChangeById routes through the decision engine', () => { + editor = setup(); + editor.commands.insertTrackedChange({ from: 6, to: 6, text: 'X', user: ALICE, id: 'ins-by-id-2' }); + const applied = editor.commands.rejectTrackedChangeById('ins-by-id-2'); + expect(applied).toBe(true); + expect(editor.state.doc.textContent).toBe('Hello world'); + expect(graphFor(editor).changes.has('ins-by-id-2')).toBe(false); + }); + + it('acceptTrackedChangesBetween routes through the decision engine for range targets', () => { + editor = setup(); + editor.commands.insertTrackedChange({ from: 6, to: 6, text: 'NEW ', user: ALICE }); + const textBefore = editor.state.doc.textContent; + const applied = editor.commands.acceptTrackedChangesBetween(0, editor.state.doc.content.size); + expect(applied).toBe(true); + // Accepted insertion keeps inserted text; ensure graph is now empty. + expect(editor.state.doc.textContent).toBe(textBefore); + expect(graphFor(editor).changes.size).toBe(0); + }); + + it('permission denial under overlap aborts before mutation', () => { + const { editor: ed } = initTestEditor({ + mode: 'text', + content: '

Hello world

', + user: ALICE, + trackedChanges: {}, + permissionResolver: () => false, + }); + ed.commands.enableTrackChanges(); + ed.commands.insertTrackedChange({ from: 6, to: 6, text: 'X', user: ALICE, id: 'deny-ins' }); + const before = ed.state.doc.textContent; + const applied = ed.commands.acceptTrackedChangeById('deny-ins'); + expect(applied).toBe(false); + expect(ed.state.doc.textContent).toBe(before); + const failure = ed.storage.trackChanges.lastDecisionFailure; + expect(failure?.code).toBe('PERMISSION_DENIED'); + ed.destroy(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-overlap-history.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-overlap-history.test.js new file mode 100644 index 0000000000..b38313fa2e --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-overlap-history.test.js @@ -0,0 +1,152 @@ +// @ts-check +/** + * Phase 005 — undo/redo restores successor fragment ids verbatim. + * + * Plan: v1-3220 / phase0-005 "Undo/Redo Requirements". + * + * "Do not recompute new ids on redo. The decision engine must write the + * full successor-fragment mark state into the dispatched transaction so + * ProseMirror history restores it verbatim. Redo must not rerun the + * decision engine to mint ids again." + * + * These integration tests prove that a partial-range accept on a tracked + * insertion: + * + * 1) writes deterministic successor fragment ids in the dispatched + * transaction (already covered by the decision-engine unit tests); + * 2) survives undo with the source logical id restored; + * 3) survives redo with the SAME successor ids (not re-minted by re-running + * the engine on redo). + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { initTestEditor } from '@tests/helpers/helpers.js'; +import { TrackInsertMarkName } from './constants.js'; + +const ALICE = { name: 'Alice', email: 'alice@example.com' }; + +const setup = () => { + const { editor } = initTestEditor({ + mode: 'text', + content: '

Hello world

', + user: ALICE, + trackedChanges: {}, + }); + editor.commands.enableTrackChanges(); + return editor; +}; + +const collectInsertMarks = (state) => { + const marks = []; + state.doc.descendants((node, pos) => { + if (!node.marks) return; + for (const mark of node.marks) { + if (mark.type.name !== TrackInsertMarkName) continue; + marks.push({ + id: mark.attrs.id, + splitFromId: mark.attrs.splitFromId, + revisionGroupId: mark.attrs.revisionGroupId, + text: node.text ?? '', + pos, + }); + } + }); + return marks; +}; + +describe('overlap — partial accept + undo/redo preserves successor ids', () => { + let editor; + afterEach(() => { + editor?.destroy(); + editor = null; + }); + + it('preserves successor fragment ids verbatim across undo / redo', () => { + editor = setup(); + + // Insert a tracked insertion spanning a known range. + editor.commands.insertTrackedChange({ + from: 6, + to: 6, + text: 'INSERTED', + user: ALICE, + id: 'logical-ins', + }); + + const beforeMarks = collectInsertMarks(editor.state); + expect(beforeMarks).toHaveLength(1); + expect(beforeMarks[0].id).toBe('logical-ins'); + + // Find the inserted-mark range in the document; the inserted text is + // 'INSERTED' (8 chars). Accept only the middle 4 chars to force a + // partial decision that mints two successor fragments. + const insertedPos = beforeMarks[0].pos; + const partialFrom = insertedPos + 2; // skip 'IN' + const partialTo = insertedPos + 6; // through 'SERT' + + const applied = editor.commands.acceptTrackedChangesBetween(partialFrom, partialTo); + expect(applied).toBe(true); + + const afterMarks = collectInsertMarks(editor.state); + // Source id retired; two successor fragments remain with splitFromId. + expect(afterMarks).toHaveLength(2); + for (const m of afterMarks) { + expect(m.splitFromId).toBe('logical-ins'); + expect(m.id).not.toBe('logical-ins'); + } + // Capture the minted successor ids so we can compare across history. + const successorIds = afterMarks.map((m) => m.id).sort(); + expect(successorIds[0]).not.toBe(successorIds[1]); + + // Undo — original logical id restored, successor ids gone. + const undid = editor.commands.undo(); + expect(undid).toBe(true); + const undoneMarks = collectInsertMarks(editor.state); + expect(undoneMarks).toHaveLength(1); + expect(undoneMarks[0].id).toBe('logical-ins'); + expect(undoneMarks[0].splitFromId).toBe(''); + + // Redo — the SAME successor ids must come back. Re-running the engine + // would mint new ids; PM history restores the dispatched transaction + // verbatim, including the typed successor marks. + const redid = editor.commands.redo(); + expect(redid).toBe(true); + const redoneMarks = collectInsertMarks(editor.state); + const redoneSuccessorIds = redoneMarks.map((m) => m.id).sort(); + expect(redoneSuccessorIds).toEqual(successorIds); + for (const m of redoneMarks) { + expect(m.splitFromId).toBe('logical-ins'); + } + }); + + it('preserves successor revisionGroupId across undo / redo', () => { + editor = setup(); + editor.commands.insertTrackedChange({ + from: 6, + to: 6, + text: 'INSERTED', + user: ALICE, + id: 'group-ins', + }); + + const before = collectInsertMarks(editor.state); + const baseGroup = before[0].revisionGroupId || before[0].id; + const insertedPos = before[0].pos; + + editor.commands.acceptTrackedChangesBetween(insertedPos + 2, insertedPos + 6); + const after = collectInsertMarks(editor.state); + for (const m of after) { + // Successor fragments inherit the source's revisionGroupId so the + // graph can correlate them as one logical group. + expect(m.revisionGroupId).toBe(baseGroup); + } + + editor.commands.undo(); + editor.commands.redo(); + + const redone = collectInsertMarks(editor.state); + for (const m of redone) { + expect(m.revisionGroupId).toBe(baseGroup); + } + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-overlap.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-overlap.test.js new file mode 100644 index 0000000000..a680c5dccf --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-overlap.test.js @@ -0,0 +1,217 @@ +// @ts-check +/** + * Integration tests for overlap-aware tracked editing. + * + * Phase 0 / plan 003 ("Tests"): the matrix tests above (see + * `review-model/overlap-compiler.test.js`) verify the compiler in isolation. + * This file exercises the wired path: a real editor with suggesting mode + * enabled and ordinary command dispatches. + * + * The tests assert: + * - text content after the edit + * - tracked-mark structure (insert/delete/format) + * - logical change projection via the review graph + * + * Decision-engine accept/reject lifecycle is owned by plan 004 and not + * exercised here. + */ +import { beforeEach, afterEach, describe, expect, it } from 'vitest'; +import { TextSelection } from 'prosemirror-state'; +import { TrackInsertMarkName, TrackDeleteMarkName } from './constants.js'; +import { initTestEditor } from '@tests/helpers/helpers.js'; +import { buildReviewGraph, CanonicalChangeType } from './review-model/review-graph.js'; + +const ALICE = { name: 'Alice', email: 'alice@example.com' }; +const BOB = { name: 'Bob', email: 'bob@example.com' }; + +const setup = (user = ALICE, content = '

Hi there

') => { + const { editor } = initTestEditor({ + mode: 'text', + content, + user, + trackedChanges: {}, + }); + // Enable suggesting (track changes) mode. + editor.commands.enableTrackChanges(); + return editor; +}; + +const graphFor = (editor) => buildReviewGraph({ state: editor.state }); + +describe('overlap wired: native trackedTransaction routes through compiler', () => { + let editor; + afterEach(() => { + editor?.destroy(); + editor = null; + }); + + it('marks a fresh insertion with a tracked-insert mark', () => { + editor = setup(); + // Set caret inside the paragraph (after "Hi "). + const insertPos = 4; + editor.commands.command(({ tr, dispatch }) => { + tr.setSelection(TextSelection.create(tr.doc, insertPos)); + tr.insertText('X', insertPos); + if (dispatch) dispatch(tr); + return true; + }); + const text = editor.state.doc.textContent; + expect(text).toContain('X'); + const graph = graphFor(editor); + expect(graph.changes.size).toBe(1); + const change = Array.from(graph.changes.values())[0]; + expect(change.type).toBe(CanonicalChangeType.Insertion); + expect(change.authorEmail).toBe(ALICE.email); + }); + + it('refines own insertion when the same user types again inside it', () => { + editor = setup(); + const at = 4; + editor.commands.command(({ tr, dispatch }) => { + tr.insertText('XY', at); + if (dispatch) dispatch(tr); + return true; + }); + // Now insert in the middle of the just-typed insertion. + const middle = at + 1; // between X and Y + editor.commands.command(({ tr, dispatch }) => { + tr.insertText('M', middle); + if (dispatch) dispatch(tr); + return true; + }); + const graph = graphFor(editor); + // Still one logical change — refinement preserved a single id. + expect(graph.changes.size).toBe(1); + const change = Array.from(graph.changes.values())[0]; + expect(change.type).toBe(CanonicalChangeType.Insertion); + expect(change.insertedSegments.length).toBeGreaterThanOrEqual(1); + // Doc text contains all the inserted characters in order. + expect(editor.state.doc.textContent).toContain('XMY'); + }); +}); + +describe('overlap wired: insertTrackedChange delegates to compiler', () => { + let editor; + afterEach(() => { + editor?.destroy(); + editor = null; + }); + + it('document-api tracked replace produces a paired replacement in the graph', () => { + editor = setup(ALICE, '

hello world

'); + // Replace "hello" with "HELLO" — paired (default). + const ok = editor.commands.insertTrackedChange({ + from: 1, + to: 6, + text: 'HELLO', + user: ALICE, + }); + expect(ok).toBe(true); + const graph = graphFor(editor); + // Paired mode → one logical replacement change. + expect(graph.changes.size).toBe(1); + const change = Array.from(graph.changes.values())[0]; + expect(change.type).toBe(CanonicalChangeType.Replacement); + expect(change.replacement?.inserted.length).toBeGreaterThan(0); + expect(change.replacement?.deleted.length).toBeGreaterThan(0); + }); + + it('document-api tracked insert in middle of text creates one insertion change', () => { + editor = setup(ALICE, '

hello

'); + // Find the inline text position for "hello" and pick the offset after + // the first three characters ("hel"). + let textNode = null; + let textNodePos = -1; + editor.state.doc.descendants((node, pos) => { + if (textNode || !node.isText) return; + textNode = node; + textNodePos = pos; + }); + expect(textNode).toBeTruthy(); + const insertAt = textNodePos + 3; // between "hel" and "lo" + const ok = editor.commands.insertTrackedChange({ + from: insertAt, + to: insertAt, + text: 'X', + user: ALICE, + }); + expect(ok).toBe(true); + const graph = graphFor(editor); + expect(graph.changes.size).toBe(1); + const change = Array.from(graph.changes.values())[0]; + expect(change.type).toBe(CanonicalChangeType.Insertion); + expect(editor.state.doc.textContent).toBe('helXlo'); + }); + + it('document-api tracked insert preserves active inline formatting marks', () => { + editor = setup(ALICE, '

hello

'); + let textNodePos = -1; + editor.state.doc.descendants((node, pos) => { + if (!node.isText || textNodePos !== -1) return; + textNodePos = pos; + }); + expect(textNodePos).toBeGreaterThanOrEqual(0); + + const insertAt = textNodePos + 3; + const ok = editor.commands.insertTrackedChange({ + from: insertAt, + to: insertAt, + text: 'X', + user: ALICE, + }); + + expect(ok).toBe(true); + let insertedMarks = []; + editor.state.doc.descendants((node) => { + if (node.isText && node.text === 'X') { + insertedMarks = node.marks.map((mark) => mark.type.name); + return false; + } + }); + expect(insertedMarks).toContain('bold'); + expect(insertedMarks).toContain(TrackInsertMarkName); + }); + + it('document-api tracked insert uses the provided id as the logical change id', () => { + editor = setup(ALICE, '

hello

'); + const providedId = 'api-provided-id'; + const ok = editor.commands.insertTrackedChange({ + from: 4, + to: 4, + text: 'X', + id: providedId, + user: ALICE, + }); + expect(ok).toBe(true); + const graph = graphFor(editor); + expect(graph.changes.get(providedId)).toBeDefined(); + }); + + it('document-api tracked replacement uses the provided id as the paired logical change id', () => { + editor = setup(ALICE, '

hello

'); + const providedId = 'api-replace-id'; + const ok = editor.commands.insertTrackedChange({ + from: 1, + to: 6, + text: 'HELLO', + id: providedId, + user: ALICE, + }); + expect(ok).toBe(true); + const graph = graphFor(editor); + const change = graph.changes.get(providedId); + expect(change).toBeDefined(); + expect(change.type).toBe(CanonicalChangeType.Replacement); + }); + + it('returns false for invalid range', () => { + editor = setup(ALICE, '

short

'); + const ok = editor.commands.insertTrackedChange({ + from: 0, + to: 999, + text: 'X', + user: ALICE, + }); + expect(ok).toBe(false); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js index f33f7040bd..e839e0b236 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js @@ -1,16 +1,39 @@ +// @ts-check import { Extension } from '@core/Extension.js'; -import { Slice } from 'prosemirror-model'; -import { Mapping, ReplaceStep, AddMarkStep, RemoveMarkStep } from 'prosemirror-transform'; -import { v4 as uuidv4 } from 'uuid'; import { TrackDeleteMarkName, TrackInsertMarkName, TrackFormatMarkName } from './constants.js'; import { TrackChangesBasePlugin, TrackChangesBasePluginKey } from './plugins/index.js'; import { getTrackChanges } from './trackChangesHelpers/getTrackChanges.js'; -import { markDeletion } from './trackChangesHelpers/markDeletion.js'; -import { markInsertion } from './trackChangesHelpers/markInsertion.js'; -import { collectTrackedChanges, isTrackedChangeActionAllowed } from './permission-helpers.js'; -import { CommentsPluginKey, createOrUpdateTrackedChangeComment } from '../comment/comments-plugin.js'; -import { findMarkInRangeBySnapshot } from './trackChangesHelpers/markSnapshotHelpers.js'; +import { collectTrackedChanges } from './permission-helpers.js'; +import { CommentsPluginKey } from '../comment/comments-plugin.js'; import { hasExpandedSelection } from '@utils/selectionUtils.js'; +import { compileTrackedEdit } from './review-model/overlap-compiler.js'; +import { + makeTextInsertIntent, + makeTextDeleteIntent, + makeTextReplaceIntent, + sliceFromText, +} from './review-model/edit-intent.js'; +import { decideTrackedChanges, buildDecisionBubbleEvents } from './review-model/decision-engine.js'; + +/** + * @typedef {{ code: string, message: string, details?: unknown }} TrackChangesFailure + * @typedef {{ + * lastCompilerFailure: TrackChangesFailure | null, + * lastDecisionFailure: TrackChangesFailure | null, + * lastDecisionReceipt: unknown, + * }} TrackChangesStorage + */ + +/** + * @param {unknown} editor + * @returns {TrackChangesStorage | null} + */ +const getTrackChangesStorage = (editor) => { + const storage = /** @type {{ storage?: { trackChanges?: unknown } } | null | undefined} */ (editor)?.storage + ?.trackChanges; + if (!storage || typeof storage !== 'object') return null; + return /** @type {TrackChangesStorage} */ (storage); +}; /** * Reads the `replacements` mode from editor.options.trackedChanges. @@ -20,122 +43,242 @@ import { hasExpandedSelection } from '@utils/selectionUtils.js'; const readReplacementsMode = (editor) => editor?.options?.trackedChanges?.replacements === 'independent' ? 'independent' : 'paired'; -export const TrackChanges = Extension.create({ - name: 'trackChanges', +/** + * Runs the atomic decision engine, dispatches the resulting transaction, and + * emits bubble lifecycle events from the decision receipt. + */ +const dispatchReviewDecision = ({ editor, state, dispatch, decision, target }) => { + const trackChangesStorage = getTrackChangesStorage(editor); + if (trackChangesStorage) { + trackChangesStorage.lastDecisionFailure = null; + trackChangesStorage.lastDecisionReceipt = null; + } + const result = decideTrackedChanges({ + state, + editor, + decision, + target, + replacements: readReplacementsMode(editor), + }); + if (result.ok === false) { + // Fail closed (do NOT mutate) for hard errors. NO_OP and + // CAPABILITY_UNAVAILABLE return `false` so toolbar wrappers can decide + // how to surface the result. + if (trackChangesStorage) { + trackChangesStorage.lastDecisionFailure = { + code: result.code, + message: result.message, + details: result.details, + }; + } + return { applied: false, failure: result }; + } + if (trackChangesStorage) { + trackChangesStorage.lastDecisionReceipt = result.receipt ?? null; + } + if (dispatch) { + // Compute the post-dispatch state locally so we can derive update events + // for partial decisions (where a change has remaining tracked text on the + // doc). Then dispatch the real transaction. + const nextState = state.apply(result.tr); + dispatch(result.tr); + + if (editor?.emit) { + const documentId = editor.options?.documentId; + // Partial decisions retire the original id and mint successor fragments + // (splitFromId === originalId). For each retired id, decide whether to + // emit `resolve` (no successors remain) or `update` (successors keep + // the logical change alive with refreshed text). + const resolveEvents = buildDecisionBubbleEvents({ result, editor }); + for (const event of resolveEvents) { + const successorsPresent = collectRemainingForLogicalId({ + state: nextState, + originalId: event.changeId, + }).some(({ mark }) => mark.attrs?.splitFromId === event.changeId); + if (successorsPresent) continue; + editor.emit('commentsUpdate', event); + } - addCommands() { - return { - acceptTrackedChangesBetween: - (from, to) => - ({ state, dispatch, editor }) => { - const trackedChanges = collectTrackedChanges({ state, from, to }); - if (!isTrackedChangeActionAllowed({ editor, action: 'accept', trackedChanges })) return false; + const touched = + result.touchedChangeIds instanceof Set ? result.touchedChangeIds : new Set(result.touchedChangeIds || []); + const emittedFor = new Set(); + for (const changeId of touched) { + if (emittedFor.has(changeId)) continue; + // Skip ids that are successor fragments of another touched id (we + // emit one update for the logical original id). + if ( + Array.from(touched).some( + (other) => other !== changeId && isSuccessorOf({ state: nextState, id: changeId, originalId: other }), + ) + ) { + continue; + } + const remaining = collectRemainingForLogicalId({ state: nextState, originalId: changeId }); + if (!remaining.length) continue; + const payload = buildPartialUpdatePayload({ + state: nextState, + documentId, + originalId: changeId, + remaining, + }); + if (payload) { + editor.emit('commentsUpdate', payload); + emittedFor.add(changeId); + } + } + + const deletedCommentIds = new Set((result.receipt?.deletedComments ?? []).map(({ id }) => id).filter(Boolean)); + const detachedCommentIds = new Set( + (result.receipt?.detachedComments ?? []).map(({ id }) => id).filter((id) => id && !deletedCommentIds.has(id)), + ); + + for (const commentId of deletedCommentIds) { + editor.emit('commentsUpdate', buildDeletedCommentPayload({ commentId, documentId })); + } + for (const commentId of detachedCommentIds) { + editor.emit('commentsUpdate', buildDetachedCommentUpdatePayload({ commentId, documentId })); + } + } + } + return { applied: true, result }; +}; - let { tr, doc } = state; +/** + * Collect tracked-change marks that represent the logical original id, either + * directly (mark.attrs.id === originalId) or via successor fragments + * (mark.attrs.splitFromId === originalId). + * + * @param {{ state: import('prosemirror-state').EditorState, originalId: string }} options + * @returns {import('./trackChangesHelpers/types.js').TrackedMarkRange[]} + */ +const collectRemainingForLogicalId = ({ state, originalId }) => { + const all = getTrackChanges(state); + return all.filter(({ mark }) => mark.attrs?.id === originalId || mark.attrs?.splitFromId === originalId); +}; - // if (from === to) { - // to += 1; - // } +const isSuccessorOf = ({ state, id, originalId }) => { + const all = getTrackChanges(state); + return all.some(({ mark }) => mark.attrs?.id === id && mark.attrs?.splitFromId === originalId); +}; - // tr.setMeta('acceptReject', true); - tr.setMeta('inputType', 'acceptReject'); - const touchedChangeIds = new Set(); - const map = new Mapping(); +/** + * Build a comments-plugin-shaped `update` payload from the remaining + * tracked-change marks for a logical original id. Aggregates inserted / + * deleted text across all surviving successor fragments and uses the original + * id as the changeId so existing bubble threads remain addressable. + */ +const buildPartialUpdatePayload = ({ state, documentId, originalId, remaining }) => { + let insertedMark = null; + let deletionMark = null; + let formatMark = null; + for (const entry of remaining) { + if (!insertedMark && entry.mark.type.name === TrackInsertMarkName) insertedMark = entry.mark; + if (!deletionMark && entry.mark.type.name === TrackDeleteMarkName) deletionMark = entry.mark; + if (!formatMark && entry.mark.type.name === TrackFormatMarkName) formatMark = entry.mark; + } + const anchorMark = insertedMark || deletionMark || formatMark; + if (!anchorMark) return null; + + const insertedText = remaining + .filter(({ mark }) => mark.type.name === TrackInsertMarkName) + .map(({ node }) => node?.text || node?.textContent || '') + .join(''); + const deletedText = remaining + .filter(({ mark }) => mark.type.name === TrackDeleteMarkName) + .map(({ node }) => node?.text || node?.textContent || '') + .join(''); + + const trackedChangeType = insertedMark + ? TrackInsertMarkName + : deletionMark + ? TrackDeleteMarkName + : TrackFormatMarkName; + const isReplacement = Boolean(insertedMark && deletionMark); + const { author, authorId, authorEmail, authorImage, date, importedAuthor } = anchorMark.attrs; - doc.nodesBetween(from, to, (node, pos) => { - const trackedMark = getTrackedMark(node); - if (!trackedMark) return; + return { + event: 'update', + type: 'trackedChange', + documentId, + changeId: originalId, + trackedChangeType: isReplacement ? 'both' : trackedChangeType, + trackedChangeText: trackedChangeType === TrackDeleteMarkName ? deletedText : insertedText, + trackedChangeDisplayType: null, + deletedText: isReplacement || deletionMark ? deletedText : null, + author, + ...(authorId && { authorId }), + authorEmail, + ...(authorImage && { authorImage }), + date, + ...(importedAuthor && { importedAuthor: { name: importedAuthor } }), + }; +}; - const mappedFrom = map.map(Math.max(pos, from)); - const mappedTo = map.map(Math.min(pos + node.nodeSize, to)); - if (mappedFrom >= mappedTo) return; +const buildDetachedCommentUpdatePayload = ({ commentId, documentId }) => ({ + type: 'update', + comment: { + commentId, + documentId, + fileId: documentId ?? null, + trackedChange: false, + trackedChangeParentId: null, + trackedChangeType: null, + trackedChangeDisplayType: null, + trackedChangeText: null, + trackedChangeStory: null, + trackedChangeStoryKind: null, + trackedChangeStoryLabel: null, + trackedChangeAnchorKey: null, + deletedText: null, + }, +}); - if (trackedMark.attrs?.id) touchedChangeIds.add(trackedMark.attrs.id); +const buildDeletedCommentPayload = ({ commentId, documentId }) => ({ + type: 'deleted', + comment: { + commentId, + documentId, + fileId: documentId ?? null, + }, +}); - if (trackedMark.type.name === TrackDeleteMarkName) { - const deletionStep = new ReplaceStep(mappedFrom, mappedTo, Slice.empty); - tr.step(deletionStep); - map.appendMap(deletionStep.getMap()); - return; - } +export const TrackChanges = Extension.create({ + name: 'trackChanges', - tr.step(new RemoveMarkStep(mappedFrom, mappedTo, trackedMark)); - }); + addStorage() { + return { + lastCompilerFailure: null, + lastDecisionFailure: null, + lastDecisionReceipt: null, + }; + }, - return dispatchTrackedChangeResolution({ + addCommands() { + return { + acceptTrackedChangesBetween: + (from, to) => + ({ state, dispatch, editor }) => { + const reviewDecision = dispatchReviewDecision({ + editor, state, - tr, dispatch, - editor, - touchedChangeIds, + decision: 'accept', + target: { kind: 'range', from, to }, }); + return reviewDecision.applied; }, rejectTrackedChangesBetween: (from, to) => ({ state, dispatch, editor }) => { - const trackedChanges = collectTrackedChanges({ state, from, to }); - if (!isTrackedChangeActionAllowed({ editor, action: 'reject', trackedChanges })) return false; - - const { tr, doc } = state; - const touchedChangeIds = new Set(); - tr.setMeta('inputType', 'acceptReject'); - - const map = new Mapping(); - - doc.nodesBetween(from, to, (node, pos) => { - const trackedMark = getTrackedMark(node); - if (!trackedMark) return; - - const mappedFrom = map.map(Math.max(pos, from)); - const mappedTo = map.map(Math.min(pos + node.nodeSize, to)); - if (mappedFrom >= mappedTo) return; - - if (trackedMark.attrs?.id) touchedChangeIds.add(trackedMark.attrs.id); - - if (trackedMark.type.name === TrackDeleteMarkName) { - tr.step(new RemoveMarkStep(mappedFrom, mappedTo, trackedMark)); - return; - } - - if (trackedMark.type.name === TrackInsertMarkName) { - const deletionStep = new ReplaceStep(mappedFrom, mappedTo, Slice.empty); - tr.step(deletionStep); - map.appendMap(deletionStep.getMap()); - return; - } - - trackedMark.attrs.after.forEach((newMark) => { - const liveMark = findMarkInRangeBySnapshot({ - doc: tr.doc, - from: mappedFrom, - to: mappedTo, - snapshot: newMark, - }); - - if (!liveMark) { - return; - } - - tr.step(new RemoveMarkStep(mappedFrom, mappedTo, liveMark)); - }); - - // Remove suggested "after" marks first, then restore "before" marks. - // This avoids overlap matching removing a just-restored attribute-only mark (e.g. textStyle). - trackedMark.attrs.before.forEach((oldMark) => { - tr.step(new AddMarkStep(mappedFrom, mappedTo, state.schema.marks[oldMark.type].create(oldMark.attrs))); - }); - - tr.step(new RemoveMarkStep(mappedFrom, mappedTo, trackedMark)); - }); - - return dispatchTrackedChangeResolution({ + const reviewDecision = dispatchReviewDecision({ + editor, state, - tr, dispatch, - editor, - touchedChangeIds, + decision: 'reject', + target: { kind: 'range', from, to }, }); + return reviewDecision.applied; }, acceptTrackedChange: @@ -168,7 +311,7 @@ export const TrackChanges = Extension.create({ }, acceptTrackedChangeFromContextMenu: - ({ from, to, trackedChangeId = null } = {}) => + ({ from = null, to = null, trackedChangeId = null } = {}) => ({ state, commands, editor }) => { return resolveTrackedChangeAction({ action: 'accept', @@ -188,38 +331,41 @@ export const TrackChanges = Extension.create({ acceptTrackedChangeById: (id) => - ({ state, tr, commands }) => { - const toResolve = getChangesByIdToResolve(state, id) || []; - - return toResolve - .map(({ from, to }) => { - let mappedFrom = tr.mapping.map(from); - let mappedTo = tr.mapping.map(to); - return commands.acceptTrackedChangesBetween(mappedFrom, mappedTo); - }) - .every((result) => result); + ({ state, dispatch, editor }) => { + const reviewDecision = dispatchReviewDecision({ + editor, + state, + dispatch, + decision: 'accept', + target: { kind: 'id', id }, + }); + return reviewDecision.applied; }, acceptAllTrackedChanges: () => - ({ state, commands }) => { - const from = 0, - to = state.doc.content.size; - return commands.acceptTrackedChangesBetween(from, to); + ({ state, dispatch, editor }) => { + const reviewDecision = dispatchReviewDecision({ + editor, + state, + dispatch, + decision: 'accept', + target: { kind: 'all' }, + }); + return reviewDecision.applied; }, rejectTrackedChangeById: (id) => - ({ state, tr, commands }) => { - const toReject = getChangesByIdToResolve(state, id) || []; - - return toReject - .map(({ from, to }) => { - let mappedFrom = tr.mapping.map(from); - let mappedTo = tr.mapping.map(to); - return commands.rejectTrackedChangesBetween(mappedFrom, mappedTo); - }) - .every((result) => result); + ({ state, dispatch, editor }) => { + const reviewDecision = dispatchReviewDecision({ + editor, + state, + dispatch, + decision: 'reject', + target: { kind: 'id', id }, + }); + return reviewDecision.applied; }, rejectTrackedChange: @@ -252,7 +398,7 @@ export const TrackChanges = Extension.create({ }, rejectTrackedChangeFromContextMenu: - ({ from, to, trackedChangeId = null } = {}) => + ({ from = null, to = null, trackedChangeId = null } = {}) => ({ state, commands, editor }) => { return resolveTrackedChangeAction({ action: 'reject', @@ -272,10 +418,15 @@ export const TrackChanges = Extension.create({ rejectAllTrackedChanges: () => - ({ state, commands }) => { - const from = 0, - to = state.doc.content.size; - return commands.rejectTrackedChangesBetween(from, to); + ({ state, dispatch, editor }) => { + const reviewDecision = dispatchReviewDecision({ + editor, + state, + dispatch, + decision: 'reject', + target: { kind: 'all' }, + }); + return reviewDecision.applied; }, insertTrackedChange: @@ -316,108 +467,21 @@ export const TrackChanges = Extension.create({ console.warn('insertTrackedChange: no user name/email provided, track change will have undefined author'); } const date = new Date().toISOString(); - const tr = state.tr; - - // Get marks from original position BEFORE any changes for format preservation - const marks = state.doc.resolve(from).marks(); - - // id-minting strategy for a tracked insert/delete/replace: - // - One `primaryId` anchors the operation. When the caller supplies - // `id` (e.g. the Document API write adapter), that becomes the - // primary; otherwise we mint a fresh UUID. - // - The primary id is used for the insertion (pure insert) or the - // lone deletion (pure delete), and always as the `changeId` we - // report back — comment threads key off this id too. - // - For a replacement: in `'paired'` mode both halves share the - // primary id (Google-Docs-like one-click resolve). In - // `'independent'` mode (modules.trackChanges.replacements: - // 'independent'), the insertion keeps the primary id and the - // deletion mints its own fresh id via markDeletion, so each - // revision is independently addressable per ECMA-376 §17.13.5. - const replacementsMode = readReplacementsMode(editor); - const pairedReplacements = replacementsMode === 'paired'; - const isReplacement = from !== to && text; - const primaryId = id ?? uuidv4(); - const insertionId = primaryId; - const deletionId = pairedReplacements || !isReplacement ? primaryId : null; - - const changeId = primaryId; - let insertPos = to; // Default insert position is after the selection - let deletionMark = null; - let deletionNodes = []; - - // Step 1: Mark the original text as deleted (if there's text to delete) - if (from !== to) { - const result = markDeletion({ - tr, - from, - to, - user: resolvedUser, - date, - id: deletionId, - }); - deletionMark = result.deletionMark; - deletionNodes = result.nodes || []; - // Map the insert position through the deletion mapping - insertPos = result.deletionMap.map(to); - } - - // Step 2: Insert the new text after the deleted content - let insertedMark = null; - let insertedNode = null; - if (text) { - insertedNode = state.schema.text(text, marks); - tr.insert(insertPos, insertedNode); - - // Step 3: Mark the insertion - const insertedFrom = insertPos; - const insertedTo = insertPos + insertedNode.nodeSize; - insertedMark = markInsertion({ - tr, - from: insertedFrom, - to: insertedTo, - user: resolvedUser, - date, - id: insertionId, - }); - } - // Store metadata for external consumers (pass full mark objects for comments plugin) - // Create a mock step with slice for the comments plugin to extract nodes - const mockStep = insertedNode - ? { - slice: { content: { content: [insertedNode] } }, - } - : null; - - tr.setMeta(TrackChangesBasePluginKey, { - insertedMark: insertedMark || null, - deletionMark: deletionMark || null, - deletionNodes, - step: mockStep, + return dispatchCompiledInsertTrackedChange({ + editor, + state, + dispatch, + from, + to, + text, + resolvedUser, + date, + providedId: id, + comment, + addToHistory, emitCommentEvent, }); - tr.setMeta(CommentsPluginKey, { type: 'force' }); - tr.setMeta('skipTrackChanges', true); - - if (!addToHistory) { - tr.setMeta('addToHistory', false); - } - - dispatch(tr); - - // Handle comment if provided (guard for editors without comments extension) - if (comment?.trim() && changeId && editor.commands.addCommentReply) { - editor.commands.addCommentReply({ - parentId: changeId, - content: comment, - author: resolvedUser.name, - authorEmail: resolvedUser.email, - authorImage: resolvedUser.image, - }); - } - - return true; }, toggleTrackChanges: @@ -513,10 +577,6 @@ export const TrackChanges = Extension.create({ }, }); -const TRACKED_CHANGE_MARKS = [TrackDeleteMarkName, TrackInsertMarkName, TrackFormatMarkName]; - -const getTrackedMark = (node) => node?.marks?.find((mark) => TRACKED_CHANGE_MARKS.includes(mark.type.name)) ?? null; - const getTrackedChangeActionSelection = ({ state, editor }) => { const currentSelection = state?.selection; if (hasExpandedSelection(currentSelection)) { @@ -607,76 +667,6 @@ const resolveTrackedChangeAction = ({ : selectionCommand(); }; -const collectRemainingMarksByType = (trackedChanges = []) => ({ - insertedMark: trackedChanges.find(({ mark }) => mark.type.name === TrackInsertMarkName)?.mark ?? null, - deletionMark: trackedChanges.find(({ mark }) => mark.type.name === TrackDeleteMarkName)?.mark ?? null, - formatMark: trackedChanges.find(({ mark }) => mark.type.name === TrackFormatMarkName)?.mark ?? null, -}); - -const emitTrackedChangeCommentLifecycle = ({ editor, nextState, touchedChangeIds }) => { - if (!editor?.emit || !touchedChangeIds?.size) { - return; - } - - const resolvedByEmail = editor.options?.user?.email; - const resolvedByName = editor.options?.user?.name; - - touchedChangeIds.forEach((changeId) => { - const remainingTrackedChanges = getTrackChanges(nextState, changeId); - - // Partial resolution keeps the tracked-change thread alive with updated text; - // full resolution emits the normal resolve event so the bubble can disappear. - if (!remainingTrackedChanges.length) { - editor.emit('commentsUpdate', { - type: 'trackedChange', - event: 'resolve', - changeId, - resolvedByEmail, - resolvedByName, - }); - return; - } - - const marks = collectRemainingMarksByType(remainingTrackedChanges); - const updatePayload = createOrUpdateTrackedChangeComment({ - event: 'update', - marks, - deletionNodes: [], - nodes: [], - newEditorState: nextState, - documentId: editor.options?.documentId, - trackedChangesForId: remainingTrackedChanges, - }); - - if (updatePayload) { - editor.emit('commentsUpdate', updatePayload); - } - }); -}; - -const dispatchTrackedChangeResolution = ({ state, tr, dispatch, editor, touchedChangeIds }) => { - if (!tr.steps.length) { - return true; - } - - // Apply tr locally to get nextState for comment lifecycle; dispatch(tr) updates the editor afterward. - const nextState = state.apply(tr); - - if (dispatch) { - dispatch(tr); - } - - if (dispatch && touchedChangeIds?.size) { - emitTrackedChangeCommentLifecycle({ - editor, - nextState, - touchedChangeIds, - }); - } - - return true; -}; - const getChangesByIdToResolve = (state, id) => { const trackedChanges = getTrackChanges(state); const changeIndex = trackedChanges.findIndex(({ mark }) => mark.attrs.id === id); @@ -725,3 +715,147 @@ const getChangesByIdToResolve = (state, id) => { return [matchingChange, ...linkedAfter, ...linkedBefore]; }; + +/** + * Routes the document-api tracked text mutation through the shared overlap + * compiler so native and document-api semantics agree. + * + * @param {{ + * editor: import('../../core/Editor.ts').Editor, + * state: import('prosemirror-state').EditorState, + * dispatch: (tr: import('prosemirror-state').Transaction) => void, + * from: number, + * to: number, + * text: string, + * resolvedUser: object, + * date: string, + * providedId?: string, + * comment?: string, + * addToHistory: boolean, + * emitCommentEvent: boolean, + * }} options + */ +const dispatchCompiledInsertTrackedChange = ({ + editor, + state, + dispatch, + from, + to, + text, + resolvedUser, + date, + providedId, + comment, + addToHistory, + emitCommentEvent, +}) => { + const replacements = readReplacementsMode(editor); + const tr = state.tr; + const schema = state.schema; + const trackChangesStorage = getTrackChangesStorage(editor); + if (trackChangesStorage) { + trackChangesStorage.lastCompilerFailure = null; + } + const activeMarks = state.storedMarks ?? state.doc.resolve(from).marks(); + let intent; + try { + if (from === to && text) { + intent = makeTextInsertIntent({ + at: from, + content: sliceFromText(schema, text, activeMarks), + user: resolvedUser, + date, + source: 'document-api', + replacementGroupHint: providedId, + }); + } else if (from !== to && !text) { + intent = makeTextDeleteIntent({ + from, + to, + user: resolvedUser, + date, + source: 'document-api', + replacementGroupHint: providedId, + }); + } else if (from !== to && text) { + intent = makeTextReplaceIntent({ + from, + to, + content: sliceFromText(schema, text, activeMarks), + replacements, + user: resolvedUser, + date, + source: 'document-api', + replacementGroupHint: providedId, + }); + } else { + return false; + } + } catch (error) { + console.warn('insertTrackedChange: could not build intent', error); + return false; + } + + const result = compileTrackedEdit({ + state, + tr, + intent, + replacements, + }); + + if (result.ok === false) { + if (trackChangesStorage) { + trackChangesStorage.lastCompilerFailure = { + code: result.code, + message: result.message, + details: result.details, + }; + } + return false; + } + if (!dispatch) { + return true; + } + + // Build real metadata for the comments plugin from the compiler result so + // the bubble pipeline can derive the inserted/deleted text immediately + // without re-scanning the doc. + const insertedNodes = result.insertedNodes ?? []; + const deletionNodes = result.deletionNodes ?? []; + const meta = { + insertedMark: result.insertedMark || null, + deletionMark: result.deletionMark || result.deletionMarks?.[0] || null, + deletionNodes, + step: result.insertedStep + ? result.insertedStep + : result.insertedMark + ? { slice: { content: { content: insertedNodes } } } + : null, + emitCommentEvent, + }; + tr.setMeta(TrackChangesBasePluginKey, meta); + tr.setMeta(CommentsPluginKey, { type: 'force' }); + tr.setMeta('skipTrackChanges', true); + if (!addToHistory) { + tr.setMeta('addToHistory', false); + } + + dispatch(tr); + + // Compute a public-facing change id for the comment thread: prefer the + // explicit id the caller provided; otherwise the first created/updated + // change id from the compiler receipt. + const changeId = providedId || result.createdChangeIds?.[0] || result.updatedChangeIds?.[0] || null; + if (comment?.trim() && changeId && editor.commands?.addCommentReply) { + editor.commands.addCommentReply({ + parentId: changeId, + content: comment, + author: resolvedUser.name, + authorId: resolvedUser.id, + authorEmail: resolvedUser.email, + authorImage: resolvedUser.image, + }); + } + + return true; +}; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-delete.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-delete.js index 89542f676c..efc7f2db2e 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/track-delete.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-delete.js @@ -43,6 +43,11 @@ export const TrackDelete = Mark.create({ }, }, + authorId: { + default: '', + rendered: false, + }, + authorEmail: { default: '', parseDOM: (elem) => elem.getAttribute('data-authoremail'), @@ -85,6 +90,49 @@ export const TrackDelete = Mark.create({ default: '', rendered: false, }, + + // Review graph metadata. See track-insert.js for the rationale — + // never DOM-rendered, optional, inferred for older marks. + + revisionGroupId: { + default: '', + rendered: false, + }, + + splitFromId: { + default: '', + rendered: false, + }, + + changeType: { + default: '', + rendered: false, + }, + + replacementGroupId: { + default: '', + rendered: false, + }, + + replacementSideId: { + default: '', + rendered: false, + }, + + overlapParentId: { + default: '', + rendered: false, + }, + + sourceIds: { + default: null, + rendered: false, + }, + + origin: { + default: '', + rendered: false, + }, }; }, diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-format.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-format.js index 36da848685..fd37c7bc2d 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/track-format.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-format.js @@ -44,6 +44,11 @@ export const TrackFormat = Mark.create({ }, }, + authorId: { + default: '', + rendered: false, + }, + authorEmail: { default: '', parseDOM: (elem) => elem.getAttribute('data-authoremail'), @@ -120,6 +125,49 @@ export const TrackFormat = Mark.create({ default: '', rendered: false, }, + + // Review graph metadata. See track-insert.js for the rationale — + // never DOM-rendered, optional, inferred for older marks. + + revisionGroupId: { + default: '', + rendered: false, + }, + + splitFromId: { + default: '', + rendered: false, + }, + + changeType: { + default: '', + rendered: false, + }, + + replacementGroupId: { + default: '', + rendered: false, + }, + + replacementSideId: { + default: '', + rendered: false, + }, + + overlapParentId: { + default: '', + rendered: false, + }, + + sourceIds: { + default: null, + rendered: false, + }, + + origin: { + default: '', + rendered: false, + }, }; }, diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-insert.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-insert.js index f66f369cef..9bb5178d83 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/track-insert.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-insert.js @@ -43,6 +43,11 @@ export const TrackInsert = Mark.create({ }, }, + authorId: { + default: '', + rendered: false, + }, + authorEmail: { default: '', parseDOM: (elem) => elem.getAttribute('data-authoremail'), @@ -85,6 +90,56 @@ export const TrackInsert = Mark.create({ default: '', rendered: false, }, + + // Review graph metadata. + // These optional persisted attrs carry logical review graph + // state. They are never rendered as DOM attributes — graph metadata is + // not visual state. Compatibility: older marks omit them and the graph + // builder infers values from mark type and sibling adjacency. + + revisionGroupId: { + default: '', + rendered: false, + }, + + splitFromId: { + default: '', + rendered: false, + }, + + changeType: { + default: '', + rendered: false, + }, + + replacementGroupId: { + default: '', + rendered: false, + }, + + replacementSideId: { + default: '', + rendered: false, + }, + + overlapParentId: { + default: '', + rendered: false, + }, + + // Deterministic JSON object carrying raw Word ids / rsids when present. + // Empty object is the canonical default at the graph level; on the mark + // we store `null` so adjacent marks without source ids do not differ by + // missing-vs-empty defaults during PM mark.eq comparison. + sourceIds: { + default: null, + rendered: false, + }, + + origin: { + default: '', + rendered: false, + }, }; }, diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/addMarkStep.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/addMarkStep.js index c169b4a08b..67317bc55b 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/addMarkStep.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/addMarkStep.js @@ -1,17 +1,9 @@ // @ts-check -import { TrackDeleteMarkName, TrackFormatMarkName, TrackedFormatMarkNames } from '../constants.js'; -import { v4 as uuidv4 } from 'uuid'; +import { TrackDeleteMarkName, TrackedFormatMarkNames } from '../constants.js'; import { TrackChangesBasePluginKey } from '../plugins/trackChangesBasePlugin.js'; import { CommentsPluginKey } from '../../comment/comments-plugin.js'; -import { - hasMatchingMark, - markSnapshotMatchesStepMark, - upsertMarkSnapshotByType, - isTrackFormatNoOp, - getTypeName, - createMarkSnapshot, -} from './markSnapshotHelpers.js'; -import { getLiveInlineMarksInRange } from './getLiveInlineMarksInRange.js'; +import { compileTrackedEdit } from '../review-model/overlap-compiler.js'; +import { makeFormatIntent } from '../review-model/edit-intent.js'; /** * Add mark step. @@ -24,10 +16,39 @@ import { getLiveInlineMarksInRange } from './getLiveInlineMarksInRange.js'; * @param {string} options.date Date. */ export const addMarkStep = ({ state, step, newTr, doc, user, date }) => { - /** @type {{ formatMark?: import('prosemirror-model').Mark, step?: import('prosemirror-transform').AddMarkStep }} */ - const meta = {}; - /** @type {string | null} */ - let sharedWid = null; + if (TrackedFormatMarkNames.includes(step.mark.type.name)) { + const intentUser = { + name: user?.name || '', + email: user?.email || '', + image: user?.image || '', + }; + const intent = makeFormatIntent({ + kind: 'format-apply', + from: step.from, + to: step.to, + mark: step.mark, + user: intentUser, + date, + source: 'native', + }); + const result = compileTrackedEdit({ + state, + tr: newTr, + intent, + }); + if (result.ok === true) { + if (result.formatMarks?.length) { + newTr.setMeta(TrackChangesBasePluginKey, { + formatMark: result.formatMarks[0], + step, + }); + } + newTr.setMeta(CommentsPluginKey, { type: 'force' }); + return; + } + // Fail closed for tracked formatting; do not silently apply untracked. + return; + } doc.nodesBetween(step.from, step.to, (node, pos) => { if (!node.isInline || node.type.name === 'run') { @@ -38,93 +59,6 @@ export const addMarkStep = ({ state, step, newTr, doc, user, date }) => { return false; } - const rangeFrom = Math.max(step.from, pos); - const rangeTo = Math.min(step.to, pos + node.nodeSize); - - /** @type {import('prosemirror-model').Mark[]} */ - const liveMarks = getLiveInlineMarksInRange({ - doc: newTr.doc, - from: rangeFrom, - to: rangeTo, - }); - const existingChangeMark = liveMarks.find((mark) => - [TrackDeleteMarkName, TrackFormatMarkName].includes(mark.type.name), - ); - const wid = existingChangeMark ? existingChangeMark.attrs.id : (sharedWid ?? (sharedWid = uuidv4())); newTr.addMark(Math.max(step.from, pos), Math.min(step.to, pos + node.nodeSize), step.mark); - - if (TrackedFormatMarkNames.includes(step.mark.type.name) && !hasMatchingMark(liveMarks, step.mark)) { - const formatChangeMark = liveMarks.find((mark) => mark.type.name === TrackFormatMarkName); - - /** @type {{ type?: string, attrs?: Record }[]} */ - let after = []; - /** @type {{ type?: string, attrs?: Record }[]} */ - let before = []; - - if (formatChangeMark) { - const beforeSnapshots = /** @type {{ type?: string, attrs?: Record }[]} */ ( - formatChangeMark.attrs.before || [] - ); - const afterSnapshots = /** @type {{ type?: string, attrs?: Record }[]} */ ( - formatChangeMark.attrs.after || [] - ); - let foundBefore = beforeSnapshots.find((mark) => markSnapshotMatchesStepMark(mark, step.mark, true)); - - if (foundBefore) { - before = [...beforeSnapshots.filter((mark) => !markSnapshotMatchesStepMark(mark, step.mark, true))]; - // The step restores the original mark for this type — remove the - // corresponding "after" entry since the change has been reverted. - after = afterSnapshots.filter((mark) => getTypeName(mark) !== step.mark.type.name); - } else { - before = [...beforeSnapshots]; - after = upsertMarkSnapshotByType(afterSnapshots, { - type: step.mark.type.name, - attrs: step.mark.attrs, - }); - } - } else { - const existingMarkOfSameType = liveMarks.find( - (mark) => - mark.type.name === step.mark.type.name && - ![TrackDeleteMarkName, TrackFormatMarkName].includes(mark.type.name), - ); - before = existingMarkOfSameType - ? [createMarkSnapshot(existingMarkOfSameType.type.name, existingMarkOfSameType.attrs)] - : []; - - after = [createMarkSnapshot(step.mark.type.name, step.mark.attrs)]; - } - - // Check if the format change is effectively a no-op (e.g., reverting - // vertAlign to 'baseline' when the original had no vertAlign). - if (isTrackFormatNoOp(before, after)) { - if (formatChangeMark) { - newTr.removeMark(Math.max(step.from, pos), Math.min(step.to, pos + node.nodeSize), formatChangeMark); - } - return; - } - - if (after.length || before.length) { - const newFormatMark = state.schema.marks[TrackFormatMarkName].create({ - id: wid, - sourceId: formatChangeMark?.attrs?.sourceId || '', - author: user.name || '', - authorEmail: user.email || '', - authorImage: user.image || '', - date, - before, - after, - }); - newTr.addMark(Math.max(step.from, pos), Math.min(step.to, pos + node.nodeSize), newFormatMark); - - meta.formatMark = newFormatMark; - meta.step = step; - - newTr.setMeta(TrackChangesBasePluginKey, meta); - newTr.setMeta(CommentsPluginKey, { type: 'force' }); - } else if (formatChangeMark) { - newTr.removeMark(Math.max(step.from, pos), Math.min(step.to, pos + node.nodeSize), formatChangeMark); - } - } }); }; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.js index 449ff7d4bb..7c89f7175b 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.js @@ -16,6 +16,8 @@ * @param {import('./types.js').Attrs} [args.attrs] - Partial attrs * to match; every key listed must equal the candidate's attr value. * Defaults to `{}` (no attr constraint). + * @param {((mark: import('./types.js').PmMark) => boolean) | null} [args.predicate] - + * Additional predicate a candidate mark must satisfy. * @param {number} [args.offset] - Expand the range by this many * positions on each side. Defaults to `1` to catch non-inclusive marks. * @returns {import('./types.js').TrackedMarkRange | null} The first @@ -27,6 +29,7 @@ export const findTrackedMarkBetween = ({ to, markName, attrs = {}, + predicate = null, offset = 1, // To get non-inclusive marks. }) => { const { doc } = tr; @@ -43,7 +46,10 @@ export const findTrackedMarkBetween = ({ } const mark = node.marks?.find( - (mark) => mark.type.name === markName && Object.keys(attrs).every((attr) => mark.attrs[attr] === attrs[attr]), + (mark) => + mark.type.name === markName && + Object.keys(attrs).every((attr) => mark.attrs[attr] === attrs[attr]) && + (typeof predicate !== 'function' || predicate(mark)), ); if (mark && !markFound) { diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/getTrackChanges.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/getTrackChanges.js index bc01183934..ee8ca8756b 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/getTrackChanges.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/getTrackChanges.js @@ -37,6 +37,7 @@ export const getTrackChanges = (state, id = null) => { if (trackedMarks.includes(mark.type.name)) { trackedChanges.push({ mark, + node, from: pos, to: pos + node.nodeSize, }); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/markDeletion.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/markDeletion.js index 3a42230e43..6b2f2e14fb 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/markDeletion.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/markDeletion.js @@ -4,6 +4,12 @@ import { Slice } from 'prosemirror-model'; import { v4 as uuidv4 } from 'uuid'; import { TrackDeleteMarkName, TrackInsertMarkName } from '../constants.js'; import { findTrackedMarkBetween } from './findTrackedMarkBetween.js'; +import { + getCurrentUserIdentity, + getChangeAuthorIdentity, + matchesSameUserRefinement, + shouldCollapseNoEmailInsertion, +} from '../review-model/identity.js'; /** * Mark deletion. @@ -17,19 +23,20 @@ import { findTrackedMarkBetween } from './findTrackedMarkBetween.js'; * @returns {{ deletionMark: import('prosemirror-model').Mark, deletionMap: Mapping, nodes: import('prosemirror-model').Node[] }} Deletion map and deletion mark. */ export const markDeletion = ({ tr, from, to, user, date, id: providedId }) => { - /** - * @param {unknown} value - */ - const normalizeEmail = (value) => (typeof value === 'string' ? value.trim().toLowerCase() : ''); - const userEmail = normalizeEmail(user?.email); + const currentIdentity = getCurrentUserIdentity({ options: { user } }); /** * @param {import('prosemirror-model').Mark | null | undefined} mark */ const isOwnInsertion = (mark) => { - const authorEmail = normalizeEmail(mark?.attrs?.authorEmail); - // Word imports often omit authorEmail, treat missing as "own" to allow deletion. - if (!authorEmail || !userEmail) return true; - return authorEmail === userEmail; + const changeIdentity = getChangeAuthorIdentity(mark); + if (matchesSameUserRefinement({ currentUser: currentIdentity, change: changeIdentity })) return true; + // No-email imported insertions collapse only when truly unattributed, or + // when their no-email display name matches the current user. A named + // different author with no email remains protected review state. + if (!changeIdentity.hasId && !changeIdentity.hasEmail) { + return shouldCollapseNoEmailInsertion({ currentUser: user, insertionAttrs: mark?.attrs }); + } + return false; }; const trackedMark = @@ -39,7 +46,11 @@ export const markDeletion = ({ tr, from, to, user, date, id: providedId }) => { from, to, markName: TrackDeleteMarkName, - attrs: { authorEmail: user.email || '' }, + predicate: (mark) => + matchesSameUserRefinement({ + currentUser: currentIdentity, + change: getChangeAuthorIdentity(mark), + }), }) ); @@ -56,6 +67,7 @@ export const markDeletion = ({ tr, from, to, user, date, id: providedId }) => { const deletionMark = tr.doc.type.schema.marks[TrackDeleteMarkName].create({ id, author: user.name || '', + authorId: user.id || '', authorEmail: user.email || '', authorImage: user.image || '', date, diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/markInsertion.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/markInsertion.js index 52bd72f0fa..3528db19cd 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/markInsertion.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/markInsertion.js @@ -2,6 +2,11 @@ import { v4 as uuidv4 } from 'uuid'; import { TrackInsertMarkName, TrackDeleteMarkName } from '../constants.js'; import { findTrackedMarkBetween } from './findTrackedMarkBetween.js'; +import { + getCurrentUserIdentity, + getChangeAuthorIdentity, + matchesSameUserRefinement, +} from '../review-model/identity.js'; /** * Mark insertion. @@ -17,6 +22,7 @@ import { findTrackedMarkBetween } from './findTrackedMarkBetween.js'; export const markInsertion = ({ tr, from, to, user, date, id: providedId }) => { tr.removeMark(from, to, tr.doc.type.schema.marks[TrackDeleteMarkName]); tr.removeMark(from, to, tr.doc.type.schema.marks[TrackInsertMarkName]); + const currentIdentity = getCurrentUserIdentity({ options: { user } }); const trackedMark = /** @type {{ from: number, to: number, mark: import('prosemirror-model').Mark } | null | undefined} */ ( @@ -25,7 +31,11 @@ export const markInsertion = ({ tr, from, to, user, date, id: providedId }) => { from, to, markName: TrackInsertMarkName, - attrs: { authorEmail: user.email || '' }, + predicate: (mark) => + matchesSameUserRefinement({ + currentUser: currentIdentity, + change: getChangeAuthorIdentity(mark), + }), }) ); @@ -42,6 +52,7 @@ export const markInsertion = ({ tr, from, to, user, date, id: providedId }) => { const insertionMark = tr.doc.type.schema.marks[TrackInsertMarkName].create({ id, author: user.name || '', + authorId: user.id || '', authorEmail: user.email || '', authorImage: user.image || '', date, diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/removeMarkStep.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/removeMarkStep.js index 686788cbe0..f9eb9d5e6f 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/removeMarkStep.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/removeMarkStep.js @@ -1,14 +1,8 @@ -import { v4 as uuidv4 } from 'uuid'; -import { TrackDeleteMarkName, TrackFormatMarkName, TrackedFormatMarkNames } from '../constants.js'; +import { TrackDeleteMarkName, TrackedFormatMarkNames } from '../constants.js'; import { TrackChangesBasePluginKey } from '../plugins/trackChangesBasePlugin.js'; import { CommentsPluginKey } from '../../comment/comments-plugin.js'; -import { - createMarkSnapshot, - hasMatchingMark, - markSnapshotMatchesStepMark, - upsertMarkSnapshotByType, -} from './markSnapshotHelpers.js'; -import { getLiveInlineMarksInRange } from './getLiveInlineMarksInRange.js'; +import { compileTrackedEdit } from '../review-model/overlap-compiler.js'; +import { makeFormatIntent } from '../review-model/edit-intent.js'; /** * Remove mark step. @@ -21,8 +15,39 @@ import { getLiveInlineMarksInRange } from './getLiveInlineMarksInRange.js'; * @param {string} options.date Date. */ export const removeMarkStep = ({ state, step, newTr, doc, user, date }) => { - const meta = {}; - let sharedWid = null; + if (TrackedFormatMarkNames.includes(step.mark.type.name)) { + const intentUser = { + name: user?.name || '', + email: user?.email || '', + image: user?.image || '', + }; + const intent = makeFormatIntent({ + kind: 'format-remove', + from: step.from, + to: step.to, + mark: step.mark, + user: intentUser, + date, + source: 'native', + }); + const result = compileTrackedEdit({ + state, + tr: newTr, + intent, + }); + if (result.ok) { + if (result.formatMarks?.length) { + newTr.setMeta(TrackChangesBasePluginKey, { + formatMark: result.formatMarks[0], + step, + }); + } + newTr.setMeta(CommentsPluginKey, { type: 'force' }); + return; + } + // Fail closed for tracked formatting; do not silently apply untracked. + return; + } doc.nodesBetween(step.from, step.to, (node, pos) => { if (!node.isInline || node.type.name === 'run') { @@ -33,87 +58,6 @@ export const removeMarkStep = ({ state, step, newTr, doc, user, date }) => { return false; } - const rangeFrom = Math.max(step.from, pos); - const rangeTo = Math.min(step.to, pos + node.nodeSize); - const liveMarksBeforeRemove = getLiveInlineMarksInRange({ - doc: newTr.doc, - from: rangeFrom, - to: rangeTo, - }); newTr.removeMark(Math.max(step.from, pos), Math.min(step.to, pos + node.nodeSize), step.mark); - - if (TrackedFormatMarkNames.includes(step.mark.type.name) && hasMatchingMark(liveMarksBeforeRemove, step.mark)) { - const formatChangeMark = liveMarksBeforeRemove.find((mark) => mark.type.name === TrackFormatMarkName); - - let after = []; - let before = []; - - if (formatChangeMark) { - let foundAfter = formatChangeMark.attrs.after.find((mark) => - markSnapshotMatchesStepMark(mark, step.mark, true), - ); - - if (foundAfter) { - after = [ - ...formatChangeMark.attrs.after.filter((mark) => !markSnapshotMatchesStepMark(mark, step.mark, true)), - ]; - if (after.length === 0) { - // All additions were canceled. Check if any marks in `before` were - // actually removed from the node. If they all still exist, the - // tracked change is a no-op — clean it up. - const remainingFormatMarks = liveMarksBeforeRemove.filter( - (m) => - ![TrackDeleteMarkName, TrackFormatMarkName].includes(m.type.name) && - m.type.name !== step.mark.type.name, - ); - const isNoop = formatChangeMark.attrs.before.every((snapshot) => - remainingFormatMarks.some((m) => markSnapshotMatchesStepMark(snapshot, m, true)), - ); - if (isNoop) { - newTr.removeMark(Math.max(step.from, pos), Math.min(step.to, pos + node.nodeSize), formatChangeMark); - return; - } - } - before = [...formatChangeMark.attrs.before]; - } else { - after = [...formatChangeMark.attrs.after]; - before = upsertMarkSnapshotByType(formatChangeMark.attrs.before, { - type: step.mark.type.name, - attrs: step.mark.attrs, - }); - } - } else { - after = []; - let existingMark = node.marks.find((mark) => mark.type === step.mark.type); - if (existingMark) { - before = [createMarkSnapshot(step.mark.type.name, existingMark.attrs)]; - } else { - before = []; - } - } - - if (after.length || before.length) { - const newFormatMark = state.schema.marks[TrackFormatMarkName].create({ - id: formatChangeMark ? formatChangeMark.attrs.id : (sharedWid ?? (sharedWid = uuidv4())), - sourceId: formatChangeMark?.attrs?.sourceId || '', - author: user.name || '', - authorEmail: user.email || '', - authorImage: user.image || '', - date, - before, - after, - }); - - newTr.addMark(Math.max(step.from, pos), Math.min(step.to, pos + node.nodeSize), newFormatMark); - - meta.formatMark = newFormatMark; - meta.step = step; - - newTr.setMeta(TrackChangesBasePluginKey, meta); - newTr.setMeta(CommentsPluginKey, { type: 'force' }); - } else if (formatChangeMark) { - newTr.removeMark(Math.max(step.from, pos), Math.min(step.to, pos + node.nodeSize), formatChangeMark); - } - } }); }; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceAroundStep.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceAroundStep.js index 80ba605bd9..4981e8be24 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceAroundStep.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceAroundStep.js @@ -3,6 +3,7 @@ import { Slice } from 'prosemirror-model'; import { replaceStep } from './replaceStep.js'; import { TrackDeleteMarkName } from '../constants.js'; import { TrackChangesBasePluginKey } from '../plugins/index.js'; +import { getChangeAuthorIdentity, matchesSameUserRefinement } from '../review-model/identity.js'; /** * Check whether the enclosing structural scope (listItem, or paragraph @@ -117,6 +118,7 @@ const findPreviousLiveCharPos = (doc, cursorPos, trackDeleteMarkType) => { * @param {string} options.date * @param {import('prosemirror-transform').Step} options.originalStep * @param {number} options.originalStepIndex + * @param {'paired' | 'independent'} [options.replacements] */ export const replaceAroundStep = ({ state, @@ -129,6 +131,7 @@ export const replaceAroundStep = ({ date, originalStep, originalStepIndex, + replacements = 'paired', }) => { // Diff replay uses forceTrackChanges for consistency, but structural metadata updates // (e.g. table style setNodeMarkup) are encoded as ReplaceAroundStep and cannot be @@ -202,6 +205,7 @@ export const replaceAroundStep = ({ date, originalStep: charStep, originalStepIndex, + replacements, }); // Position the cursor at the deletion edge. The original transaction's @@ -220,8 +224,8 @@ export const replaceAroundStep = ({ // earlier deletion with the current ID so they merge into a single tracked change. if (trackMeta.deletionMark) { const ourId = trackMeta.deletionMark.attrs.id; - const ourEmail = trackMeta.deletionMark.attrs.authorEmail; const ourDate = trackMeta.deletionMark.attrs.date; + const ourIdentity = getChangeAuthorIdentity(trackMeta.deletionMark); const searchTo = Math.min(newTr.doc.content.size, deleteFrom + 20); let contiguous = true; @@ -233,7 +237,11 @@ export const replaceAroundStep = ({ contiguous = false; // Live text — stop, deletions are no longer contiguous. return; } - if (delMark.attrs.id !== ourId && delMark.attrs.authorEmail === ourEmail && delMark.attrs.date === ourDate) { + const isSameActor = matchesSameUserRefinement({ + currentUser: ourIdentity, + change: getChangeAuthorIdentity(delMark), + }); + if (delMark.attrs.id !== ourId && isSameActor && delMark.attrs.date === ourDate) { const markType = state.schema.marks[TrackDeleteMarkName]; const merged = markType.create({ ...delMark.attrs, id: ourId }); newTr.removeMark(pos, pos + node.nodeSize, delMark); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceStep.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceStep.js index 3791b3fa16..e3134c7a8f 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceStep.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/replaceStep.js @@ -1,12 +1,9 @@ import { ReplaceStep } from 'prosemirror-transform'; import { Slice } from 'prosemirror-model'; -import { Selection, TextSelection } from 'prosemirror-state'; -import { markInsertion } from './markInsertion.js'; -import { markDeletion } from './markDeletion.js'; -import { TrackDeleteMarkName } from '../constants.js'; import { TrackChangesBasePluginKey } from '../plugins/index.js'; import { CommentsPluginKey } from '../../comment/comments-plugin.js'; -import { findMarkPosition } from './documentHelpers.js'; +import { compileTrackedEdit } from '../review-model/overlap-compiler.js'; +import { makeTextInsertIntent, makeTextDeleteIntent, makeTextReplaceIntent } from '../review-model/edit-intent.js'; /** * Given a range (from..to) and a count of characters ("the Nth character in that range"), @@ -173,15 +170,36 @@ export const replaceStep = ({ step.to !== originalRange.to || step.slice.content.size !== originalRange.sliceSize; + const compiled = tryCompileStep({ + state, + tr, + newTr, + step, + stepWasNormalized, + originalStep, + originalStepIndex, + map, + user, + date, + replacements, + }); + if (compiled.handled) { + return; + } + if (compiled.failed) { + // Do not fall through to applying the original step untracked. + return; + } + // Handle structural deletions with no inline content (e.g., empty paragraph removal, // paragraph joins). When there's no content being inserted and no inline content in - // the deletion range, markDeletion has nothing to mark — apply the step directly. + // the deletion range, there is no tracked inline content for the compiler to mark, so + // apply the structural step directly. // // Edge case: if a paragraph contains only TrackDelete-marked text, hasInlineContent - // returns true and the normal tracking flow runs. markDeletion skips already-deleted - // nodes, but the join still applies through the replace machinery — the delete is - // not swallowed. This is correct: the structural join merges the blocks while - // preserving the existing deletion marks on the text content. + // returns true and the compiler path runs. If the compiler cannot represent that + // mixed text/structure operation, the edit fails closed rather than applying + // untracked content. if (step.from !== step.to && step.slice.content.size === 0) { let hasInlineContent = false; newTr.doc.nodesBetween(step.from, step.to, (node) => { @@ -198,201 +216,174 @@ export const replaceStep = ({ return; } } + // Every text-shaped tracked edit must be represented by compileTrackedEdit. + // If the compiler declined and the structural branch above did not apply, + // fail closed instead of keeping a second tracked-write implementation here. +}; - const trTemp = state.apply(newTr).tr; - - // Default: insert replacement after the selected range (Word-like replace behavior). - // If the selection ends inside an existing deletion, move insertion to after that deletion span. - // NOTE: Only adjust position for single-step transactions. Multi-step transactions (like input rules) - // have subsequent steps that depend on original positions, and adjusting breaks their mapping. - let positionTo = step.to; - const isSingleStep = tr.steps.length === 1; - - if (isSingleStep) { - const probePos = Math.max(step.from, step.to - 1); - const deletionSpan = findMarkPosition(trTemp.doc, probePos, TrackDeleteMarkName); - if (deletionSpan && deletionSpan.to > positionTo) { - positionTo = deletionSpan.to; - } +/** + * Try to route a text-shaped ReplaceStep through the overlap-aware compiler. + * + * Returns one of: + * - `{ handled: true }` — compiler applied the edit; caller must return. + * - `{ failed: true }` — compiler aborted (typed failure); caller must + * NOT fall back to the original untracked step. + * - `{ handled: false }` — compiler declined (e.g. structural step + * without inline content). Caller may run the + * narrow structural fallback below. + * + * @param {{ state: import('prosemirror-state').EditorState, tr: import('prosemirror-state').Transaction, newTr: import('prosemirror-state').Transaction, step: import('prosemirror-transform').ReplaceStep, stepWasNormalized: boolean, originalStep: import('prosemirror-transform').ReplaceStep, map: import('prosemirror-transform').Mapping, user: object, date: string, replacements: 'paired'|'independent' }} options + */ +const tryCompileStep = ({ + state, + tr, + newTr, + step, + stepWasNormalized, + originalStep, + originalStepIndex, + map, + user, + date, + replacements, +}) => { + // Empty structural deletion handled by the structural branch above. + if (step.from !== step.to && step.slice.content.size === 0) { + let hasInlineContent = false; + newTr.doc.nodesBetween(step.from, step.to, (node) => { + if (node.isInline) { + hasInlineContent = true; + return false; + } + }); + if (!hasInlineContent) return { handled: false }; } - // When pasting into a textblock, try the open slice first so content merges inline - // instead of creating new paragraphs (prevents inserting block nodes into non-textblocks). - const baseParentIsTextblock = trTemp.doc.resolve(positionTo).parent?.isTextblock; - const shouldPreferInlineInsertion = step.from === step.to && baseParentIsTextblock; - - const tryInsert = (slice) => { - const tempTr = state.apply(newTr).tr; - // Empty slices represent pure deletions (no content to insert). - // Detecting them ensures deletion tracking runs even if `tempTr` doesn't change. - const isEmptySlice = slice?.content?.size === 0; - try { - tempTr.replaceRange(positionTo, positionTo, slice); - } catch { - return null; + // Build the intent. Pure inserts and pure deletes use the matching intent + // type; mixed (text-replace) carries the original slice. + let intent; + try { + const preserveExistingReviewState = tr.getMeta('protectTrackedReviewState') === true; + if (step.from === step.to && step.slice.content.size > 0) { + intent = makeTextInsertIntent({ + at: step.from, + content: step.slice, + user, + date, + source: 'native', + preserveExistingReviewState, + }); + } else if (step.from !== step.to && step.slice.content.size === 0) { + intent = makeTextDeleteIntent({ + from: step.from, + to: step.to, + user, + date, + source: 'native', + preserveExistingReviewState, + }); + } else if (step.from !== step.to && step.slice.content.size > 0) { + intent = makeTextReplaceIntent({ + from: step.from, + to: step.to, + content: step.slice, + replacements, + user, + date, + source: 'native', + preserveExistingReviewState, + }); + // Single-step user actions (text replace from one ReplaceStep) probe + // for adjacent tracked-delete spans so insertion lands past the + // strike-through content. Multi-step transactions (input rules, + // plan-engine multi-op rewrites) must not probe. + if (tr.steps.length === 1) /** @type {any} */ (intent).probeForDeletionSpan = true; + } else { + // Zero-op step; nothing to compile. + return { handled: false }; } + } catch (error) { + return { failed: true, error }; + } - if (!tempTr.docChanged && !isEmptySlice) return null; + const beforeSize = newTr.doc.content.size; + const beforeSteps = newTr.steps.length; + const newTrDocBeforeCompile = newTr.doc; + const result = compileTrackedEdit({ + state, + tr: newTr, + intent, + replacements, + }); - const insertedFrom = tempTr.mapping.map(positionTo, -1); - const insertedTo = tempTr.mapping.map(positionTo, 1); - if (insertedFrom === insertedTo) return { tempTr, insertedFrom, insertedTo }; - if (shouldPreferInlineInsertion && !tempTr.doc.resolve(insertedFrom).parent?.isTextblock) return null; - return { tempTr, insertedFrom, insertedTo }; - }; + if (!result.ok) { + return { failed: true, error: new Error(result.message) }; + } - const openSlice = Slice.maxOpen(step.slice.content, true); - const insertion = tryInsert(step.slice) || tryInsert(openSlice); + // Update the outer mapping (`map`) so subsequent original steps in the + // same transaction remap correctly into newTr.doc space. We didn't apply + // the original step in its original place (we applied a condensed insert + // at positionTo plus delete marks). For trackedTransaction's + // `originalStep.map(map)` to land subsequent steps where the user expected, + // the outer map must encode the original step's user-view position effect. + // Mirror the legacy invert+condensed dance: append the inverse of the + // source step (cancels the original step's expected map) then mirror-append + // the compiled steps (what we actually did to newTr). + const invertSourceStep = stepWasNormalized ? step : originalStep; + const invertSourceDoc = stepWasNormalized ? newTrDocBeforeCompile : tr.docs[originalStepIndex]; + let invertStep; + try { + invertStep = stepWasNormalized + ? invertSourceStep.invert(invertSourceDoc) + : invertSourceStep.invert(invertSourceDoc).map(map); + } catch { + invertStep = null; + } - // If we can't insert the replacement content into the temp transaction, fall back to applying the original step. - // This keeps user intent (content change) even if we can't represent it as tracked insert+delete. - if (!insertion) { - if (!newTr.maybeStep(step).failed) { - map.appendMap(step.getMap()); + if (invertStep) { + map.appendMap(invertStep.getMap()); + const mirrorIndex = map.maps.length - 1; + for (let i = beforeSteps; i < newTr.steps.length; i += 1) { + map.appendMap(newTr.steps[i].getMap(), mirrorIndex); + } + } else { + for (let i = beforeSteps; i < newTr.steps.length; i += 1) { + map.appendMap(newTr.steps[i].getMap()); } - return; } + // Build comments-plugin-shaped metadata directly from the compiler result + // so the bubble pipeline can derive inserted/deleted text immediately + // (without fake step.slice payloads). const meta = {}; - const { insertedFrom, insertedTo, tempTr } = insertion; - let insertedMark = null; - let trackedInsertedSlice = Slice.empty; - - if (insertedFrom !== insertedTo) { - insertedMark = markInsertion({ - tr: tempTr, - from: insertedFrom, - to: insertedTo, - user, - date, - }); - trackedInsertedSlice = tempTr.doc.slice(insertedFrom, insertedTo); + if (typeof result.insertedTo === 'number') { + meta.insertedTo = result.insertedTo; } - - // Condense insertion down to a single replace step (so this tracked transaction remains a single-step insertion). - const docBeforeCondensedStep = newTr.doc; - const condensedStep = new ReplaceStep(positionTo, positionTo, trackedInsertedSlice, false); - if (newTr.maybeStep(condensedStep).failed) { - // If the condensed step can't be applied, fall back to the original step and skip deletion tracking. - if (!newTr.maybeStep(step).failed) { - map.appendMap(step.getMap()); - } - return; + if (result.insertedMark) { + meta.insertedMark = result.insertedMark; } - - // We didn't apply the original step in its original place. We adjust the map accordingly. - // When stepWasNormalized is true, `step` is already in the mapped position space - // (originalStep.map(map) was applied before entering replaceStep). Calling .map(map) - // again would double-map positions and corrupt subsequent step/selection mapping - // in multi-step transactions. - const invertSourceStep = stepWasNormalized ? step : originalStep; - const invertSourceDoc = stepWasNormalized ? docBeforeCondensedStep : tr.docs[originalStepIndex]; - const invertStep = stepWasNormalized - ? invertSourceStep.invert(invertSourceDoc) - : invertSourceStep.invert(invertSourceDoc).map(map); - map.appendMap(invertStep.getMap()); - const mirrorIndex = map.maps.length - 1; - map.appendMap(condensedStep.getMap(), mirrorIndex); - - if (insertedFrom !== insertedTo) { - meta.insertedMark = insertedMark; - meta.step = condensedStep; - // Store insertion end position when (1) we adjusted the insertion position (e.g. past a - // deletion span), or (2) single-step replace of a range — selection mapping is wrong then - // so we need an explicit caret position. Skip for multi-step (e.g. input rules) so their - // intended selection is preserved. - const needInsertedTo = positionTo !== step.to || (isSingleStep && step.from !== step.to); - if (needInsertedTo) { - const insertionLength = insertedTo - insertedFrom; - meta.insertedTo = positionTo + insertionLength; - } + if (result.deletionMark) { + meta.deletionMark = result.deletionMark; + } else if (result.deletionMarks?.length) { + meta.deletionMark = result.deletionMarks[0]; } - - if (!newTr.selection.eq(tempTr.selection)) { - syncSelectionFromTransaction({ targetTr: newTr, sourceSelection: tempTr.selection }); + if (result.deletionNodes?.length) { + meta.deletionNodes = result.deletionNodes; } - - if (step.from !== step.to) { - const { - deletionMark, - deletionMap, - nodes: deletionNodes, - } = markDeletion({ - tr: newTr, - from: step.from, - to: step.to, - user, - date, - // SD-2607: in 'paired' mode (default), share the insertion's id so the - // two halves of a user-driven replacement resolve together. In - // 'independent' mode, pass undefined so markDeletion mints its own id - // — making the deletion an independent revision per ECMA-376 §17.13.5. - id: replacements === 'paired' ? meta.insertedMark?.attrs?.id : undefined, - }); - - meta.deletionNodes = deletionNodes; - meta.deletionMark = deletionMark; - - // Map insertedTo through deletionMap to account for position shifts from removing - // the user's own prior insertions (which markDeletion deletes instead of marking). - if (meta.insertedTo !== undefined) { - meta.insertedTo = deletionMap.map(meta.insertedTo, 1); - } - - // Normalized broad -> single-char deletions should keep the caret at the - // normalized deletion edge, not the original broad transaction selection. - // This avoids follow-up Backspace events targeting structural boundaries. - if (stepWasNormalized && !meta.insertedMark) { - meta.selectionPos = deletionMap.map(step.from, -1); - } - - map.appendMapping(deletionMap); + if (result.insertedMark && result.insertedStep) { + // Pass the real condensed ReplaceStep so the comments plugin can read + // step.slice.content (Fragment) just like the legacy code did. + meta.step = result.insertedStep; + } else if (result.insertedMark && result.insertedNodes?.length) { + // Compiler paths that don't produce a single condensed ReplaceStep — + // fall back to a shaped step the comments plugin already understands. + meta.step = { slice: { content: { content: result.insertedNodes } } }; + } + if (result.selection?.kind === 'near' && stepWasNormalized && !result.insertedMark) { + meta.selectionPos = result.selection.pos; } - - // Add meta to the new transaction. newTr.setMeta(TrackChangesBasePluginKey, meta); newTr.setMeta(CommentsPluginKey, { type: 'force' }); -}; - -/** - * Copies a selection from one transaction into another transaction that has a different - * document instance, while guaranteeing the resulting selection is valid for the target doc. - * - * ProseMirror selections are bound to a specific document object. Reusing a `Selection` - * created from another transaction can throw: - * `Selection passed to setSelection must point at the current document`. - * - * This helper performs a safe transfer strategy: - * 1. Clamp source selection positions to the target document bounds. - * 2. Recreate `TextSelection` directly on the target doc when possible. - * 3. If recreation fails (for example, target endpoints are no longer valid text positions), - * fall back to `Selection.near(...)` so caret placement still succeeds. - * 4. For non-text selections, use the same `Selection.near(...)` fallback. - * - * The intent is to preserve cursor location as closely as possible without ever throwing - * during tracked replay. - * - * @param {{ targetTr: import('prosemirror-state').Transaction, sourceSelection: import('prosemirror-state').Selection }} options - * @param {import('prosemirror-state').Transaction} options.targetTr - * Transaction that should receive the selection. The resulting selection is always created - * against `targetTr.doc`. - * @param {import('prosemirror-state').Selection} options.sourceSelection - * Selection taken from another transaction/document context. - * @returns {void} - */ -const syncSelectionFromTransaction = ({ targetTr, sourceSelection }) => { - const boundedFrom = Math.max(0, Math.min(sourceSelection.from, targetTr.doc.content.size)); - const boundedTo = Math.max(0, Math.min(sourceSelection.to, targetTr.doc.content.size)); - - if (sourceSelection instanceof TextSelection) { - try { - targetTr.setSelection(TextSelection.create(targetTr.doc, boundedFrom, boundedTo)); - return; - } catch { - targetTr.setSelection(Selection.near(targetTr.doc.resolve(boundedFrom), -1)); - return; - } - } - targetTr.setSelection(Selection.near(targetTr.doc.resolve(boundedFrom), -1)); + return { handled: true, sizeDelta: newTr.doc.content.size - beforeSize }; }; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackChangesHelpers.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackChangesHelpers.test.js index dcf77f01c9..b8be6965d3 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackChangesHelpers.test.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackChangesHelpers.test.js @@ -305,8 +305,8 @@ describe('trackChangesHelpers', () => { expect(plainHasOldDeleteId).toBe(false); }); - it('removes Word-imported insertions without authorEmail when deleted', () => { - const insertXml = ` + it('removes unattributed Word-imported insertions without authorEmail when deleted', () => { + const insertXml = ` Inserted @@ -335,6 +335,42 @@ describe('trackChangesHelpers', () => { expect(finalState.doc.textContent).toBe(''); }); + it('preserves named Word-imported insertions without authorEmail when deleted by another user', () => { + const insertXml = ` + + Inserted + + `; + const nodes = parseXmlToJson(insertXml).elements; + const result = handleTrackChangeNode({ docx: {}, nodes, nodeListHandler: defaultNodeListHandler() }); + expect(result.nodes.length).toBe(1); + + const insertedMark = result.nodes?.[0]?.content?.[0]?.marks?.find((mark) => mark.type === TrackInsertMarkName); + expect(insertedMark).toBeDefined(); + expect(insertedMark.attrs?.author).toBe('Word Author'); + expect(insertedMark.attrs?.authorEmail).toBeUndefined(); + + const runNodes = result.nodes.map((node) => ProseMirrorNode.fromJSON(schema, node)); + const paragraph = schema.nodes.paragraph.create({}, runNodes); + const doc = schema.nodes.doc.create({}, paragraph); + const state = createState(doc); + + const textEntry = documentHelpers.findInlineNodes(state.doc).find(({ node }) => node.isText); + expect(textEntry).toBeDefined(); + + const deleteTr = state.tr.delete(textEntry.pos, textEntry.pos + textEntry.node.nodeSize); + deleteTr.setMeta('inputType', 'deleteContentBackward'); + const trackedDelete = trackedTransaction({ tr: deleteTr, state, user }); + const finalState = state.apply(trackedDelete); + + expect(finalState.doc.textContent).toBe('Inserted'); + const finalTextEntry = documentHelpers.findInlineNodes(finalState.doc).find(({ node }) => node.isText); + expect(finalTextEntry).toBeDefined(); + const markNames = finalTextEntry.node.marks.map((mark) => mark.type.name); + expect(markNames).toContain(TrackInsertMarkName); + expect(markNames).toContain(TrackDeleteMarkName); + }); + it('addMarkStep adds format mark metadata for styling changes', () => { const state = createState(createDocWithText('Format me')); const boldMark = schema.marks.bold.create(); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js index 5f1aadb789..dcdbc8f3ac 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js @@ -10,6 +10,11 @@ import { TrackDeleteMarkName, TrackInsertMarkName } from '../constants.js'; import { TrackChangesBasePluginKey } from '../plugins/index.js'; import { findMark } from '@core/helpers/index.js'; import { CommentsPluginKey } from '../../comment/comments-plugin.js'; +import { + getCurrentUserIdentity, + getChangeAuthorIdentity, + matchesSameUserRefinement, +} from '../review-model/identity.js'; const COMPOSITION_INPUT_TYPES = new Set(['insertCompositionText', 'deleteCompositionText']); const COMBINING_MARK_REGEX = /^\p{Mark}$/u; @@ -85,8 +90,14 @@ const getOwnedDeadKeyPlaceholderInfoAt = ({ doc, pos, user }) => { } const textNodeAtPos = getTextNodeAtPos({ doc, pos }); + const currentIdentity = getCurrentUserIdentity({ options: { user } }); const hasOwnTrackedInsert = textNodeAtPos?.node?.marks?.some( - (mark) => mark.type.name === TrackInsertMarkName && mark.attrs?.authorEmail === user.email, + (mark) => + mark.type.name === TrackInsertMarkName && + matchesSameUserRefinement({ + currentUser: currentIdentity, + change: getChangeAuthorIdentity(mark), + }), ); return hasOwnTrackedInsert ? { placeholderChar, combiningMark, textNodeAtPos } : null; @@ -303,6 +314,7 @@ const getPendingDeadKeyPlaceholder = ({ tr, newTr, user }) => { return { pos, placeholderChar: insertedText, + authorId: user.id, authorEmail: user.email, }; }; @@ -336,7 +348,12 @@ export const trackedTransaction = ({ tr, state, user, replacements = 'paired' }) const isProgrammaticInput = tr.getMeta('inputType') === 'programmatic'; const ySyncMeta = tr.getMeta(ySyncPluginKey); const pendingDeadKeyPlaceholder = TrackChangesBasePluginKey.getState(state)?.pendingDeadKeyPlaceholder ?? null; - const allowedMeta = new Set([...onlyInputTypeMeta, ySyncPluginKey.key, 'forceTrackChanges']); + const allowedMeta = new Set([ + ...onlyInputTypeMeta, + ySyncPluginKey.key, + 'forceTrackChanges', + 'protectTrackedReviewState', + ]); const hasDisallowedMeta = tr.meta && Object.keys(tr.meta).some((meta) => !allowedMeta.has(meta)); if ( @@ -415,6 +432,7 @@ export const trackedTransaction = ({ tr, state, user, replacements = 'paired' }) date, originalStep, originalStepIndex, + replacements, }); } else { // Non-structural steps (AttrStep, SetNodeMarkupStep) are typically diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/types.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/types.js index 05251a6b67..f28b01f8e4 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/types.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/types.js @@ -34,7 +34,7 @@ * @typedef {{ node: PmNode; pos: number }} NodePosEntry * The standard `findChildren` / `nodesBetween` result shape. * - * @typedef {{ from: number; to: number; mark: PmMark }} TrackedMarkRange + * @typedef {{ from: number; to: number; mark: PmMark; node?: PmNode }} TrackedMarkRange * A live ProseMirror mark located in a `[from, to]` document range. * Used as `findTrackedMarkBetween`'s non-null return and as the * element shape of `getTrackChanges`'s result array. diff --git a/packages/super-editor/src/editors/v1/extensions/types/comment-commands.ts b/packages/super-editor/src/editors/v1/extensions/types/comment-commands.ts index b642b5fc66..c41a37177d 100644 --- a/packages/super-editor/src/editors/v1/extensions/types/comment-commands.ts +++ b/packages/super-editor/src/editors/v1/extensions/types/comment-commands.ts @@ -12,6 +12,8 @@ export type AddCommentOptions = { commentId?: string; /** Author name (defaults to user from editor config) */ author?: string; + /** Stable actor id (defaults to user from editor config) */ + authorId?: string; /** Author email (defaults to user from editor config) */ authorEmail?: string; /** Author image URL (defaults to user from editor config) */ @@ -36,6 +38,8 @@ export type InsertCommentOptions = { commentText?: string; /** Comment creator name */ creatorName?: string; + /** Comment creator actor id */ + creatorId?: string; /** Comment creator email */ creatorEmail?: string; /** Comment creator image URL */ @@ -135,6 +139,8 @@ export type AddCommentReplyOptions = { content?: string; /** Author name (defaults to user from editor config) */ author?: string; + /** Stable actor id (defaults to user from editor config) */ + authorId?: string; /** Author email (defaults to user from editor config) */ authorEmail?: string; /** Author image URL (defaults to user from editor config) */ diff --git a/packages/super-editor/src/editors/v1/extensions/types/mark-attributes.ts b/packages/super-editor/src/editors/v1/extensions/types/mark-attributes.ts index ce4b81c25a..d9ec2761b6 100644 --- a/packages/super-editor/src/editors/v1/extensions/types/mark-attributes.ts +++ b/packages/super-editor/src/editors/v1/extensions/types/mark-attributes.ts @@ -180,6 +180,8 @@ export interface TrackInsertAttrs { id?: string; /** Author of the insertion */ author?: string; + /** Stable actor id of the author */ + authorId?: string; /** Author email */ authorEmail?: string; /** Author avatar/image */ @@ -196,6 +198,8 @@ export interface TrackDeleteAttrs { id?: string; /** Author of the deletion */ author?: string; + /** Stable actor id of the author */ + authorId?: string; /** Author email */ authorEmail?: string; /** Author avatar/image */ @@ -218,6 +222,8 @@ export interface TrackFormatAttrs { id?: string; /** Author of the format change */ author?: string; + /** Stable actor id of the author */ + authorId?: string; /** Author email */ authorEmail?: string; /** Author avatar/image */ diff --git a/packages/super-editor/src/editors/v1/tests/export/exportDocx.commentsFallback.test.js b/packages/super-editor/src/editors/v1/tests/export/exportDocx.commentsFallback.test.js index b3c09b0ffe..b69b1b7893 100644 --- a/packages/super-editor/src/editors/v1/tests/export/exportDocx.commentsFallback.test.js +++ b/packages/super-editor/src/editors/v1/tests/export/exportDocx.commentsFallback.test.js @@ -30,6 +30,15 @@ const FAKE_FROM_CALLER = [ }, ]; +const FAKE_FROM_TEXT_ONLY = [ + { + commentId: 'c-text-1', + creatorEmail: 'text@example.com', + creatorName: 'Text Only', + commentText: 'Hi from text only', + }, +]; + /** * Pins the engine-level contract that `SuperDoc.exportEditorsToDOCX` * relies on: @@ -110,6 +119,34 @@ describe('Editor.exportDocx() comments fallback contract', () => { } }); + it('caller passes commentText without commentJSON/elements → engine synthesizes exportable comment JSON', async () => { + const editor = await buildEditorWithImports(); + try { + const spy = vi.spyOn(editor.converter, 'exportToDocx').mockResolvedValue(Buffer.from('')); + await editor.exportDocx({ exportXmlOnly: true, comments: FAKE_FROM_TEXT_ONLY }); + expect(spy).toHaveBeenCalledTimes(1); + const passedComments = spy.mock.calls[0][5]; + expect(Array.isArray(passedComments)).toBe(true); + expect(passedComments[0]).toMatchObject({ + commentId: 'c-text-1', + commentText: 'Hi from text only', + commentJSON: [ + { + type: 'paragraph', + content: [ + { + type: 'run', + content: [{ type: 'text', text: 'Hi from text only' }], + }, + ], + }, + ], + }); + } finally { + editor.destroy(); + } + }); + it('null/undefined `converter.comments` resolves to [] (not a throw)', async () => { const editor = await Editor.open(undefined, { json: SAMPLE_JSON }); try { diff --git a/packages/super-editor/src/editors/v1/tests/import-export/trackChangesRoundtrip-overlap.test.js b/packages/super-editor/src/editors/v1/tests/import-export/trackChangesRoundtrip-overlap.test.js new file mode 100644 index 0000000000..9929d75dcf --- /dev/null +++ b/packages/super-editor/src/editors/v1/tests/import-export/trackChangesRoundtrip-overlap.test.js @@ -0,0 +1,414 @@ +// @ts-check +/** + * Phase 005 — repo-local critical tests for overlap DOCX export cleanliness. + * + * Plan: v1-3220 / phase0-005 "DOCX Metadata Storage Contract" + "Word + * Revision Id Allocation" + "Repo-Local Critical Tests Only". + * + * The plan forbids exporting SuperDoc-private metadata into DOCX. These + * tests prove that overlap-aware export: + * + * 1. exported `word/document.xml` contains only Word-native tracked-change + * wrappers (`w:ins`, `w:del`, `w:rPrChange`). + * 2. all `w:id` attributes on tracked-change wrappers are Word-compatible + * decimal strings. + * 3. imported decimal `w:id` values are preserved verbatim. + * 4. no SuperDoc-specific namespaces / custom XML parts appear anywhere in + * the exported package. + * + */ + +import { describe, it, expect } from 'vitest'; +import { loadTestDataForEditorTests, initTestEditor } from '../helpers/helpers.js'; +import { Editor } from '@core/Editor.js'; +import DocxZipper from '@core/DocxZipper.js'; +import { parseXmlToJson } from '@converter/v2/docxHelper.js'; +import { TRACKED_CHANGE_SOURCE_ID_MAP_PROPERTY } from '@extensions/track-changes/review-model/word-id-allocator.js'; + +const TRACK_NAMES = new Set(['w:ins', 'w:del']); +const FORMAT_REVISION_NAMES = new Set(['w:rPrChange', 'w:pPrChange']); +const FORBIDDEN_NAMESPACE_PREFIXES = ['sd:', 'sdrev:', 'superdoc:']; + +const visitNodes = (node, visit) => { + if (!node || typeof node !== 'object') return; + visit(node); + if (Array.isArray(node.elements)) node.elements.forEach((child) => visitNodes(child, visit)); +}; + +const collectTrackedWrappers = (body) => { + const tracked = []; + const formats = []; + visitNodes(body, (node) => { + if (TRACK_NAMES.has(node.name)) tracked.push(node); + if (FORMAT_REVISION_NAMES.has(node.name)) formats.push(node); + }); + return { tracked, formats }; +}; + +const allAttributeKeysFor = (body) => { + const keys = new Set(); + visitNodes(body, (node) => { + if (!node.attributes || typeof node.attributes !== 'object') return; + for (const key of Object.keys(node.attributes)) keys.add(key); + }); + return keys; +}; + +const allElementNames = (body) => { + const names = new Set(); + visitNodes(body, (node) => { + if (typeof node.name === 'string') names.add(node.name); + }); + return names; +}; + +const loadExportedPackage = async (exportedBuffer) => { + const zipper = new DocxZipper(); + const files = await zipper.getDocxData(exportedBuffer, true); + return files; +}; + +describe('overlap export — Word-native shape only', () => { + it('preserves imported decimal w:id values and emits no SuperDoc-private metadata', async () => { + const fileName = 'msword-tracked-changes.docx'; + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests(fileName); + const { editor } = await initTestEditor({ + content: docx, + media, + mediaFiles, + fonts, + isHeadless: true, + trackedChanges: {}, + }); + + try { + const exportedBuffer = await editor.exportDocx({ isFinalDoc: false }); + expect(exportedBuffer?.byteLength ?? exportedBuffer?.length).toBeGreaterThan(0); + + const exportedFiles = await loadExportedPackage(exportedBuffer); + + // Phase 005 — hard prohibitions: no SuperDoc-private custom XML parts + // and no review-graph sidecars must be present in the package. + const sdSidecar = exportedFiles.find((entry) => /customXml\/.*sd-tracked-review/i.test(entry.name)); + expect(sdSidecar, 'No customXml/sd-tracked-review.xml sidecar permitted').toBeUndefined(); + + const customXmlEntries = exportedFiles.filter((entry) => entry.name.startsWith('customXml/')); + for (const entry of customXmlEntries) { + // Any custom XML parts that survive round-trip must NOT carry the + // overlap review graph; surface that as a hard test failure by + // scanning for our internal attribute names. + expect(entry.content).not.toMatch(/sdrev:/); + expect(entry.content).not.toMatch(/sd-tracked-review/); + } + + const documentXmlEntry = exportedFiles.find((entry) => entry.name === 'word/document.xml'); + expect(documentXmlEntry).toBeDefined(); + + const documentJson = parseXmlToJson(documentXmlEntry.content); + const documentNode = documentJson.elements?.find((el) => el.name === 'w:document'); + const body = documentNode?.elements?.find((el) => el.name === 'w:body'); + expect(body).toBeDefined(); + + const { tracked, formats } = collectTrackedWrappers(body); + expect(tracked.length).toBeGreaterThan(0); + + // Phase 005 — every emitted `w:id` must be a Word-compatible decimal + // string. UUIDs / non-decimal sourceIds are not valid Word revision + // ids; the allocator mints fresh decimals for SuperDoc-native + // revisions while preserving imported decimals verbatim. + for (const node of tracked) { + const id = node.attributes?.['w:id']; + expect(id, `Tracked-change node missing w:id (${node.name})`).toBeDefined(); + expect(String(id)).toMatch(/^\d+$/); + } + + // Format revisions, when present, follow the same allocator rule. + for (const node of formats) { + const id = node.attributes?.['w:id']; + expect(id, `Format revision node missing w:id (${node.name})`).toBeDefined(); + expect(String(id)).toMatch(/^\d+$/); + } + + // Phase 005 — no SuperDoc-private attributes on tracked-change + // wrappers. The Word-native attribute set is `w:id`, `w:author`, + // `w:authorEmail`, `w:date`, plus optional `w:authorImage` (treated + // as Word-supported per converter convention). + // Word-standard tracked-change attributes. `w:rsid*` are + // revision-save ids that ship in untouched Word documents. + const allowedAttrs = new Set([ + 'w:id', + 'w:author', + 'w:authorEmail', + 'w:date', + 'w:authorImage', + 'w:rsidDel', + 'w:rsidR', + 'w:rsidRDefault', + 'w:rsidRPr', + 'w:rsidP', + 'w:rsidTr', + 'w:rsidSect', + ]); + for (const node of [...tracked, ...formats]) { + if (!node.attributes) continue; + for (const key of Object.keys(node.attributes)) { + // overlap internal attrs (`changeType`, `revisionGroupId`, + // `splitFromId`, etc.) live on PM marks only — never on OOXML. + expect(allowedAttrs.has(key), `Tracked-change wrapper carries non-Word attribute "${key}"`).toBe(true); + } + } + + // Phase 005 — no SuperDoc-private namespaces appear anywhere in the + // body. `sd:*`, `sdrev:*`, and `superdoc:*` are forbidden even on + // unrelated wrappers. + const allKeys = allAttributeKeysFor(body); + for (const key of allKeys) { + for (const banned of FORBIDDEN_NAMESPACE_PREFIXES) { + expect(key.startsWith(banned), `Body attribute "${key}" uses forbidden namespace ${banned}`).toBe(false); + } + } + + // Phase 005 — no SuperDoc-specific element names. + const allNames = allElementNames(body); + for (const name of allNames) { + for (const banned of FORBIDDEN_NAMESPACE_PREFIXES) { + expect(name.startsWith(banned), `Body element "${name}" uses forbidden namespace ${banned}`).toBe(false); + } + } + } finally { + editor.destroy(); + } + }); + + it('installs the Word id allocator by default', async () => { + const fileName = 'msword-tracked-changes.docx'; + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests(fileName); + const { editor } = await initTestEditor({ + content: docx, + media, + mediaFiles, + fonts, + isHeadless: true, + }); + + try { + const exportedBuffer = await editor.exportDocx({ isFinalDoc: false }); + const exportedFiles = await loadExportedPackage(exportedBuffer); + const documentXmlEntry = exportedFiles.find((entry) => entry.name === 'word/document.xml'); + expect(documentXmlEntry).toBeDefined(); + expect(editor.converter.wordIdAllocator).not.toBeNull(); + } finally { + editor.destroy(); + } + }); +}); + +describe('overlap export — allocator collision behavior', () => { + it('successor fragments mint ids that do not collide with preserved sourceIds', async () => { + // Build a synthetic document whose tracked marks carry preserved decimal + // sourceIds (1, 2) plus a native revision with no sourceId. The + // allocator must mint a fresh decimal id for the native revision + // that does not collide with the preserved set. + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests('blank-doc.docx'); + const { editor } = await initTestEditor({ + content: docx, + media, + mediaFiles, + fonts, + isHeadless: true, + trackedChanges: {}, + }); + + try { + const schema = editor.schema; + const baseDocJson = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'run', + content: [ + { + type: 'text', + text: 'imported', + marks: [ + { + type: 'trackInsert', + attrs: { + id: 'logical-a', + sourceId: '1', + author: 'Alice', + authorEmail: 'alice@example.com', + date: '2024-01-01T00:00:00Z', + }, + }, + ], + }, + ], + }, + { + type: 'run', + content: [ + { + type: 'text', + text: 'paired', + marks: [ + { + type: 'trackDelete', + attrs: { + id: 'logical-b', + sourceId: '2', + author: 'Alice', + authorEmail: 'alice@example.com', + date: '2024-01-01T00:00:00Z', + }, + }, + ], + }, + ], + }, + { + type: 'run', + content: [ + { + type: 'text', + text: 'native', + marks: [ + { + type: 'trackInsert', + attrs: { + id: 'logical-c', + sourceId: '', + author: 'Alice', + authorEmail: 'alice@example.com', + date: '2024-02-01T00:00:00Z', + }, + }, + ], + }, + ], + }, + ], + }, + ], + }; + const replacementDoc = schema.nodeFromJSON(baseDocJson); + const tx = editor.state.tr.replaceWith(0, editor.state.doc.content.size, replacementDoc.content); + editor.dispatch(tx); + + const exportedBuffer = await editor.exportDocx({ isFinalDoc: false }); + const files = await loadExportedPackage(exportedBuffer); + const documentXmlEntry = files.find((f) => f.name === 'word/document.xml'); + const documentJson = parseXmlToJson(documentXmlEntry.content); + const documentNode = documentJson.elements?.find((el) => el.name === 'w:document'); + const body = documentNode?.elements?.find((el) => el.name === 'w:body'); + + const { tracked } = collectTrackedWrappers(body); + const ids = tracked.map((node) => String(node.attributes?.['w:id'])); + + // Imported sourceIds preserved. + expect(ids).toContain('1'); + expect(ids).toContain('2'); + + // The native revision must NOT collide with the preserved set. + const nativeId = ids.find((id) => id !== '1' && id !== '2'); + expect(nativeId).toBeDefined(); + expect(nativeId).toMatch(/^\d+$/); + expect(nativeId).not.toBe('1'); + expect(nativeId).not.toBe('2'); + + // Every id is decimal. + for (const id of ids) { + expect(id).toMatch(/^\d+$/); + } + } finally { + editor.destroy(); + } + }); + + it('restores non-decimal sourceIds after Word-compatible export rewrites w:id', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests('blank-doc.docx'); + const { editor } = await initTestEditor({ + content: docx, + media, + mediaFiles, + fonts, + isHeadless: true, + trackedChanges: {}, + }); + let reopened; + + try { + const schema = editor.schema; + const originalSourceId = '77eb0a88-caef-402e-9329-ea504555afa3'; + const replacementDoc = schema.nodeFromJSON({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'run', + content: [ + { + type: 'text', + text: 'superdoc-origin', + marks: [ + { + type: 'trackInsert', + attrs: { + id: 'logical-superdoc-origin', + sourceId: originalSourceId, + author: 'Alice', + authorEmail: 'alice@example.com', + date: '2024-01-01T00:00:00Z', + }, + }, + ], + }, + ], + }, + ], + }, + ], + }); + editor.dispatch(editor.state.tr.replaceWith(0, editor.state.doc.content.size, replacementDoc.content)); + + const exportedBuffer = await editor.exportDocx({ isFinalDoc: false }); + const files = await loadExportedPackage(exportedBuffer); + const documentXmlEntry = files.find((f) => f.name === 'word/document.xml'); + expect(documentXmlEntry.content).toContain('w:id="1"'); + expect(documentXmlEntry.content).not.toContain(originalSourceId); + + const customXmlEntry = files.find((f) => f.name === 'docProps/custom.xml'); + expect(customXmlEntry.content).toContain(TRACKED_CHANGE_SOURCE_ID_MAP_PROPERTY); + expect(customXmlEntry.content).toContain(originalSourceId); + + const [roundtripDocx, roundtripMedia, roundtripMediaFiles, roundtripFonts] = await Editor.loadXmlData( + exportedBuffer, + true, + ); + ({ editor: reopened } = await initTestEditor({ + content: roundtripDocx, + media: roundtripMedia, + mediaFiles: roundtripMediaFiles, + fonts: roundtripFonts, + isHeadless: true, + trackedChanges: {}, + })); + + const sourceIds = []; + reopened.state.doc.descendants((node) => { + for (const mark of node.marks ?? []) { + if (mark.type.name === 'trackInsert') sourceIds.push(mark.attrs.sourceId); + } + }); + expect(sourceIds).toContain(originalSourceId); + } finally { + editor.destroy(); + reopened?.destroy(); + } + }); +}); diff --git a/packages/super-editor/src/editors/v1/utils/comment-content.ts b/packages/super-editor/src/editors/v1/utils/comment-content.ts new file mode 100644 index 0000000000..aee15f2cb9 --- /dev/null +++ b/packages/super-editor/src/editors/v1/utils/comment-content.ts @@ -0,0 +1,18 @@ +export function buildCommentJsonFromText(text: string): unknown[] { + const normalized = text.replace(/\r\n?/g, '\n'); + + return normalized.split('\n').map((paragraphText) => ({ + type: 'paragraph', + content: [ + { + type: 'run', + content: [ + { + type: 'text', + text: paragraphText, + }, + ], + }, + ], + })); +} diff --git a/packages/super-editor/src/ui/entity-at.test.ts b/packages/super-editor/src/ui/entity-at.test.ts index 1e42b6cd19..91eba0565d 100644 --- a/packages/super-editor/src/ui/entity-at.test.ts +++ b/packages/super-editor/src/ui/entity-at.test.ts @@ -17,6 +17,8 @@ import type { */ type ChainLayer = { trackChangeId?: string; + trackChangeIds?: string; + trackChangePreferredTargetId?: string; commentIds?: string; sdtId?: string; sdtType?: string; @@ -26,6 +28,10 @@ type ChainLayer = { function applyLayer(el: HTMLElement, layer: ChainLayer): void { if (layer.trackChangeId) el.dataset.trackChangeId = layer.trackChangeId; + if (layer.trackChangeIds) el.dataset.trackChangeIds = layer.trackChangeIds; + if (layer.trackChangePreferredTargetId) { + el.dataset.trackChangePreferredTargetId = layer.trackChangePreferredTargetId; + } if (layer.commentIds) el.dataset.commentIds = layer.commentIds; if (layer.sdtId) el.dataset.sdtId = layer.sdtId; if (layer.sdtType) el.dataset.sdtType = layer.sdtType; @@ -59,6 +65,40 @@ describe('collectEntityHitsFromChain', () => { ]); }); + it('orders the preferred tracked-change target before remaining multi-layer ids', () => { + const inner = buildPaintedChain([ + { trackChangeIds: 'ins-parent,del-child', trackChangePreferredTargetId: 'del-child' }, + ]); + + expect(collectEntityHitsFromChain(inner)).toEqual([ + { type: 'trackedChange', id: 'del-child' }, + { type: 'trackedChange', id: 'ins-parent' }, + ]); + }); + + it('falls back to legacy single tracked-change id data attributes', () => { + const inner = buildPaintedChain([{ trackChangeId: 'legacy-tc' }]); + + expect(collectEntityHitsFromChain(inner)).toEqual([{ type: 'trackedChange', id: 'legacy-tc' }]); + }); + + it('falls back to the legacy id when the multi-layer tracked-change list is empty', () => { + const inner = buildPaintedChain([{ trackChangeId: 'legacy-tc' }]); + inner.dataset.trackChangeIds = ',,'; + + expect(collectEntityHitsFromChain(inner)).toEqual([{ type: 'trackedChange', id: 'legacy-tc' }]); + }); + + it('skips empty tracked-change ids and deduplicates malformed comma lists across the chain', () => { + const inner = buildPaintedChain([{ trackChangeIds: ',tc-1,,tc-2,tc-1,' }, { trackChangeIds: 'tc-2,tc-3' }]); + + expect(collectEntityHitsFromChain(inner)).toEqual([ + { type: 'trackedChange', id: 'tc-1' }, + { type: 'trackedChange', id: 'tc-2' }, + { type: 'trackedChange', id: 'tc-3' }, + ]); + }); + it('expands comma-separated comment ids into one hit per id', () => { const inner = buildPaintedChain([{ commentIds: 'c-1,c-2,c-3' }]); @@ -85,6 +125,21 @@ describe('collectEntityHitsFromChain', () => { ]); }); + it('keeps inner multi-layer tracked changes before outer comments and content controls', () => { + const inner = buildPaintedChain([ + { trackChangeIds: 'ins-parent,del-child', trackChangePreferredTargetId: 'del-child' }, + { commentIds: 'c-outer' }, + { sdtId: 'sdt-outer', sdtType: 'structuredContent', sdtScope: 'inline' }, + ]); + + expect(collectEntityHitsFromChain(inner)).toEqual([ + { type: 'trackedChange', id: 'del-child' }, + { type: 'trackedChange', id: 'ins-parent' }, + { type: 'comment', id: 'c-outer' }, + { type: 'contentControl', id: 'sdt-outer', scope: 'inline' }, + ]); + }); + it('returns [] when the chain has no painted entities', () => { const inner = buildPaintedChain([{}]); diff --git a/packages/super-editor/src/ui/entity-at.ts b/packages/super-editor/src/ui/entity-at.ts index baa4841e96..c7862107f3 100644 --- a/packages/super-editor/src/ui/entity-at.ts +++ b/packages/super-editor/src/ui/entity-at.ts @@ -10,6 +10,29 @@ import type { ViewportEntityHit } from './types.js'; +function parseCommaSeparatedIds(value: string): string[] { + return value + .split(',') + .map((id) => id.trim()) + .filter(Boolean); +} + +function getTrackChangeIds(node: { getAttribute(name: string): string | null }): string[] { + const trackChangeIds = node.getAttribute('data-track-change-ids'); + if (trackChangeIds !== null) { + const ids = parseCommaSeparatedIds(trackChangeIds); + if (ids.length > 0) return ids; + } + + const trackChangeId = node.getAttribute('data-track-change-id'); + return trackChangeId ? [trackChangeId] : []; +} + +function orderTrackChangeIds(ids: string[], preferredTargetId: string | null): string[] { + if (!preferredTargetId || !ids.includes(preferredTargetId)) return ids; + return [preferredTargetId, ...ids.filter((id) => id !== preferredTargetId)]; +} + /** * Read painted entities off `el` and every ancestor up to the document * root. Innermost-first ordering: a tracked change inside a comment @@ -31,8 +54,11 @@ export function collectEntityHitsFromChain(start: Element | null): ViewportEntit let el: Element | null = start; while (el) { const node = el as { getAttribute(name: string): string | null }; - const trackChangeId = node.getAttribute('data-track-change-id'); - if (trackChangeId) { + const trackChangeIds = orderTrackChangeIds( + getTrackChangeIds(node), + node.getAttribute('data-track-change-preferred-target-id'), + ); + for (const trackChangeId of trackChangeIds) { const key = `trackedChange:${trackChangeId}`; if (!seen.has(key)) { seen.add(key); diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index b9df414235..67e6ef93fe 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -864,8 +864,13 @@ const REPLAY_MUTABLE_COMMENT_FIELDS = new Set([ 'trackedChangeType', 'trackedChangeText', 'trackedChangeDisplayType', + 'trackedChangeStory', + 'trackedChangeStoryKind', + 'trackedChangeStoryLabel', + 'trackedChangeAnchorKey', 'deletedText', 'resolvedTime', + 'resolvedById', 'resolvedByEmail', 'resolvedByName', 'importedAuthor', @@ -874,18 +879,27 @@ const REPLAY_MUTABLE_COMMENT_FIELDS = new Set([ const applyReplayIsDoneResolutionFallback = (target, payload = {}) => { if (!target || payload.isDone === undefined) return; - if (payload.resolvedTime != null || payload.resolvedByEmail != null || payload.resolvedByName != null) return; + if ( + payload.resolvedTime != null || + payload.resolvedById != null || + payload.resolvedByEmail != null || + payload.resolvedByName != null + ) { + return; + } // Imported replay payloads often use `isDone` while resolved fields remain null. // When resolved fields are not explicitly populated, derive sidebar/export state from `isDone`. if (payload.isDone) { target.resolvedTime = target.resolvedTime || Date.now(); + target.resolvedById = target.resolvedById || payload.creatorId || null; target.resolvedByEmail = target.resolvedByEmail || payload.creatorEmail || null; target.resolvedByName = target.resolvedByName || payload.creatorName || null; return; } target.resolvedTime = null; + target.resolvedById = null; target.resolvedByEmail = null; target.resolvedByName = null; }; @@ -1005,6 +1019,7 @@ const onEditorCommentsUpdate = (params = {}) => { const currentUser = proxy.$superdoc?.user; if (currentUser) { + if (!commentPayload.creatorId) commentPayload.creatorId = currentUser.id; if (!commentPayload.creatorName) commentPayload.creatorName = currentUser.name; if (!commentPayload.creatorEmail) commentPayload.creatorEmail = currentUser.email; } diff --git a/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue b/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue index 557a541eb8..6aac8e72c8 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue +++ b/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue @@ -609,6 +609,7 @@ const handleReject = () => { // disappears from getFloatingComments — even when a custom handler is used (SD-2049). if (props.comment.trackedChange) { props.comment.resolveComment({ + id: superdocStore.user.id, email: superdocStore.user.email, name: superdocStore.user.name, superdoc: proxy.$superdoc, @@ -642,6 +643,7 @@ const handleResolve = () => { // Always resolve so resolvedTime is set and the bubble disappears // from getFloatingComments — even when a custom handler is used (SD-2049). props.comment.resolveComment({ + id: superdocStore.user.id, email: superdocStore.user.email, name: superdocStore.user.name, superdoc: proxy.$superdoc, diff --git a/packages/superdoc/src/components/CommentsLayer/CommentHeader.test.js b/packages/superdoc/src/components/CommentsLayer/CommentHeader.test.js new file mode 100644 index 0000000000..5875ca3fb4 --- /dev/null +++ b/packages/superdoc/src/components/CommentsLayer/CommentHeader.test.js @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { defineComponent, h } from 'vue'; + +let commentsStoreStub; +let isAllowedMock; + +const { PERMISSIONS } = vi.hoisted(() => ({ + PERMISSIONS: { + RESOLVE_OWN: 'RESOLVE_OWN', + RESOLVE_OTHER: 'RESOLVE_OTHER', + REJECT_OWN: 'REJECT_OWN', + REJECT_OTHER: 'REJECT_OTHER', + COMMENTS_DELETE_OWN: 'COMMENTS_DELETE_OWN', + COMMENTS_DELETE_OTHER: 'COMMENTS_DELETE_OTHER', + }, +})); + +vi.mock('@superdoc/stores/comments-store', () => ({ + useCommentsStore: () => commentsStoreStub, +})); + +vi.mock('@superdoc/core/collaboration/permissions.js', () => ({ + PERMISSIONS, + isAllowed: (...args) => isAllowedMock(...args), +})); + +vi.mock('@superdoc/composables/useUiFontFamily.js', () => ({ + useUiFontFamily: () => ({ uiFontFamily: 'Test Sans' }), +})); + +vi.mock('@superdoc/components/general/Avatar.vue', () => ({ + default: defineComponent({ + name: 'AvatarStub', + props: ['user'], + setup(props) { + return () => h('div', { class: 'avatar-stub' }, props.user?.name ?? ''); + }, + }), +})); + +vi.mock('./CommentsDropdown.vue', () => ({ + default: defineComponent({ + name: 'CommentsDropdownStub', + props: ['options'], + setup(props, { slots }) { + return () => + h('div', { class: 'comments-dropdown-stub' }, [ + h('span', { class: 'options-labels' }, (props.options ?? []).map((option) => option.label).join(',')), + slots.default?.(), + ]); + }, + }), +})); + +import CommentHeader from './CommentHeader.vue'; + +const makeComment = (overrides = {}) => ({ + creatorId: 'alice-id', + creatorEmail: 'shared@example.com', + creatorName: 'Alice', + createdTime: Date.now(), + resolvedTime: null, + trackedChange: false, + parentCommentId: null, + trackedChangeParentId: null, + origin: null, + importedAuthor: null, + getCommentUser: () => ({ id: 'alice-id', name: 'Alice', email: 'shared@example.com' }), + ...overrides, +}); + +const mountHeader = ({ currentUser, comment, config = { readOnly: false } }) => + mount(CommentHeader, { + props: { + config, + comment, + isActive: true, + }, + global: { + config: { + globalProperties: { + $superdoc: { + config: { + role: 'editor', + isInternal: false, + user: currentUser, + }, + }, + }, + }, + }, + }); + +describe('CommentHeader.vue', () => { + beforeEach(() => { + commentsStoreStub = { + pendingComment: null, + }; + isAllowedMock = vi.fn((permission) => permission === PERMISSIONS.COMMENTS_DELETE_OWN); + }); + + it('does not treat same-email different-id comments as own comments', () => { + const wrapper = mountHeader({ + currentUser: { id: 'bob-id', email: 'shared@example.com', name: 'Bob' }, + comment: makeComment(), + }); + + expect(wrapper.find('.comments-dropdown-stub').exists()).toBe(false); + expect(isAllowedMock).toHaveBeenCalledWith( + PERMISSIONS.COMMENTS_DELETE_OTHER, + 'editor', + false, + expect.objectContaining({ comment: expect.any(Object) }), + ); + }); + + it('allows the anonymous default user to edit comments created in the same session', () => { + const wrapper = mountHeader({ + currentUser: { id: null, email: null, name: 'Default SuperDoc user' }, + comment: makeComment({ + creatorId: null, + creatorEmail: null, + creatorName: 'Default SuperDoc user', + getCommentUser: () => ({ id: null, name: 'Default SuperDoc user', email: null }), + }), + }); + + expect(wrapper.find('.options-labels').text()).toContain('Edit'); + }); + + it('keeps the imported tag for a different actor even when emails match', () => { + const wrapper = mountHeader({ + currentUser: { id: 'bob-id', email: 'shared@example.com', name: 'Bob' }, + comment: makeComment({ origin: 'word' }), + }); + + expect(wrapper.find('.imported-tag').exists()).toBe(true); + }); +}); diff --git a/packages/superdoc/src/components/CommentsLayer/CommentHeader.vue b/packages/superdoc/src/components/CommentsLayer/CommentHeader.vue index 40aab0b779..00c56453ef 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentHeader.vue +++ b/packages/superdoc/src/components/CommentsLayer/CommentHeader.vue @@ -2,6 +2,7 @@ import { formatDate } from './helpers'; import { superdocIcons } from '@superdoc/icons.js'; import { computed, getCurrentInstance } from 'vue'; +import { actorIdentitiesMatch, getActorIdentity, normalizeActorName } from '@superdoc/common'; import { isAllowed, PERMISSIONS } from '@superdoc/core/collaboration/permissions.js'; import { useCommentsStore } from '@superdoc/stores/comments-store'; import Avatar from '@superdoc/components/general/Avatar.vue'; @@ -37,7 +38,25 @@ const props = defineProps({ const { proxy } = getCurrentInstance(); const role = proxy.$superdoc.config.role; const isInternal = proxy.$superdoc.config.isInternal; -const isOwnComment = props.comment.creatorEmail === proxy.$superdoc.config.user.email; +const isCommentOwnedByCurrentUser = (comment) => { + const currentUser = proxy.$superdoc.config.user; + const otherUser = { id: comment?.creatorId, email: comment?.creatorEmail }; + if (actorIdentitiesMatch({ current: currentUser, other: otherUser })) return true; + + const currentIdentity = getActorIdentity(currentUser); + const otherIdentity = getActorIdentity(otherUser); + if (currentIdentity.hasId || currentIdentity.hasEmail || otherIdentity.hasId || otherIdentity.hasEmail) { + return false; + } + + const hasImportOrigin = comment?.origin != null || Boolean(comment?.importedAuthor?.name); + if (hasImportOrigin) return false; + + const currentName = normalizeActorName(currentUser?.name); + const commentName = normalizeActorName(comment?.creatorName); + return Boolean(currentName && commentName && currentName === commentName); +}; +const isOwnComment = computed(() => isCommentOwnedByCurrentUser(props.comment)); const { uiFontFamily } = useUiFontFamily(); @@ -70,7 +89,7 @@ const allowResolve = computed(() => { superdoc: proxy.$superdoc, }; - if (isOwnComment || props.comment.trackedChange) { + if (isOwnComment.value || props.comment.trackedChange) { return isAllowed(PERMISSIONS.RESOLVE_OWN, role, isInternal, context); } else { return isAllowed(PERMISSIONS.RESOLVE_OTHER, role, isInternal, context); @@ -87,7 +106,7 @@ const allowReject = computed(() => { superdoc: proxy.$superdoc, }; - if (isOwnComment || props.comment.trackedChange) { + if (isOwnComment.value || props.comment.trackedChange) { return isAllowed(PERMISSIONS.REJECT_OWN, role, isInternal, context); } else { return isAllowed(PERMISSIONS.REJECT_OTHER, role, isInternal, context); @@ -110,11 +129,11 @@ const getOverflowOptions = computed(() => { const options = new Set(); // Only the comment creator can edit, and only when comments aren't read-only - if (!props.config.readOnly && props.comment.creatorEmail === proxy.$superdoc.config.user.email) { + if (!props.config.readOnly && isCommentOwnedByCurrentUser(props.comment)) { options.add('edit'); } - const isOwnComment = props.comment.creatorEmail === proxy.$superdoc.config.user.email; + const isOwnComment = isCommentOwnedByCurrentUser(props.comment); const context = { comment: props.comment, @@ -144,8 +163,7 @@ const handleSelect = (value) => emit('overflow-select', value); const isImported = computed(() => { const hasImportOrigin = props.comment.origin != null || !!props.comment.importedAuthor?.name; if (!hasImportOrigin) return false; - const currentUserEmail = proxy.$superdoc.config.user?.email; - if (currentUserEmail && props.comment.creatorEmail === currentUserEmail) return false; + if (isCommentOwnedByCurrentUser(props.comment)) return false; return true; }); diff --git a/packages/superdoc/src/components/CommentsLayer/CommentsLayer.vue b/packages/superdoc/src/components/CommentsLayer/CommentsLayer.vue index 1794f9bafe..90576eb649 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentsLayer.vue +++ b/packages/superdoc/src/components/CommentsLayer/CommentsLayer.vue @@ -27,6 +27,7 @@ const props = defineProps({ const addCommentEntry = (selection) => { const params = { + creatorId: props.user.id, creatorEmail: props.user.email, creatorName: props.user.name, documentId: selection.documentId, diff --git a/packages/superdoc/src/components/CommentsLayer/comment-schemas.js b/packages/superdoc/src/components/CommentsLayer/comment-schemas.js index 27cf7670cd..e5947b254e 100644 --- a/packages/superdoc/src/components/CommentsLayer/comment-schemas.js +++ b/packages/superdoc/src/components/CommentsLayer/comment-schemas.js @@ -1,6 +1,7 @@ export const conversation = { conversationId: null, documentId: null, + creatorId: null, creatorEmail: null, creatorName: null, comments: [], @@ -10,6 +11,7 @@ export const conversation = { export const comment = { comment: null, user: { + id: null, name: null, email: null, }, diff --git a/packages/superdoc/src/components/CommentsLayer/comment-schemas.test.js b/packages/superdoc/src/components/CommentsLayer/comment-schemas.test.js index 3800899407..5f4001ebcc 100644 --- a/packages/superdoc/src/components/CommentsLayer/comment-schemas.test.js +++ b/packages/superdoc/src/components/CommentsLayer/comment-schemas.test.js @@ -6,6 +6,7 @@ describe('comment-schemas', () => { expect(conversation).toEqual({ conversationId: null, documentId: null, + creatorId: null, creatorEmail: null, creatorName: null, comments: [], @@ -16,7 +17,7 @@ describe('comment-schemas', () => { it('exposes a comment template with user/timestamp placeholders', () => { expect(comment).toEqual({ comment: null, - user: { name: null, email: null }, + user: { id: null, name: null, email: null }, timestamp: null, }); }); diff --git a/packages/superdoc/src/components/CommentsLayer/use-comment-extended.test.js b/packages/superdoc/src/components/CommentsLayer/use-comment-extended.test.js index 5eb320fe21..1b1a23cf83 100644 --- a/packages/superdoc/src/components/CommentsLayer/use-comment-extended.test.js +++ b/packages/superdoc/src/components/CommentsLayer/use-comment-extended.test.js @@ -27,7 +27,8 @@ describe('use-comment: extended coverage', () => { it('sets resolved fields and emits a resolved event', () => { const c = useComment({ commentId: 'c-1', fileId: 'doc-1' }); const superdoc = makeSuperdoc(); - c.resolveComment({ email: 'a@b.com', name: 'Alice', superdoc }); + c.resolveComment({ id: 'alice-id', email: 'a@b.com', name: 'Alice', superdoc }); + expect(c.resolvedById).toBe('alice-id'); expect(c.resolvedByEmail).toBe('a@b.com'); expect(c.resolvedByName).toBe('Alice'); expect(typeof c.resolvedTime).toBe('number'); @@ -41,7 +42,8 @@ describe('use-comment: extended coverage', () => { it('is a no-op when already resolved', () => { const c = useComment({ commentId: 'c-1', resolvedTime: 1234 }); const superdoc = makeSuperdoc(); - c.resolveComment({ email: 'a@b.com', name: 'Alice', superdoc }); + c.resolveComment({ id: 'alice-id', email: 'a@b.com', name: 'Alice', superdoc }); + expect(c.resolvedById).toBeNull(); expect(c.resolvedByEmail).toBeNull(); expect(superdoc.emit).not.toHaveBeenCalled(); }); @@ -49,7 +51,7 @@ describe('use-comment: extended coverage', () => { it('emits when tracked change is present (suggestion resolve path)', () => { const c = useComment({ commentId: 'c-1', trackedChange: { insert: {} } }); const superdoc = makeSuperdoc(); - c.resolveComment({ email: 'a@b.com', name: 'Alice', superdoc }); + c.resolveComment({ id: 'alice-id', email: 'a@b.com', name: 'Alice', superdoc }); expect(superdoc.emit).toHaveBeenCalled(); expect(superdoc.activeEditor.commands.resolveComment).toHaveBeenCalled(); }); @@ -179,6 +181,7 @@ describe('use-comment: extended coverage', () => { creatorImage: '/a.png', }); expect(c.getCommentUser()).toEqual({ + id: null, name: 'Alice', email: 'a@b.com', image: '/a.png', diff --git a/packages/superdoc/src/components/CommentsLayer/use-comment.js b/packages/superdoc/src/components/CommentsLayer/use-comment.js index e918d28c0d..27130daeb4 100644 --- a/packages/superdoc/src/components/CommentsLayer/use-comment.js +++ b/packages/superdoc/src/components/CommentsLayer/use-comment.js @@ -27,10 +27,11 @@ export default function useComment(params) { const commentElement = ref(null); const isFocused = ref(params.isFocused || false); - const creatorEmail = params.creatorEmail; - const creatorName = params.creatorName; - const creatorImage = params.creatorImage; - const createdTime = params.createdTime || Date.now(); + const creatorId = ref(params.creatorId ?? null); + const creatorEmail = ref(params.creatorEmail ?? null); + const creatorName = ref(params.creatorName ?? null); + const creatorImage = ref(params.creatorImage ?? null); + const createdTime = ref(params.createdTime || Date.now()); const importedAuthor = ref(params.importedAuthor || null); const docxCommentJSON = ref(params.docxCommentJSON || null); const origin = params.origin; @@ -65,19 +66,22 @@ export default function useComment(params) { const deletedText = ref(params.deletedText || null); const resolvedTime = ref(params.resolvedTime || null); + const resolvedById = ref(params.resolvedById || null); const resolvedByEmail = ref(params.resolvedByEmail || null); const resolvedByName = ref(params.resolvedByName || null); /** * Mark this conversation as resolved with UTC date * + * @param {String} id The actor id of the user marking this conversation as done * @param {String} email The email of the user marking this conversation as done * @param {String} name The name of the user marking this conversation as done * @returns {void} */ - const resolveComment = ({ email, name, superdoc }) => { + const resolveComment = ({ id, email, name, superdoc }) => { if (resolvedTime.value) return; resolvedTime.value = Date.now(); + resolvedById.value = id ?? null; resolvedByEmail.value = email; resolvedByName.value = name; @@ -209,7 +213,7 @@ export default function useComment(params) { const getCommentUser = () => { const user = importedAuthor.value ? { name: importedAuthor.value.name || '(Imported)', email: importedAuthor.value.email } - : { name: creatorName, email: creatorEmail, image: creatorImage }; + : { id: creatorId.value, name: creatorName.value, email: creatorEmail.value, image: creatorImage.value }; return user; }; @@ -244,10 +248,11 @@ export default function useComment(params) { return { ...u, name: u.name ? u.name : u.email }; }), createdAtVersionNumber, - creatorEmail, - creatorName, - creatorImage, - createdTime, + creatorId: creatorId.value, + creatorEmail: creatorEmail.value, + creatorName: creatorName.value, + creatorImage: creatorImage.value, + createdTime: createdTime.value, importedAuthor: importedAuthor.value, docxCommentJSON: docxCommentJSON.value, isInternal: isInternal.value, @@ -263,6 +268,7 @@ export default function useComment(params) { trackedChangeAnchorKey: trackedChangeAnchorKey.value, deletedText: deletedText.value, resolvedTime: resolvedTime.value, + resolvedById: resolvedById.value, resolvedByEmail: resolvedByEmail.value, resolvedByName: resolvedByName.value, origin, @@ -284,6 +290,7 @@ export default function useComment(params) { mentions, commentElement, isFocused, + creatorId, creatorEmail, creatorName, creatorImage, @@ -302,6 +309,7 @@ export default function useComment(params) { trackedChangeStoryLabel, trackedChangeAnchorKey, resolvedTime, + resolvedById, resolvedByEmail, resolvedByName, importedAuthor, diff --git a/packages/superdoc/src/core/SuperDoc.ts b/packages/superdoc/src/core/SuperDoc.ts index 4f2191e20c..100acbbf24 100644 --- a/packages/superdoc/src/core/SuperDoc.ts +++ b/packages/superdoc/src/core/SuperDoc.ts @@ -5,7 +5,7 @@ import { v4 as uuidv4 } from 'uuid'; import { markRaw, toRaw } from 'vue'; import { HocuspocusProviderWebsocket } from '@hocuspocus/provider'; -import { DOCX, PDF, HTML } from '@superdoc/common'; +import { DOCX, PDF, HTML, getActorIdentityKey, normalizeActorEmail } from '@superdoc/common'; import { SuperToolbar, createZip, seedEditorStateToYDoc, onCollaborationProviderSynced } from '@superdoc/super-editor'; import { SuperComments } from '../components/CommentsLayer/commentsList/super-comments-list.js'; import { createSuperdocVueApp } from './create-app.js'; @@ -23,6 +23,7 @@ import { createDeprecatedEditorProxy } from '../helpers/deprecation.js'; import { normalizeTrackChangesConfig } from './helpers/normalize-track-changes-config.js'; const DEFAULT_USER = Object.freeze({ + id: null, name: 'Default SuperDoc user', email: null, }); @@ -1023,7 +1024,7 @@ export class SuperDoc extends EventEmitter { if (!user || user.color) return; const palette = this.colors.length > 0 ? this.colors : DEFAULT_AWARENESS_PALETTE; - const userKey = user.email || user.name || ''; + const userKey = user.id || user.email || user.name || ''; let hash = 5381; for (let i = 0; i < userKey.length; i++) { hash = ((hash << 5) + hash) ^ userKey.charCodeAt(i); @@ -1406,19 +1407,30 @@ export class SuperDoc extends EventEmitter { */ addSharedUser(user: User) { this.#requireReady('addSharedUser'); - if (this.users.some((u) => u.email === user.email)) return; + const userKey = getActorIdentityKey({ actor: user }); + if (userKey && this.users.some((u) => getActorIdentityKey({ actor: u }) === userKey)) return; this.users.push(user); } /** * Remove a user from the shared users list. Requires the instance - * to be ready for the same reason as `addSharedUser`. + * to be ready for the same reason as `addSharedUser`. Accepts + * either a user-like object or a legacy email string. * - * @param {String} email The email of the user to remove + * @param {User | string} userOrEmail The user or email of the user to remove */ - removeSharedUser(email: string) { + removeSharedUser(userOrEmail: User | string) { this.#requireReady('removeSharedUser'); - this.users = this.users.filter((u) => u.email !== email); + const legacyEmail = typeof userOrEmail === 'string' ? normalizeActorEmail(userOrEmail) : ''; + const targetKey = + typeof userOrEmail === 'string' ? `email:${legacyEmail}` : getActorIdentityKey({ actor: userOrEmail }); + + this.users = this.users.filter((u) => { + const existingKey = getActorIdentityKey({ actor: u }); + if (targetKey) return existingKey !== targetKey; + if (legacyEmail) return normalizeActorEmail(u.email) !== legacyEmail; + return true; + }); } /** diff --git a/packages/superdoc/src/core/collaboration/awareness-identity.test.js b/packages/superdoc/src/core/collaboration/awareness-identity.test.js new file mode 100644 index 0000000000..cac16c061b --- /dev/null +++ b/packages/superdoc/src/core/collaboration/awareness-identity.test.js @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { awarenessStatesToArray } from '@superdoc/common/collaboration/awareness'; + +const makeContext = () => ({ + userColorMap: new Map(), + colorIndex: 0, + config: { + colors: ['#111111', '#222222', '#333333'], + }, +}); + +describe('awareness identity dedupe', () => { + it('keeps same-email different-id actors as separate presence entries', () => { + const states = new Map([ + [1, { user: { id: 'alice-id', email: 'shared@example.com', name: 'Alice' } }], + [2, { user: { id: 'bob-id', email: 'shared@example.com', name: 'Bob' } }], + ]); + + const result = awarenessStatesToArray(makeContext(), states); + + expect(result).toHaveLength(2); + expect(result.map((user) => user.id)).toEqual(['alice-id', 'bob-id']); + }); + + it('dedupes multiple sessions for the same actor id even when emails differ', () => { + const states = new Map([ + [1, { user: { id: 'alice-id', email: 'alice@example.com', name: 'Alice' } }], + [2, { user: { id: 'alice-id', email: 'alias@example.com', name: 'Alice (Laptop)' } }], + ]); + + const result = awarenessStatesToArray(makeContext(), states); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('alice-id'); + }); +}); diff --git a/packages/superdoc/src/core/collaboration/collaboration.test.js b/packages/superdoc/src/core/collaboration/collaboration.test.js index c21b36e039..5c8cc51aa0 100644 --- a/packages/superdoc/src/core/collaboration/collaboration.test.js +++ b/packages/superdoc/src/core/collaboration/collaboration.test.js @@ -233,7 +233,7 @@ describe('collaboration helpers', () => { superdoc = { config: { superdocId: 'doc-123', - user: { name: 'Owner', email: 'owner@example.com' }, + user: { id: 'owner-id', name: 'Owner', email: 'owner@example.com' }, role: 'editor', isInternal: false, socket: { id: 'socket' }, @@ -291,6 +291,12 @@ describe('collaboration helpers', () => { // Event from same user should be ignored commentsArray.emit({ transaction: { origin: { user: superdoc.config.user } } }); expect(useCommentMock).toHaveBeenCalledTimes(2); + + // Same email but different actor id should not be ignored. + commentsArray.emit({ + transaction: { origin: { user: { id: 'other-id', name: 'Other', email: superdoc.config.user.email } } }, + }); + expect(useCommentMock).toHaveBeenCalledTimes(4); }); it('initCollaborationComments loads existing comments from ydoc on init', () => { diff --git a/packages/superdoc/src/core/collaboration/helpers.js b/packages/superdoc/src/core/collaboration/helpers.js index 9ba0a54e48..bdf416f8bc 100644 --- a/packages/superdoc/src/core/collaboration/helpers.js +++ b/packages/superdoc/src/core/collaboration/helpers.js @@ -1,5 +1,6 @@ import { createProvider } from '../collaboration/collaboration'; import useComment from '../../components/CommentsLayer/use-comment'; +import { actorIdentitiesMatch } from '@superdoc/common'; import { addYComment, updateYComment, deleteYComment } from './collaboration-comments'; @@ -97,7 +98,7 @@ export const initCollaborationComments = (superdoc) => { const origin = event?.transaction?.origin; const { user = {} } = origin || {}; - if (currentUser.name === user.name && currentUser.email === user.email) return; + if (actorIdentitiesMatch({ current: currentUser, other: user })) return; // Update conversations updateCommentsStore(); diff --git a/packages/superdoc/src/core/types/index.ts b/packages/superdoc/src/core/types/index.ts index 5907b68995..50f55c0be9 100644 --- a/packages/superdoc/src/core/types/index.ts +++ b/packages/superdoc/src/core/types/index.ts @@ -91,8 +91,8 @@ export interface AwarenessUser extends User { * The runtime helper `awarenessStatesToArray` spreads each remote user * onto the top of the entry (`{ clientId, ...value.user, color }`), so * `User` fields like `name`, `email`, `image` appear at the top level - * (not nested under a `user` property). Consumers should read - * `state.name` / `state.email`, not `state.user.name`. + * (not nested under a `user` property). Consumers should read `state.id`, + * `state.name`, and `state.email`, not `state.user.name`. * * Application-specific fields attached to the awareness state by the * provider surface through the `[key: string]: unknown` index diff --git a/packages/superdoc/src/dev/components/SuperdocDev.vue b/packages/superdoc/src/dev/components/SuperdocDev.vue index d15842eae5..234a9b6f87 100644 --- a/packages/superdoc/src/dev/components/SuperdocDev.vue +++ b/packages/superdoc/src/dev/components/SuperdocDev.vue @@ -39,7 +39,17 @@ const wordBaselineServiceUrl = 'http://127.0.0.1:9185'; const clampOpacity = (v) => Math.min(1, Math.max(0, v)); const overlayOpacityFromUrl = Number.parseFloat(urlParams.get('wordOverlayOpacity') ?? '0.45'); const isInternal = urlParams.has('internal'); -const testUserEmail = urlParams.get('email') || 'user@superdoc.com'; +const ensureStableDevTabId = () => { + const storageKey = 'superdoc-dev-tab-id'; + const existingId = window.sessionStorage.getItem(storageKey); + if (existingId) return existingId; + const nextId = `dev-${crypto.randomUUID()}`; + window.sessionStorage.setItem(storageKey, nextId); + return nextId; +}; +const stableDevTabId = ensureStableDevTabId(); +const testUserId = urlParams.get('userId') || urlParams.get('id') || stableDevTabId; +const testUserEmail = urlParams.get('email') || `${stableDevTabId}@dev.superdoc`; const testUserName = urlParams.get('name') || `SuperDoc ${Math.floor(1000 + Math.random() * 9000)}`; const userRole = urlParams.get('role') || 'editor'; const useLayoutEngine = ref(urlParams.get('layout') !== '0'); @@ -111,6 +121,7 @@ const handleLoadFromUrl = async () => { }; const user = { + id: testUserId, name: testUserName, email: testUserEmail, }; @@ -1155,7 +1166,7 @@ onMounted(async () => { const ydoc = new Y.Doc(); const provider = new WebsocketProvider(collabUrl, collabRoom, ydoc, { params: { - userId: user.email || user.name, + userId: user.id || user.email || user.name, }, }); diff --git a/packages/superdoc/src/stores/comments-store.js b/packages/superdoc/src/stores/comments-store.js index 65a8e625ea..8b46743a7f 100644 --- a/packages/superdoc/src/stores/comments-store.js +++ b/packages/superdoc/src/stores/comments-store.js @@ -101,6 +101,77 @@ export const useCommentsStore = defineStore('comments', () => { } }; + const normalizeCommentId = (id) => (id === undefined || id === null ? null : String(id)); + + const getPositionEntryByAlias = (id) => { + const normalizedId = normalizeCommentId(id); + if (!normalizedId) return { key: null, entry: null }; + + const positions = editorCommentPositions.value || {}; + if (positions[normalizedId] !== undefined) { + return { key: normalizedId, entry: positions[normalizedId] }; + } + + for (const [key, entry] of Object.entries(positions)) { + const entryKey = normalizeCommentId(entry?.key); + const threadId = normalizeCommentId(entry?.threadId); + if (entryKey === normalizedId || threadId === normalizedId) { + return { key, entry }; + } + } + + return { key: null, entry: null }; + }; + + const boundsOverlap = (a, b) => { + if (!a || !b) return false; + const left = Math.max(Number(a.left), Number(b.left)); + const right = Math.min(Number(a.right), Number(b.right)); + const top = Math.max(Number(a.top), Number(b.top)); + const bottom = Math.min(Number(a.bottom), Number(b.bottom)); + return [left, right, top, bottom].every(Number.isFinite) && right > left && bottom > top; + }; + + const isEquivalentTrackedChangePosition = (candidate, existing) => { + if (!candidate || !existing) return false; + if (candidate.kind !== 'trackedChange' || existing.kind !== 'trackedChange') return false; + if (candidate.storyKey && existing.storyKey && candidate.storyKey !== existing.storyKey) return false; + const candidatePage = Number(candidate.pageIndex); + const existingPage = Number(existing.pageIndex); + if (Number.isFinite(candidatePage) && Number.isFinite(existingPage) && candidatePage !== existingPage) { + return false; + } + + if (boundsOverlap(candidate.bounds, existing.bounds)) return true; + + const candidateStart = Number(candidate.start); + const candidateEnd = Number(candidate.end); + const existingStart = Number(existing.start); + const existingEnd = Number(existing.end); + return ( + [candidateStart, candidateEnd, existingStart, existingEnd].every(Number.isFinite) && + candidateStart === existingStart && + candidateEnd === existingEnd + ); + }; + + const getTrackedChangeCommentByPositionAlias = (id) => { + const { entry: targetEntry } = getPositionEntryByAlias(id); + if (!targetEntry) return null; + + const matches = commentsList.value.filter((comment) => { + if (!comment?.trackedChange) return false; + + const aliases = [comment.trackedChangeAnchorKey, comment.commentId, comment.importedId]; + return aliases.some((alias) => { + const { entry } = getPositionEntryByAlias(alias); + return isEquivalentTrackedChangePosition(targetEntry, entry); + }); + }); + + return matches.length === 1 ? matches[0] : null; + }; + /** * Get a comment by either ID or imported ID * @@ -109,7 +180,10 @@ export const useCommentsStore = defineStore('comments', () => { */ const getComment = (id) => { if (id === undefined || id === null) return null; - return commentsList.value.find((c) => c.commentId == id || c.importedId == id); + const directMatch = commentsList.value.find( + (c) => c.commentId == id || c.importedId == id || c.trackedChangeAnchorKey == id, + ); + return directMatch || getTrackedChangeCommentByPositionAlias(id); }; const getThreadParent = (comment) => { @@ -166,8 +240,6 @@ export const useCommentsStore = defineStore('comments', () => { return trackedChangeAnchorKey ?? commentId ?? importedId ?? null; }; - const normalizeCommentId = (id) => (id === undefined || id === null ? null : String(id)); - // Comments can be referenced by the imported DOCX id, the internal commentId, or a raw id // coming from UI/editor events. Normalize everything to strings and keep all aliases so every // lookup path resolves against the same set of ids. @@ -216,17 +288,20 @@ export const useCommentsStore = defineStore('comments', () => { if (!comment) return; if ( comment.resolvedTime !== undefined || + comment.resolvedById !== undefined || comment.resolvedByEmail !== undefined || comment.resolvedByName !== undefined ) { trackedChangeResolutionSnapshots.set(comment, { resolvedTime: comment.resolvedTime ?? null, + resolvedById: comment.resolvedById ?? null, resolvedByEmail: comment.resolvedByEmail ?? null, resolvedByName: comment.resolvedByName ?? null, }); } // Sets the resolved state to null so it can be restored in the comments sidebar comment.resolvedTime = null; + comment.resolvedById = null; comment.resolvedByEmail = null; comment.resolvedByName = null; }; @@ -518,6 +593,7 @@ export const useCommentsStore = defineStore('comments', () => { trackedChangeType, trackedChangeDisplayType, deletedText, + authorId, authorEmail, authorImage, date, @@ -557,6 +633,7 @@ export const useCommentsStore = defineStore('comments', () => { trackedChangeDisplayType, deletedText, createdTime: date, + creatorId: authorId ?? null, creatorName: authorName, creatorEmail: authorEmail, creatorImage: authorImage, @@ -606,6 +683,7 @@ export const useCommentsStore = defineStore('comments', () => { }; const setIfChanged = (target, key, value) => { + if (target?.[key] == null && value == null) return false; if (!target || shallowEqual(target[key], value)) return false; target[key] = value; return true; @@ -635,6 +713,11 @@ export const useCommentsStore = defineStore('comments', () => { trackedChangeType: trackedChangeType ?? null, trackedChangeDisplayType: trackedChangeDisplayType ?? null, deletedText: deletedText ?? null, + creatorId: authorId ?? null, + creatorName: authorName ?? null, + creatorEmail: authorEmail ?? null, + creatorImage: authorImage ?? null, + createdTime: date ?? null, }; let didChange = false; @@ -646,7 +729,10 @@ export const useCommentsStore = defineStore('comments', () => { const updateExistingTrackedChange = (trackedComment) => { const wasResolved = Boolean( - trackedComment.resolvedTime || trackedComment.resolvedByEmail || trackedComment.resolvedByName, + trackedComment.resolvedTime || + trackedComment.resolvedById || + trackedComment.resolvedByEmail || + trackedComment.resolvedByName, ); if (wasResolved) clearResolvedMetadata(trackedComment); // AIDEV-NOTE: Targeted tracked-change refresh runs during body typing. @@ -684,6 +770,7 @@ export const useCommentsStore = defineStore('comments', () => { } else if (event === 'resolve') { const existingTrackedChange = findTrackedChangeById(); const resolveArgs = { + id: params.resolvedById ?? superdoc?.user?.id ?? null, email: params.resolvedByEmail ?? superdoc?.user?.email ?? null, name: params.resolvedByName ?? superdoc?.user?.name ?? null, superdoc, @@ -695,42 +782,10 @@ export const useCommentsStore = defineStore('comments', () => { existingTrackedChange.resolveComment(resolveArgs); } - // AIDEV-NOTE: SD-2528. User-attached comments on a tracked change carry - // trackedChangeParentId === . When the TC is accepted - // or rejected, those comment bubbles must also resolve — otherwise the - // comment lingers after the redline it referred to is gone. Defer to a - // microtask so the cascading resolveComment doesn't dispatch into a - // still-running acceptTrackedChangeById/rejectTrackedChangeById loop and - // collide with its mutable `tr`. - // - // AIDEV-NOTE: SD-2528 P2 #1. Mirror `findTrackedChangeById`'s - // documentId scope (see line 591-596). In multi-document sessions - // tracked-change ids can collide across documents (each imported file - // has its own w:id space); without this filter, accepting a change in - // document A would cascade-resolve comments anchored on document B - // that happen to share the same id. Single-document callers (no - // documentId on the event) keep the legacy global behaviour. - if (normalizedChangeId) { - const linkedToResolve = commentsList.value.filter((linkedComment) => { - if (!linkedComment || linkedComment === existingTrackedChange) return false; - if (linkedComment.resolvedTime) return false; - const linkedParentId = - linkedComment.trackedChangeParentId != null ? String(linkedComment.trackedChangeParentId) : null; - if (linkedParentId !== normalizedChangeId) return false; - if (normalizedDocumentId) { - return belongsToTrackedChangeSyncDocument(linkedComment, normalizedDocumentId); - } - return true; - }); - if (linkedToResolve.length) { - Promise.resolve().then(() => { - linkedToResolve.forEach((linkedComment) => { - if (linkedComment.resolvedTime) return; - linkedComment.resolveComment(resolveArgs); - }); - }); - } - } + // User comments linked to tracked content are no longer blanket-cascaded + // here. The decision engine emits explicit standard comment update/delete + // events for each affected thread so accepted insertions can keep their + // comments while rejected/removed coverage still deletes the right ones. } }; @@ -988,6 +1043,7 @@ export const useCommentsStore = defineStore('comments', () => { fileId: activeDocument.id, fileType: activeDocument.type, parentCommentId, + creatorId: superdocStore.user.id, creatorEmail: superdocStore.user.email, creatorName: superdocStore.user.name, creatorImage: superdocStore.user.image, @@ -1186,6 +1242,7 @@ export const useCommentsStore = defineStore('comments', () => { isInternal: false, parentCommentId: comment.parentCommentId, trackedChangeParentId: comment.trackedChangeParentId, + creatorId: null, creatorName, createdTime: comment.createdTime, creatorEmail: comment.creatorEmail, @@ -1195,6 +1252,7 @@ export const useCommentsStore = defineStore('comments', () => { }, commentText: htmlContent, resolvedTime: comment.isDone ? Date.now() : null, + resolvedById: null, resolvedByEmail: comment.isDone ? comment.creatorEmail : null, resolvedByName: comment.isDone ? importedName || '(Imported)' : null, trackedChange: comment.trackedChange || false, @@ -1404,6 +1462,7 @@ export const useCommentsStore = defineStore('comments', () => { const resolutionSnapshot = trackedChangeResolutionSnapshots.get(comment); if (resolutionSnapshot) { comment.resolvedTime = resolutionSnapshot.resolvedTime ?? Date.now(); + comment.resolvedById = resolutionSnapshot.resolvedById ?? null; comment.resolvedByEmail = resolutionSnapshot.resolvedByEmail ?? null; comment.resolvedByName = resolutionSnapshot.resolvedByName ?? null; restoredComments.push(comment); @@ -1593,6 +1652,7 @@ export const useCommentsStore = defineStore('comments', () => { trackedChangeType: snapshot.type, trackedChangeDisplayType: snapshot.type, deletedText: snapshot.type === 'delete' ? (snapshot.excerpt ?? '') : null, + authorId: snapshot.authorId, authorEmail: snapshot.authorEmail, authorImage: snapshot.authorImage, date: snapshot.date, diff --git a/packages/superdoc/src/stores/comments-store.test.js b/packages/superdoc/src/stores/comments-store.test.js index 065aaa401e..de7bc80cee 100644 --- a/packages/superdoc/src/stores/comments-store.test.js +++ b/packages/superdoc/src/stores/comments-store.test.js @@ -208,6 +208,35 @@ describe('comments-store', () => { expect(store.getComment(undefined)).toBeNull(); }); + it('resolves rendered tracked-change aliases by equivalent position', () => { + const canonicalAnchorKey = 'tc::hf:part:rId8::word:trackInsert:101'; + const renderedAnchorKey = 'tc::hf:part:rId8::rendered-uuid'; + const comment = { + commentId: 'word:trackInsert:101', + trackedChange: true, + trackedChangeAnchorKey: canonicalAnchorKey, + }; + store.commentsList = [comment]; + store.editorCommentPositions = { + [canonicalAnchorKey]: { + key: canonicalAnchorKey, + threadId: 'word:trackInsert:101', + kind: 'trackedChange', + storyKey: 'hf:part:rId8', + bounds: { top: 49, left: 199, bottom: 69, right: 267, width: 68, height: 20 }, + }, + [renderedAnchorKey]: { + key: renderedAnchorKey, + threadId: 'rendered-uuid', + kind: 'trackedChange', + storyKey: 'hf:part:rId8', + bounds: { top: 47, left: 188, bottom: 69, right: 300, width: 112, height: 22 }, + }, + }; + + expect(store.getComment('rendered-uuid')).toEqual(comment); + }); + it('prefers tracked-change anchor keys for position lookup and alias resolution', () => { const comment = { commentId: 'tc-1', @@ -604,7 +633,7 @@ describe('comments-store', () => { it('resolves tracked change comments on resolve events', () => { const superdoc = { emit: vi.fn(), - user: { email: 'reviewer@example.com', name: 'Reviewer' }, + user: { id: 'reviewer-id', email: 'reviewer@example.com', name: 'Reviewer' }, }; const existingComment = { @@ -614,8 +643,9 @@ describe('comments-store', () => { resolvedByEmail: null, resolvedByName: null, getValues: vi.fn(() => ({ commentId: 'change-resolve-1', resolvedTime: Date.now() })), - resolveComment: vi.fn(function ({ email, name }) { + resolveComment: vi.fn(function ({ id, email, name }) { this.resolvedTime = Date.now(); + this.resolvedById = id; this.resolvedByEmail = email; this.resolvedByName = name; const emitData = { type: comments_module_events.RESOLVED, comment: this.getValues() }; @@ -634,6 +664,7 @@ describe('comments-store', () => { }); expect(existingComment.resolveComment).toHaveBeenCalledWith({ + id: 'reviewer-id', email: 'reviewer@example.com', name: 'Reviewer', superdoc, @@ -651,10 +682,10 @@ describe('comments-store', () => { ); }); - it('cascades resolve to user comments anchored to the same tracked change (SD-2528)', async () => { + it('does not blanket-cascade linked user comments on tracked-change resolve events', async () => { const superdoc = { emit: vi.fn(), - user: { email: 'reviewer@example.com', name: 'Reviewer' }, + user: { id: 'reviewer-id', email: 'reviewer@example.com', name: 'Reviewer' }, }; const trackedChangeComment = { @@ -695,24 +726,12 @@ describe('comments-store', () => { }); expect(trackedChangeComment.resolveComment).toHaveBeenCalledTimes(1); - // Cascading runs in a microtask so we wait one turn before asserting. await Promise.resolve(); - expect(linkedUserComment.resolveComment).toHaveBeenCalledTimes(1); - expect(linkedUserComment.resolveComment).toHaveBeenCalledWith({ - email: 'reviewer@example.com', - name: 'Reviewer', - superdoc, - }); + expect(linkedUserComment.resolveComment).not.toHaveBeenCalled(); expect(unrelatedUserComment.resolveComment).not.toHaveBeenCalled(); }); - // SD-2528 P2 #1 — when the resolve event carries an explicit `documentId`, - // the cascade must filter linked comments by that document. `findTrackedChangeById` - // does this for the primary comment; the cascade scan one level down was - // missing the same guard. In multi-document sessions where imported TC ids - // happen to collide, accepting/rejecting a change in one document must not - // resolve comments anchored on a different document. - it('scopes cascade resolve to the active document when documentId is provided', async () => { + it('does not cascade linked comments even when tracked-change resolve carries a documentId', async () => { const superdoc = { emit: vi.fn(), user: { email: 'reviewer@example.com', name: 'Reviewer' } }; __mockSuperdoc.documents.value = [ { id: 'doc-A', type: 'docx' }, @@ -757,13 +776,11 @@ describe('comments-store', () => { }); await Promise.resolve(); - expect(linkedOnDocA.resolveComment).toHaveBeenCalledTimes(1); + expect(linkedOnDocA.resolveComment).not.toHaveBeenCalled(); expect(linkedOnDocB.resolveComment).not.toHaveBeenCalled(); }); - // Regression: when no documentId is passed, single-document behaviour is - // unchanged. Mirrors the legacy `cascades resolve` test contract. - it('cascades to every doc-anchored linked comment when no documentId is provided (single-doc)', async () => { + it('does not cascade linked comments in single-document resolve flows either', async () => { const superdoc = { emit: vi.fn(), user: { email: 'r@e', name: 'R' } }; const trackedChangeComment = { @@ -792,7 +809,7 @@ describe('comments-store', () => { }); await Promise.resolve(); - expect(linkedNoFileId.resolveComment).toHaveBeenCalledTimes(1); + expect(linkedNoFileId.resolveComment).not.toHaveBeenCalled(); }); it('does not re-resolve already-resolved linked user comments', async () => { @@ -1523,6 +1540,65 @@ describe('comments-store', () => { expect(superdoc.emit).not.toHaveBeenCalled(); }); + it('creates the first live tracked-change comment from an add event', () => { + const superdoc = { + ...__mockSuperdoc, + emit: vi.fn(), + activeEditor: { commands: { removeComment: vi.fn(), setActiveComment: vi.fn() } }, + }; + + store.commentsList = []; + store.handleTrackedChangeUpdate({ + superdoc, + params: { + event: 'add', + changeId: 'tc-live-insert', + trackedChangeText: 'Tracked live insert', + trackedChangeType: 'trackInsert', + deletedText: null, + authorEmail: 'alice@example.com', + author: 'Alice', + date: 123, + documentId: 'doc-1', + }, + broadcastChanges: false, + }); + + expect(store.commentsList).toHaveLength(1); + expect(store.commentsList[0]).toEqual( + expect.objectContaining({ + commentId: 'tc-live-insert', + trackedChange: true, + trackedChangeText: 'Tracked live insert', + trackedChangeType: 'trackInsert', + fileId: 'doc-1', + }), + ); + }); + + it('does not create a missing tracked-change comment from an update-only refresh event', () => { + const superdoc = { emit: vi.fn() }; + + store.commentsList = []; + store.handleTrackedChangeUpdate({ + superdoc, + params: { + event: 'update', + changeId: 'tc-live-insert', + trackedChangeText: 'Tracked live insert', + trackedChangeType: 'trackInsert', + deletedText: null, + authorEmail: 'alice@example.com', + author: 'Alice', + date: 123, + documentId: 'doc-1', + }, + }); + + expect(store.commentsList).toEqual([]); + expect(superdoc.emit).not.toHaveBeenCalled(); + }); + it('keeps imported resolved tracked-change comments resolved during initial tracked-change rebuild', async () => { const editorDispatch = vi.fn(); const tr = { setMeta: vi.fn() }; diff --git a/shared/common/collaboration/awareness.ts b/shared/common/collaboration/awareness.ts index 564ec4c377..fa593f30a8 100644 --- a/shared/common/collaboration/awareness.ts +++ b/shared/common/collaboration/awareness.ts @@ -1,3 +1,5 @@ +import { getActorIdentityKey } from '../identity'; + type ReadonlyLooseRecord = Readonly>; /** @@ -6,8 +8,10 @@ type ReadonlyLooseRecord = Readonly>; export type HexColor = `#${string}`; export interface User extends ReadonlyLooseRecord { - readonly email: string; - readonly name?: string; + readonly id?: string | null; + readonly email?: string | null; + readonly name?: string | null; + readonly color?: HexColor | string; } export interface AwarenessState extends ReadonlyLooseRecord { @@ -49,17 +53,17 @@ export const awarenessStatesToArray = ( return Array.from(states.entries()) .filter(hasUser) - .filter(([, value]) => { - const userEmail = value.user.email; - if (seenUsers.has(userEmail)) return false; - seenUsers.add(userEmail); + .filter(([clientId, value]) => { + const identityKey = getActorIdentityKey({ actor: value.user, fallbackKey: clientId }); + if (!identityKey) return false; + if (seenUsers.has(identityKey)) return false; + seenUsers.add(identityKey); return true; }) .map(([key, value]) => { - // Type narrowing guarantees user exists here - const email = value.user.email; + const identityKey = getActorIdentityKey({ actor: value.user, fallbackKey: key }); - let color = context.userColorMap.get(email); + let color = context.userColorMap.get(identityKey); if (!color) { // Prefer the color already set on the user's awareness state (e.g. hash-assigned by SuperDoc). // Fall back to the configured palette if available. @@ -69,7 +73,7 @@ export const awarenessStatesToArray = ( (context.config.colors.length > 0 ? context.config.colors[context.colorIndex % context.config.colors.length] : (undefined as unknown as HexColor)); - context.userColorMap.set(email, color); + context.userColorMap.set(identityKey, color); context.colorIndex++; } diff --git a/shared/common/comments-types.ts b/shared/common/comments-types.ts index 1a6b037ad9..61959cb578 100644 --- a/shared/common/comments-types.ts +++ b/shared/common/comments-types.ts @@ -4,6 +4,7 @@ export type Comment = { fileId: string; fileType: string; mentions: unknown[]; + creatorId?: string | null; creatorName: string; creatorEmail?: string; createdTime: number; @@ -25,6 +26,7 @@ export type Comment = { trackedChangeDisplayType?: 'hyperlinkAdded' | 'hyperlinkModified' | null; deletedText: string | null; resolvedTime: number | null; + resolvedById: string | null; resolvedByEmail: string | null; resolvedByName: string | null; commentJSON: CommentJSON; diff --git a/shared/common/identity.ts b/shared/common/identity.ts new file mode 100644 index 0000000000..d5c171c243 --- /dev/null +++ b/shared/common/identity.ts @@ -0,0 +1,102 @@ +type IdentityFields = Readonly<{ + id?: unknown; + email?: unknown; + name?: unknown; +}>; + +type IdentityLike = IdentityFields | Readonly> | null | undefined; + +export interface NormalizedActorIdentity { + readonly id: string; + readonly email: string; + readonly name: string; + readonly hasId: boolean; + readonly hasEmail: boolean; +} + +/** + * Trim a principal id. Actor ids are treated as opaque, case-sensitive values. + */ +export const normalizeActorId = (value: unknown): string => { + if (typeof value !== 'string') return ''; + return value.trim(); +}; + +/** + * Trim and lowercase an email value. + */ +export const normalizeActorEmail = (value: unknown): string => { + if (typeof value !== 'string') return ''; + const trimmed = value.trim(); + if (!trimmed) return ''; + return trimmed.toLowerCase(); +}; + +/** + * Trim and lowercase a display-name value. + */ +export const normalizeActorName = (value: unknown): string => { + if (typeof value !== 'string') return ''; + const trimmed = value.trim(); + if (!trimmed) return ''; + return trimmed.toLowerCase(); +}; + +export const getActorIdentity = (value: IdentityLike): NormalizedActorIdentity => { + const record = value ?? {}; + const id = normalizeActorId(record.id); + const email = normalizeActorEmail(record.email); + const name = typeof record.name === 'string' ? record.name : ''; + return { + id, + email, + name, + hasId: id.length > 0, + hasEmail: email.length > 0, + }; +}; + +/** + * Principal-first actor comparison. + * + * - If both sides have ids, ids decide. + * - Otherwise, when both sides have emails, emails decide. + * - Missing comparable identifiers means "not provably same actor". + */ +export const actorIdentitiesMatch = ({ current, other }: { current?: IdentityLike; other?: IdentityLike }): boolean => { + const currentIdentity = getActorIdentity(current); + const otherIdentity = getActorIdentity(other); + + if (currentIdentity.hasId && otherIdentity.hasId) { + return currentIdentity.id === otherIdentity.id; + } + + if (currentIdentity.hasEmail && otherIdentity.hasEmail) { + return currentIdentity.email === otherIdentity.email; + } + + return false; +}; + +/** + * Stable identity key for dedupe/color assignment. + * + * Id wins over email. Callers may supply a per-session fallback key when no + * durable principal data exists. + */ +export const getActorIdentityKey = ({ + actor, + fallbackKey = '', +}: { + actor?: IdentityLike; + fallbackKey?: string | number | null | undefined; +}): string => { + const identity = getActorIdentity(actor); + if (identity.hasId) return `id:${identity.id}`; + if (identity.hasEmail) return `email:${identity.email}`; + + const fallback = + typeof fallbackKey === 'number' ? String(fallbackKey) : typeof fallbackKey === 'string' ? fallbackKey.trim() : ''; + if (fallback) return `fallback:${fallback}`; + return ''; +}; diff --git a/shared/common/index.ts b/shared/common/index.ts index 21158ff68a..bacdc8cfa4 100644 --- a/shared/common/index.ts +++ b/shared/common/index.ts @@ -16,6 +16,9 @@ export type { CommentThreadingStyle, } from './comments-types'; +// Identity helpers +export * from './identity'; + // List numbering helpers export * from './list-numbering'; export * from './list-rendering'; diff --git a/tests/behavior/helpers/story-tracked-changes.ts b/tests/behavior/helpers/story-tracked-changes.ts index 2513b16035..eab646ea11 100644 --- a/tests/behavior/helpers/story-tracked-changes.ts +++ b/tests/behavior/helpers/story-tracked-changes.ts @@ -23,11 +23,36 @@ function normalizeTrackedChangeExcerpt(change: TrackChangeInfo): string { return String(change.excerpt ?? '').trim(); } -function mapTrackChangeTypeToCommentType(type: TrackChangeType | undefined): string | null { - if (!type) return null; - if (type === 'insert') return 'trackInsert'; - if (type === 'delete') return 'trackDelete'; - return 'trackFormat'; +function matchesTrackedChangeCommentType( + comment: TrackedChangeCommentSnapshot, + type: TrackChangeType | undefined, +): boolean { + if (!type) return true; + + const trackedChangeType = comment.trackedChangeType ?? null; + const trackedChangeDisplayType = comment.trackedChangeDisplayType ?? null; + if (type === 'insert') { + return ( + trackedChangeType === 'trackInsert' || trackedChangeType === 'insert' || trackedChangeDisplayType === 'insert' + ); + } + if (type === 'delete') { + return ( + trackedChangeType === 'trackDelete' || trackedChangeType === 'delete' || trackedChangeDisplayType === 'delete' + ); + } + if (type === 'replacement') { + return ( + trackedChangeType === 'replacement' || + trackedChangeType === 'both' || + trackedChangeDisplayType === 'replacement' || + ((trackedChangeType === 'trackInsert' || + trackedChangeType === 'insert' || + trackedChangeDisplayType === 'insert') && + comment.deletedText != null) + ); + } + return trackedChangeType === 'trackFormat' || trackedChangeType === 'format' || trackedChangeDisplayType === 'format'; } function sameStory(left: StoryLocator | null | undefined, right: StoryLocator | null | undefined): boolean { @@ -110,13 +135,12 @@ export async function findTrackedChangeComment( type?: TrackChangeType; }, ): Promise { - const commentType = mapTrackChangeTypeToCommentType(input.type); const comments = await getCommentsSnapshot(page); const matched = comments.find((comment) => { if (comment.trackedChange !== true) return false; if (!sameStory(comment.trackedChangeStory ?? null, input.story)) return false; if (input.id && !trackedChangeIdMatches(comment, input.id)) return false; - if (commentType && comment.trackedChangeType !== commentType) return false; + if (!matchesTrackedChangeCommentType(comment, input.type)) return false; if (input.excerpt) { const haystack = [comment.trackedChangeText, comment.deletedText].filter(Boolean).join(' '); if (!haystack.includes(input.excerpt)) return false; diff --git a/tests/behavior/tests/comments/floating-comments-virtualization.spec.ts b/tests/behavior/tests/comments/floating-comments-virtualization.spec.ts index 30b15798f2..4a0aed1367 100644 --- a/tests/behavior/tests/comments/floating-comments-virtualization.spec.ts +++ b/tests/behavior/tests/comments/floating-comments-virtualization.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '../../fixtures/superdoc.js'; -import { assertDocumentApiReady, listTrackChanges, listComments } from '../../helpers/document-api.js'; +import { assertDocumentApiReady, listTrackChanges } from '../../helpers/document-api.js'; +import { getCommentsSnapshot } from '../../helpers/story-tracked-changes.js'; test.use({ config: { toolbar: 'full', comments: 'on', trackChanges: true } }); @@ -18,10 +19,28 @@ test('@behavior SD-1997: floating comment bubbles render after tracked changes', } // Verify tracked changes were created + let trackedInsertTotal = 0; await expect - .poll(async () => (await listTrackChanges(superdoc.page, { type: 'insert' })).total) + .poll(async () => { + trackedInsertTotal = (await listTrackChanges(superdoc.page, { type: 'insert' })).total; + return trackedInsertTotal; + }) .toBeGreaterThanOrEqual(5); + // The live review/comment model should also contain one tracked-change + // comment per live tracked insertion before any visual sidebar assertion runs. + await expect + .poll(async () => { + const comments = await getCommentsSnapshot(superdoc.page); + return comments.filter( + (comment) => + comment?.trackedChange === true && + comment?.trackedChangeType === 'trackInsert' && + String(comment?.trackedChangeText ?? '').length > 0, + ).length; + }) + .toBeGreaterThanOrEqual(trackedInsertTotal); + // Verify floating comment placeholders appear in the sidebar const placeholders = superdoc.page.locator('.comment-placeholder'); await expect(placeholders.first()).toBeAttached({ timeout: 10_000 }); diff --git a/tests/behavior/tests/comments/programmatic-tracked-change.spec.ts b/tests/behavior/tests/comments/programmatic-tracked-change.spec.ts index 5bb8420cb4..b149e4ae36 100644 --- a/tests/behavior/tests/comments/programmatic-tracked-change.spec.ts +++ b/tests/behavior/tests/comments/programmatic-tracked-change.spec.ts @@ -16,7 +16,7 @@ test.use({ config: { toolbar: 'full', comments: 'panel', trackChanges: true } }) async function assertTrackChangeTypeCount( superdoc: { page: Page }, - type: 'insert' | 'delete' | 'format', + type: 'insert' | 'delete' | 'replacement' | 'format', minimumCount = 1, ): Promise { await expect @@ -61,12 +61,12 @@ test('tracked replace via document-api', async ({ superdoc }) => { await superdoc.waitForStable(); // word-diff (PR #2817) fragments multi-word tracked replacements into per-word - // insert/delete pairs, so "new fancy" appears as two separate inserts around + // replacement chunks, so "new fancy" appears as separate replacements around // the surviving space token. Assert both inserted words are present rather // than a contiguous substring, which was the pre-word-diff assumption. await expect.poll(() => getDocumentText(superdoc.page)).toContain('new'); await expect.poll(() => getDocumentText(superdoc.page)).toContain('fancy'); - await assertTrackChangeTypeCount(superdoc, 'insert'); + await assertTrackChangeTypeCount(superdoc, 'replacement'); await superdoc.snapshot('programmatic-tc-replaced'); }); diff --git a/tests/behavior/tests/comments/sd-2509-replacement-update-preserves-deleted-text.spec.ts b/tests/behavior/tests/comments/sd-2509-replacement-update-preserves-deleted-text.spec.ts index da6be9553f..1853b7973d 100644 --- a/tests/behavior/tests/comments/sd-2509-replacement-update-preserves-deleted-text.spec.ts +++ b/tests/behavior/tests/comments/sd-2509-replacement-update-preserves-deleted-text.spec.ts @@ -21,7 +21,7 @@ test('SD-2509 replacement bubble preserves deleted text after follow-up edits', // Wait for the tracked change to appear await expect - .poll(async () => (await listTrackChanges(superdoc.page, { type: 'insert' })).total) + .poll(async () => (await listTrackChanges(superdoc.page, { type: 'replacement' })).total) .toBeGreaterThanOrEqual(1); // The bubble should show both the deleted and inserted text diff --git a/tests/behavior/tests/comments/tracked-change-replacement-bubble.spec.ts b/tests/behavior/tests/comments/tracked-change-replacement-bubble.spec.ts index 2b7a5f02cf..42006b6f32 100644 --- a/tests/behavior/tests/comments/tracked-change-replacement-bubble.spec.ts +++ b/tests/behavior/tests/comments/tracked-change-replacement-bubble.spec.ts @@ -1,8 +1,11 @@ import { test, expect } from '../../fixtures/superdoc.js'; import { assertDocumentApiReady, listTrackChanges } from '../../helpers/document-api.js'; +import { findTrackedChangeComment } from '../../helpers/story-tracked-changes.js'; test.use({ config: { toolbar: 'full', comments: 'on', trackChanges: true } }); +const BODY_STORY = { kind: 'story', storyType: 'body' } as const; + test('SD-1739 tracked change replacement does not duplicate text in bubble', async ({ superdoc }) => { await assertDocumentApiReady(superdoc.page); @@ -19,10 +22,17 @@ test('SD-1739 tracked change replacement does not duplicate text in bubble', asy await superdoc.waitForStable(); await expect - .poll(async () => (await listTrackChanges(superdoc.page, { type: 'insert' })).total) + .poll(async () => (await listTrackChanges(superdoc.page, { type: 'replacement' })).total) .toBeGreaterThanOrEqual(1); await expect.poll(async () => (await listTrackChanges(superdoc.page)).total).toBeGreaterThanOrEqual(1); + const replacementComment = await findTrackedChangeComment(superdoc.page, { + story: BODY_STORY, + excerpt: 'redlining', + type: 'replacement', + }); + expect(replacementComment.deletedText).toContain('editing'); + // The floating dialog should show the tracked change with correct text // (Bug SD-1739 would show "Added: redliningg" with duplicated trailing char) const dialog = superdoc.page.locator('.comment-placeholder .comments-dialog', { diff --git a/tests/behavior/tests/comments/tracked-change-sidebar-targeted-refresh.spec.ts b/tests/behavior/tests/comments/tracked-change-sidebar-targeted-refresh.spec.ts index b972adf55d..3949014d87 100644 --- a/tests/behavior/tests/comments/tracked-change-sidebar-targeted-refresh.spec.ts +++ b/tests/behavior/tests/comments/tracked-change-sidebar-targeted-refresh.spec.ts @@ -41,7 +41,7 @@ test('typing inside an existing tracked replacement refreshes inserted text and await superdoc.type('replacement'); await superdoc.waitForStable(); - await expect.poll(async () => (await listTrackChanges(superdoc.page, { type: 'insert' })).total).toBe(1); + await expect.poll(async () => (await listTrackChanges(superdoc.page, { type: 'replacement' })).total).toBe(1); await expect(insertedBubbleText(superdoc)).toContainText('replacement'); await expect(deletedBubbleText(superdoc)).toContainText('original'); diff --git a/tests/behavior/tests/navigation/extract.spec.ts b/tests/behavior/tests/navigation/extract.spec.ts index 83e58eb6b9..35cfc14eb2 100644 --- a/tests/behavior/tests/navigation/extract.spec.ts +++ b/tests/behavior/tests/navigation/extract.spec.ts @@ -101,7 +101,7 @@ test('@behavior SD-2525: doc.extract returns tracked changes', async ({ superdoc expect(result.trackedChanges.length).toBeGreaterThanOrEqual(1); const tc = result.trackedChanges[0]; expect(tc.entityId).toBeTruthy(); - expect(['insert', 'delete', 'format']).toContain(tc.type); + expect(['insert', 'delete', 'replacement', 'format']).toContain(tc.type); }); test('@behavior SD-2525: extract nodeIds work with scrollToElement', async ({ superdoc }) => {