From eaeb424711e9aef64fb0a387f6b34536323271fa Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 21 May 2026 20:42:51 -0700 Subject: [PATCH 01/25] feat: overlapping tracked changes --- .../reference/_generated-manifest.json | 2 +- apps/docs/document-api/reference/index.mdx | 2 +- .../reference/track-changes/decide.mdx | 21 +- apps/docs/document-engine/sdks.mdx | 4 +- .../src/contract/operation-definitions.ts | 6 +- packages/document-api/src/index.test.ts | 55 +- packages/document-api/src/index.ts | 2 + .../src/track-changes/track-changes.test.ts | 39 + .../src/track-changes/track-changes.ts | 109 ++- packages/document-api/src/types/receipt.ts | 2 + .../src/editors/v1/core/Editor.ts | 86 ++ .../v1/core/super-converter/SuperConverter.js | 12 + .../v2/exporter/footnotesExporter.js | 1 + .../v2/importer/docxImporter.js | 2 + .../v2/importer/importTrackingContext.js | 147 +++ .../v2/importer/markImporter.js | 13 +- .../v2/importer/markImporter.test.js | 35 + .../super-converter/v3/handlers/helpers.js | 17 +- .../v3/handlers/w/del/del-translator.js | 77 +- .../v3/handlers/w/del/del-translator.test.js | 33 + .../v3/handlers/w/ins/ins-translator.js | 77 +- .../v3/handlers/w/ins/ins-translator.test.js | 84 ++ .../v3/handlers/w/r/r-translator.js | 5 +- .../assemble-adapters.ts | 2 + ...xtract-adapter.consumer-simulation.test.ts | 75 +- .../document-api-adapters/extract-adapter.ts | 8 +- .../helpers/tracked-change-refs.ts | 9 +- .../helpers/tracked-change-resolver.test.ts | 74 +- .../helpers/tracked-change-resolver.ts | 86 +- .../track-changes-wrappers.test.ts | 187 +++- .../plan-engine/track-changes-wrappers.ts | 150 ++- .../__tests__/tracked-change-index.test.ts | 29 + .../tracked-changes/tracked-change-index.ts | 4 +- .../write-adapter.test.ts | 32 + .../v1/document-api-adapters/write-adapter.ts | 9 + .../track-changes/permission-helpers.js | 31 +- .../track-changes/permission-helpers.test.js | 64 +- .../review-model/comment-effects.js | 149 +++ .../review-model/comment-effects.test.js | 73 ++ .../review-model/decision-engine.js | 893 ++++++++++++++++++ .../review-model/decision-engine.test.js | 604 ++++++++++++ .../track-changes/review-model/edit-intent.js | 225 +++++ .../review-model/graph-invariants.js | 51 + .../track-changes/review-model/identity.js | 130 +++ .../review-model/identity.test.js | 102 ++ .../review-model/import-context.js | 185 ++++ .../review-model/import-context.test.js | 101 ++ .../import-diagnostics-integration.test.js | 58 ++ .../review-model/import-diagnostics.js | 298 ++++++ .../review-model/import-diagnostics.test.js | 174 ++++ .../track-changes/review-model/index.js | 64 ++ .../review-model/mark-metadata.js | 373 ++++++++ .../review-model/mark-metadata.test.js | 149 +++ .../review-model/overlap-compiler.js | 881 +++++++++++++++++ .../review-model/overlap-compiler.test.js | 642 +++++++++++++ .../review-model/review-graph.js | 770 +++++++++++++++ .../review-model/review-graph.perf.test.js | 60 ++ .../review-model/review-graph.test.js | 403 ++++++++ .../review-model/segment-index.js | 58 ++ .../review-model/story-locator.js | 69 ++ .../review-model/test-fixtures.js | 127 +++ .../review-model/word-id-allocator.js | 146 +++ .../review-model/word-id-allocator.test.js | 123 +++ .../track-changes-overlap-decisions.test.js | 107 +++ .../track-changes-overlap-history.test.js | 152 +++ .../track-changes-overlap.test.js | 217 +++++ .../extensions/track-changes/track-changes.js | 362 ++++--- .../extensions/track-changes/track-delete.js | 43 + .../extensions/track-changes/track-format.js | 43 + .../extensions/track-changes/track-insert.js | 50 + .../trackChangesHelpers/addMarkStep.js | 42 + .../trackChangesHelpers/getTrackChanges.js | 1 + .../trackChangesHelpers/markDeletion.js | 9 +- .../trackChangesHelpers/removeMarkStep.js | 36 + .../trackChangesHelpers/replaceAroundStep.js | 3 + .../trackChangesHelpers/replaceStep.js | 119 +++ .../trackChangesHelpers/trackedTransaction.js | 1 + .../trackChangesRoundtrip-overlap.test.js | 328 +++++++ 78 files changed, 9746 insertions(+), 266 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v2/importer/importTrackingContext.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/comment-effects.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/comment-effects.test.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.test.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/edit-intent.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/graph-invariants.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.test.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-context.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-context.test.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-diagnostics-integration.test.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-diagnostics.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/import-diagnostics.test.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/index.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/mark-metadata.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/mark-metadata.test.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.perf.test.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.test.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/segment-index.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/story-locator.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/test-fixtures.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/word-id-allocator.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/word-id-allocator.test.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-overlap-decisions.test.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-overlap-history.test.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-overlap.test.js create mode 100644 packages/super-editor/src/editors/v1/tests/import-export/trackChangesRoundtrip-overlap.test.js diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 4efc168b52..f14ad2c32b 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -1077,5 +1077,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "266b6bb8fa042ebd94c3da6e11652471f49c40a061960f3df9055cf64c4bcbbe" + "sourceHash": "8c6adfd1bbb1226a5847d6a791d7e07cfe994fffe9855fe845de5e196f1f3498" } diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index 49a58b3833..7cbeee530f 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -243,7 +243,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. | #### 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..98764f09be 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." --- {/* 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. - Operation ID: `trackChanges.decide` - API member path: `editor.doc.trackChanges.decide(...)` @@ -20,7 +20,7 @@ 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 @@ -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"`, `"CAPABILITY_UNAVAILABLE"`, `"PERMISSION_DENIED"`, `"COMMENT_CASCADE_PARTIAL"` | | `failure.details` | any | no | | | `failure.message` | string | yes | | | `success` | `false` | yes | Constant: `false` | @@ -97,6 +97,9 @@ Returns a Receipt confirming the decision was applied; reports NO_OP if the chan ## Non-applied failure codes - `NO_OP` +- `CAPABILITY_UNAVAILABLE` +- `PERMISSION_DENIED` +- `COMMENT_CASCADE_PARTIAL` ## Raw schemas @@ -169,7 +172,10 @@ Returns a Receipt confirming the decision was applied; reports NO_OP if the chan "properties": { "code": { "enum": [ - "NO_OP" + "NO_OP", + "CAPABILITY_UNAVAILABLE", + "PERMISSION_DENIED", + "COMMENT_CASCADE_PARTIAL" ] }, "details": {}, @@ -216,7 +222,10 @@ Returns a Receipt confirming the decision was applied; reports NO_OP if the chan "properties": { "code": { "enum": [ - "NO_OP" + "NO_OP", + "CAPABILITY_UNAVAILABLE", + "PERMISSION_DENIED", + "COMMENT_CASCADE_PARTIAL" ] }, "details": {}, diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index c886ed7c82..f94c069e0e 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -1006,7 +1006,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. | #### History @@ -1484,7 +1484,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. | #### History diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index 7920d9b8d0..5a23fa6520 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -2482,15 +2482,15 @@ 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.', 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', 'CAPABILITY_UNAVAILABLE', 'PERMISSION_DENIED', 'COMMENT_CASCADE_PARTIAL'], throws: [...T_NOT_FOUND_CAPABLE, 'INVALID_INPUT', 'INVALID_TARGET'], }), referenceDocPath: 'track-changes/decide.mdx', diff --git a/packages/document-api/src/index.test.ts b/packages/document-api/src/index.test.ts index 2e27a3c8df..6afcce9348 100644 --- a/packages/document-api/src/index.test.ts +++ b/packages/document-api/src/index.test.ts @@ -161,6 +161,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 })), }; } @@ -843,6 +844,14 @@ 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 rejectAllResult = api.trackChanges.decide({ decision: 'reject', target: { scope: 'all' } }); @@ -853,10 +862,48 @@ describe('createDocumentApi', () => { 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.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 +1012,7 @@ describe('createDocumentApi', () => { expectError( () => api.trackChanges.decide({ decision: 'accept', target: 'tc-1' } as any), 'INVALID_TARGET', - '{ id: string } or { scope: "all" }', + '{ kind: "id" | "range" | "all" }', ); }); @@ -974,7 +1021,7 @@ describe('createDocumentApi', () => { expectError( () => api.trackChanges.decide({ decision: 'accept', target: null } as any), 'INVALID_TARGET', - '{ id: string } or { scope: "all" }', + '{ kind: "id" | "range" | "all" }', ); }); @@ -983,7 +1030,7 @@ describe('createDocumentApi', () => { expectError( () => api.trackChanges.decide({ decision: 'accept', target: { foo: 'bar' } } as any), 'INVALID_TARGET', - '{ id: string } or { scope: "all" }', + '{ kind: "id" | "range" | "all" }', ); }); @@ -992,7 +1039,7 @@ describe('createDocumentApi', () => { expectError( () => api.trackChanges.decide({ decision: 'accept', target: { id: '' } } as any), 'INVALID_TARGET', - '{ id: string } or { scope: "all" }', + '{ kind: "id" | "range" | "all" }', ); }); }); diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index 5f09bdf468..b35e2084b2 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -1028,7 +1028,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'; 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..8e1a65f9d6 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,43 @@ 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' }, + }); + }); }); diff --git a/packages/document-api/src/track-changes/track-changes.ts b/packages/document-api/src/track-changes/track-changes.ts index 4dda0c2f5a..facf82203d 100644 --- a/packages/document-api/src/track-changes/track-changes.ts +++ b/packages/document-api/src/track-changes/track-changes.ts @@ -1,5 +1,6 @@ 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'; @@ -27,15 +28,43 @@ export type TrackChangesAcceptAllInput = Record; 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 // --------------------------------------------------------------------------- +/** + * Canonical decide input shape per + * `plans/tracked-changes-comments/tracked-changes-spec.md` § 9. The legacy + * `{ id }` and `{ scope: 'all' }` aliases are preserved during the migration + * window so existing headless callers keep working; the executor normalizes + * them into the canonical `{ kind: ... }` form before dispatch. + */ +export type ReviewDecisionTarget = + | { kind: 'id'; id: string; story?: StoryLocator } + | { kind: 'range'; range: TextTarget; story?: StoryLocator; part?: string } + | { kind: 'all'; story?: StoryLocator | 'all' } + // Legacy aliases — kept for backwards compatibility with the previous + // call shape. Emitted as deprecation diagnostics during normalization. + | { id: string; story?: StoryLocator; kind?: undefined } + | { scope: 'all'; kind?: undefined }; + 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. */ @@ -50,6 +79,17 @@ export interface TrackChangesAdapter { acceptAll(input: TrackChangesAcceptAllInput, options?: RevisionGuardOptions): Receipt; /** Reject all tracked changes in the document. */ 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 +162,78 @@ 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 { kind: "id" | "range" | "all" }.', { field: 'target', value: input.target }, ); } const target = input.target as Record; - const isAll = target.scope === 'all'; + const story = (target as { story?: StoryLocator }).story; + const decision = input.decision as 'accept' | 'reject'; - if (!isAll) { + // Canonical shape: `{ kind: 'id' | 'range' | 'all' }`. + if (target.kind === 'id') { if (typeof target.id !== 'string' || target.id.length === 0) { throw new DocumentApiValidationError( 'INVALID_TARGET', - 'trackChanges.decide target must have { id: string } or { scope: "all" }.', + 'trackChanges.decide target.kind = "id" requires 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); } - const story = (target as { story?: StoryLocator }).story; + if (target.kind === 'range') { + 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); + } + + if (target.kind === 'all') { + if (decision === 'accept') return adapter.acceptAll({} as TrackChangesAcceptAllInput, options); + return adapter.rejectAll({} as TrackChangesRejectAllInput, options); + } + + // Legacy aliases — `{ id }` / `{ scope: 'all' }`. Preserved for backwards + // compatibility per the closed product decision in `phase0-checkpoint.md`. + const isAll = target.scope === 'all'; + if (!isAll) { + if (typeof target.id !== 'string' || target.id.length === 0) { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + 'trackChanges.decide target must have { kind: "id" | "range" | "all" } or the legacy { id } / { scope: "all" } shape.', + { field: 'target', value: input.target }, + ); + } + } - if (input.decision === 'accept') { + if (decision === 'accept') { if (isAll) return adapter.acceptAll({} as TrackChangesAcceptAllInput, options); return adapter.accept({ id: target.id as string, ...(story ? { story } : {}) }, options); } - if (isAll) return adapter.rejectAll({} as TrackChangesRejectAllInput, options); return adapter.reject({ id: target.id as string, ...(story ? { story } : {}) }, options); } 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/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index 54a7583657..5d4f93bfaf 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -34,6 +34,7 @@ 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 } from '@extensions/track-changes/review-model/word-id-allocator.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'; @@ -3195,6 +3196,84 @@ 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.wordIdAllocator = allocator; + } + /** * Export the editor document to DOCX. * @@ -3249,6 +3328,13 @@ export class Editor extends EventEmitter { // 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( 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..5fbdc441a6 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 @@ -212,6 +212,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 = []; @@ -1303,6 +1311,7 @@ class SuperConverter { preserveSdtWrappers = false, statFieldCacheMap = undefined, existingRelationships = [], + partPath = 'word/document.xml', }) { const bodyNode = this.savedTagsToRestore.find((el) => el.name === 'w:body'); @@ -1340,6 +1349,7 @@ class SuperConverter { preserveSdtWrappers, statFieldCacheMap: resolvedCacheMap, existingRelationships, + currentPartPath: partPath, }); return { result, params }; @@ -1500,6 +1510,7 @@ class SuperConverter { isHeaderFooter: true, isFinalDoc, existingRelationships, + partPath, }); const bodyContent = result.elements[0].elements; @@ -1566,6 +1577,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..81dc17383f 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 @@ -324,6 +324,7 @@ const createNodeListHandler = (nodeHandlers) => { parentStyleId, lists, inlineDocumentFonts, + importTrackingContext, path = [], extraParams = {}, }) => { @@ -359,6 +360,7 @@ const createNodeListHandler = (nodeHandlers) => { parentStyleId, lists, inlineDocumentFonts, + importTrackingContext, path, extraParams, }); 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..2ec19303fd --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/importTrackingContext.js @@ -0,0 +1,147 @@ +// @ts-check +import { createImportTrackingContext, withParentFrame } from '@extensions/track-changes/review-model/import-context.js'; + +const contextsByConverter = new WeakMap(); + +/** + * @typedef {{ + * trackedChangeIdMapsByPart?: Map>, + * trackedChangeIdMap?: 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} 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 trackedChangeIdMap = getTrackedChangeIdMapForPart(params, partPath); + return { + partPath, + sourceId: id, + 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/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..983e8f8204 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,36 @@ 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; + const exportSourceId = + typeof sourceId === 'string' || typeof sourceId === 'number' || sourceId == null ? sourceId : 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..5528776bed 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,36 @@ 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; + const exportSourceId = + typeof sourceId === 'string' || typeof sourceId === 'number' || sourceId == null ? sourceId : 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/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/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..c2f980ff2e 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 @@ -148,12 +148,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 remain separate source-wrapper entities, + // while each span still resolves to the correct public tracked-change entry. const delEntity = deleteSpans[0].trackedChanges!.find((c) => c.type === 'delete')!.entityId; const insEntity = insertSpans[0].trackedChanges!.find((c) => c.type === 'insert')!.entityId; - expect(insEntity).toBe(delEntity); + const entitiesById = new Map(result.trackedChanges.map((tc) => [tc.entityId, tc])); + expect(insEntity).not.toBe(delEntity); + expect(entitiesById.get(delEntity)?.wordRevisionIds?.delete).toBeTruthy(); + expect(entitiesById.get(insEntity)?.wordRevisionIds?.insert).toBeTruthy(); }); it('attaches every tracked change to the blocks it lives in via blockIds', async () => { @@ -218,11 +220,10 @@ describe('extract-adapter consumer simulation (SD-2766)', () => { } }); - 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 +244,37 @@ 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).not.toBe(insertEntityId); + + const deleteEntity = result.trackedChanges.find((tc) => tc.entityId === deleteEntityId)!; + const insertEntity = result.trackedChanges.find((tc) => tc.entityId === insertEntityId)!; + expect(deleteEntity.wordRevisionIds?.delete).toBeTruthy(); + expect(insertEntity.wordRevisionIds?.insert).toBeTruthy(); + expect(deleteEntity.blockIds).toEqual(insertEntity.blockIds); + + 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.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/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..16f8266ae4 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 @@ -65,7 +65,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 +74,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 +168,37 @@ 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('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 +276,25 @@ 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(deleteChange!.id); + expect(map.get('tc-1')).toBeUndefined(); + }); + 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..cdcee5d412 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 @@ -24,12 +24,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 +39,12 @@ export type GroupedTrackedChange = { hasDelete: boolean; hasFormat: boolean; attrs: Record; + excerpt?: string; wordRevisionIds?: TrackChangeWordRevisionIds; }; type ChangeTypeInput = Pick; +type GroupedTrackedChangeDraft = Omit & { excerptParts: string[] }; function getRawTrackedMarks(editor: Editor): RawTrackedMark[] { try { @@ -92,7 +96,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) ?? ''; @@ -134,36 +141,82 @@ 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; +} + +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 = !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 +232,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,10 +245,18 @@ 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); @@ -209,7 +273,7 @@ export function resolveTrackedChange(editor: Editor, id: string): GroupedTracked export function toCanonicalTrackedChangeId(editor: Editor, rawId: string): string | null { const grouped = groupTrackedChanges(editor); - return grouped.find((item) => item.rawId === rawId)?.id ?? null; + return grouped.find((item) => item.rawId === rawId || item.commandRawId === rawId)?.id ?? null; } export function buildTrackedChangeCanonicalIdMap(editor: Editor): Map { @@ -316,5 +380,5 @@ export function resolveTrackedChangeInStory( */ function findMatchingChange(editor: Editor, id: string): GroupedTrackedChange | null { const grouped = groupTrackedChanges(editor); - return grouped.find((item) => item.id === id || item.rawId === id) ?? null; + return grouped.find((item) => item.id === id || item.rawId === id || item.commandRawId === id) ?? null; } 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..362fd0e3de 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 @@ -33,7 +33,11 @@ vi.mock('../story-runtime/resolve-story-runtime.js', () => ({ resolveStoryRuntime: mocks.resolveStoryRuntime, })); -import { trackChangesAcceptAllWrapper, trackChangesAcceptWrapper } from './track-changes-wrappers.js'; +import { + trackChangesAcceptAllWrapper, + trackChangesAcceptWrapper, + trackChangesDecideRangeWrapper, +} from './track-changes-wrappers.js'; const footnoteStory: StoryLocator = { kind: 'story', storyType: 'footnote', noteId: '5' }; @@ -44,6 +48,72 @@ function makeEditor(commands: Record = {}): Editor { } 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'); @@ -100,6 +170,47 @@ 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('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 +259,78 @@ describe('track-changes-wrappers revision guard', () => { expect(index.invalidate).toHaveBeenCalledWith(bodyStory); 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, + }, + }, + }); + }); }); 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..2769ff6e34 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, @@ -31,6 +33,7 @@ import { buildResolvedHandle, buildDiscoveryItem, buildDiscoveryResult } from '@ import { DocumentApiAdapterError } from '../errors.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 { getTrackedChangeIndex } from '../tracked-changes/tracked-change-index.js'; @@ -86,6 +89,30 @@ 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, + }, + }; +} + function resolveListScope(input: TrackChangesListInput | undefined): 'body' | 'all' | { story: StoryLocator } { if (!input || input.in === undefined) return 'body'; if (input.in === 'all') return 'all'; @@ -173,9 +200,9 @@ export function trackChangesGetWrapper(editor: Editor, input: TrackChangesGetInp 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: + (resolved.change.excerpt !== undefined ? resolved.change.excerpt : undefined) ?? + normalizeExcerpt(resolved.editor.state.doc.textBetween(resolved.change.from, resolved.change.to, ' ', '\ufffc')), }; } @@ -211,13 +238,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) { @@ -282,7 +314,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; } } @@ -300,7 +334,10 @@ function decideAll(editor: Editor, decision: ReviewDecision, options: RevisionGu } 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 }; @@ -321,3 +358,96 @@ export function trackChangesRejectAllWrapper( ): Receipt { return decideAll(editor, 'reject', 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' }); + 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..1e33501464 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 @@ -293,4 +293,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..0b461cfc46 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 @@ -195,7 +195,9 @@ class TrackedChangeIndexImpl implements TrackedChangeIndex { 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, 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/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..5baf4447a6 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) }), ); }); @@ -167,4 +167,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/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..4f7f1ad09d --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/comment-effects.js @@ -0,0 +1,149 @@ +// @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<{ 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. + * @returns {CommentEffectsPlan} + */ +export const planCommentEffects = ({ doc, removedRanges }) => { + /** @type {CommentEffectsPlan} */ + const plan = { nodeDeletes: [], entityDeletes: [], entityShrinks: [], diagnostics: [] }; + if (!doc || !removedRanges?.length) 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); + + 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 cause = sortedRemoved[0]?.cause ?? 'trackedChange'; + + // 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 }); + 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, + 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. + } + } + + 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..4ec93f6271 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js @@ -0,0 +1,893 @@ +// @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 }>} 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(change.author ? { author: change.author, authorEmail: change.authorEmail } : {}), + }); + 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, 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} 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 {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 && (change.type === CanonicalChangeType.Insertion || change.type === CanonicalChangeType.Deletion)) { + const partialResult = planPartialTextDecision({ + ops, + change, + selection, + decision, + removedRanges, + retired, + diagnostics, + }); + 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, state }); + } 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 }); + + // 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) { + const segment = + change.insertedSegments.find((s) => s.from <= range.from && s.to >= range.to) ?? change.insertedSegments[0]; + if (!segment) continue; + ops.push({ + kind: 'removeMark', + from: range.from, + to: range.to, + changeId: change.id, + side: SegmentSide.Inserted, + mark: segment.mark, + }); + } + 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) { + const segment = + change.deletedSegments.find((s) => s.from <= range.from && s.to >= range.to) ?? change.deletedSegments[0]; + if (!segment) continue; + ops.push({ + kind: 'removeMark', + from: range.from, + to: range.to, + changeId: change.id, + side: SegmentSide.Deleted, + mark: segment.mark, + }); + } + 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) { + ops.push({ + kind: 'removeMark', + from: seg.from, + to: seg.to, + changeId: change.id, + side: SegmentSide.Inserted, + mark: seg.mark, + }); + } + } 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) { + ops.push({ + kind: 'removeMark', + from: seg.from, + to: seg.to, + changeId: change.id, + side: SegmentSide.Deleted, + mark: seg.mark, + }); + } + } + retired.add(change.id); + return { ok: true }; +}; + +const planFormattingDecision = ({ ops, change, decision, retired }) => { + for (const seg of change.formattingSegments) { + if (decision === 'accept') { + ops.push({ + kind: 'removeMark', + from: seg.from, + to: seg.to, + changeId: change.id, + side: SegmentSide.Formatting, + mark: seg.mark, + }); + } else { + ops.push({ + kind: 'restoreFormat', + from: seg.from, + to: seg.to, + changeId: change.id, + side: SegmentSide.Formatting, + mark: seg.mark, + beforeMarks: seg.mark.attrs?.before ?? [], + afterMarks: seg.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) { + ops.push({ kind: 'removeMark', from: segment.from, to: segment.to, changeId: change.id, side, mark: segment.mark }); + + 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 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, + 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, resolvedByEmail?: string, resolvedByName?: string }>} + */ +export const buildDecisionBubbleEvents = ({ result, editor }) => { + const resolvedByEmail = editor?.options?.user?.email; + const resolvedByName = editor?.options?.user?.name; + const events = []; + for (const entry of result.receipt.removedChangeIds) { + events.push({ type: 'trackedChange', event: 'resolve', changeId: entry.id, resolvedByEmail, resolvedByName }); + } + for (const child of result.receipt.affectedChildren) { + events.push({ type: 'trackedChange', event: 'resolve', changeId: child.changeId, 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..9f5a719d84 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.test.js @@ -0,0 +1,604 @@ +// @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('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..f7e4df66d5 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/edit-intent.js @@ -0,0 +1,225 @@ +// @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] + */ + +/** + * @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 {Array} [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, + * }} input + * @returns {TrackedEditIntent} + */ +export const makeTextInsertIntent = ({ at, content, schema, user, date, source, replacementGroupHint }) => { + 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 } : {}), + }; +}; + +/** + * Build a `text-delete` intent. + * + * @param {{ + * from: number, + * to: number, + * user: TrackedEditIntentUser, + * date: string, + * source: EditIntentSource, + * replacementGroupHint?: string, + * }} input + * @returns {TrackedEditIntent} + */ +export const makeTextDeleteIntent = ({ from, to, user, date, source, replacementGroupHint }) => { + 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 } : {}), + }; +}; + +/** + * 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, + * }} input + * @returns {TrackedEditIntent} + */ +export const makeTextReplaceIntent = ({ + from, + to, + content, + schema, + replacements, + user, + date, + source, + replacementGroupHint, +}) => { + 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 } : {}), + }; +}; + +/** + * 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..79d06930dd --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.js @@ -0,0 +1,130 @@ +// @ts-check +/** + * Identity helpers for the review graph. + * + * Same-user behavior requires high-confidence identity. A trusted author email + * match on both the current editor user and the change author's stored email + * is the only signal that returns `same-user`. Every other combination — + * missing email on either side, only display-name match, imported author + * strings without a trusted email, or mismatched email — returns a + * different-user classification. + */ + +/** + * Trim and lowercase an email value. Anything not a string normalizes to ''. + * @param {unknown} value + * @returns {string} + */ +export const normalizeEmail = (value) => { + if (typeof value !== 'string') return ''; + const trimmed = value.trim(); + if (!trimmed) return ''; + return trimmed.toLowerCase(); +}; + +/** + * @typedef {Object} UserIdentity + * @property {string} email normalized email, '' when unknown. + * @property {string} name display name (may be empty). + * @property {boolean} hasEmail true when normalized email is non-empty. + */ + +/** + * 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?: { name?: unknown, email?: unknown } } } | null | undefined} editor + * @returns {UserIdentity} + */ +export const getCurrentUserIdentity = (editor) => { + const user = editor?.options?.user ?? null; + const email = normalizeEmail(user?.email); + const name = typeof user?.name === 'string' ? user.name : ''; + return { email, name, 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 { email: '', name: '', hasEmail: false }; + + // Accept either { attrs: {...} }, { mark: { attrs } }, or a flat attrs map. + const attrs = changeOrAttrs.attrs ?? changeOrAttrs.mark?.attrs ?? changeOrAttrs; + + const email = normalizeEmail(attrs?.authorEmail); + const name = typeof attrs?.author === 'string' ? attrs.author : ''; + return { email, name, hasEmail: email.length > 0 }; +}; + +/** + * @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 (per plan): + * - 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 ?? { email: '', name: '', hasEmail: false }; + const auth = change ?? { email: '', name: '', hasEmail: false }; + + if (!cur.hasEmail) return 'unknown-current-user'; + if (!auth.hasEmail) return 'unknown-change-author'; + + if (cur.email === auth.email) { + // Same email but obviously different display name pattern is still + // 'same-user' — display name is not a security signal. Only flag + // `conflicting` if the change carries an explicit `importedAuthor` + // mismatch with a different display, which is an import-provenance + // signal that should NOT be treated as ordinary same-user refinement. + if ( + typeof change?.importedAuthor === 'string' && + change.importedAuthor.trim() && + cur.name && + change.importedAuthor.trim().toLowerCase() !== cur.name.trim().toLowerCase() && + change.name && + change.name !== cur.name + ) { + return 'conflicting'; + } + 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'; 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..d73e9d0908 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/identity.test.js @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import { + normalizeEmail, + getCurrentUserIdentity, + getChangeAuthorIdentity, + classifyOwnership, + isSameUserHighConfidence, +} 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: { name: 'Alice', email: 'Alice@example.com' } } }; + expect(getCurrentUserIdentity(editor)).toEqual({ + email: 'alice@example.com', + name: 'Alice', + hasEmail: true, + }); + }); + it('returns empty identity for missing editor/user', () => { + expect(getCurrentUserIdentity(undefined)).toEqual({ email: '', name: '', hasEmail: false }); + expect(getCurrentUserIdentity({ options: {} })).toEqual({ email: '', name: '', hasEmail: false }); + }); + }); + + describe('getChangeAuthorIdentity', () => { + it('reads from a raw mark', () => { + const mark = { attrs: { author: 'Bob', authorEmail: 'BOB@example.com' } }; + expect(getChangeAuthorIdentity(mark)).toEqual({ email: 'bob@example.com', name: 'Bob', hasEmail: true }); + }); + it('reads from flat attrs', () => { + expect(getChangeAuthorIdentity({ author: 'Carol', authorEmail: 'carol@example.com' })).toEqual({ + email: 'carol@example.com', + name: 'Carol', + hasEmail: true, + }); + }); + it('returns empty for null', () => { + expect(getChangeAuthorIdentity(null)).toEqual({ email: '', name: '', hasEmail: false }); + }); + }); + + describe('classifyOwnership', () => { + const alice = { email: 'alice@example.com', name: 'Alice', hasEmail: true }; + const bob = { email: 'bob@example.com', name: 'Bob', hasEmail: true }; + + it('returns same-user for matching emails', () => { + expect(classifyOwnership({ currentUser: alice, change: { ...alice } })).toBe('same-user'); + }); + it('returns different-user for distinct emails', () => { + expect(classifyOwnership({ currentUser: alice, change: bob })).toBe('different-user'); + }); + it('returns unknown-current-user when current email is missing', () => { + expect(classifyOwnership({ currentUser: { email: '', name: '', hasEmail: false }, change: bob })).toBe( + 'unknown-current-user', + ); + }); + it('returns unknown-change-author when change email is missing', () => { + expect(classifyOwnership({ currentUser: alice, change: { email: '', name: 'B', hasEmail: false } })).toBe( + 'unknown-change-author', + ); + }); + it('display-name-only never matches', () => { + expect( + classifyOwnership({ + currentUser: { email: '', name: 'Alice', hasEmail: false }, + change: { email: '', name: 'Alice', 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); + }); + }); +}); 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..29643495a2 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/mark-metadata.js @@ -0,0 +1,373 @@ +// @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', +}); + +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') { + 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 {}; +}; + +const canonicalSourceIdsFromObject = (obj) => { + 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} 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), + 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.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..c54a0909f9 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js @@ -0,0 +1,881 @@ +// @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 } 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, +} from './identity.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 {{ + * ok: true, + * tr: import('prosemirror-state').Transaction, + * createdChangeIds: string[], + * updatedChangeIds: string[], + * removedChangeIds: string[], + * remappedChangeIds: Array<{ from: string, to: string }>, + * selection?: SelectionHint, + * diagnostics?: GraphDiagnostic[], + * insertedMark?: import('prosemirror-model').Mark, + * deletionMarks?: import('prosemirror-model').Mark[], + * formatMarks?: import('prosemirror-model').Mark[], + * insertedFrom?: number, + * insertedTo?: number, + * } | { + * ok: false, + * code: 'CAPABILITY_UNAVAILABLE'|'INVALID_TARGET'|'PRECONDITION_FAILED', + * message: string, + * details?: unknown, + * }} TrackedEditResult + */ + +const SUPPORTED_KINDS = new Set(['text-insert', 'text-delete', 'text-replace', 'format-apply', 'format-remove']); + +/** + * 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 ${intent.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: [], + }; +}; + +const failure = (code, message, details) => ({ ok: false, code, message, ...(details ? { 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'; +}; + +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 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 || '', + 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 || '', + 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 +// --------------------------------------------------------------------------- + +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; + + // Same-user refinement targets: own insertion that strictly contains `at`, + // OR an own-insertion edge we are adjacent to. Adjacent same-user own + // insertion still refines the same id (extend the run). + const refinementTarget = + overlapParent && overlapParent.side === SegmentSide.Inserted && classifySegment(ctx, overlapParent) === 'same-user' + ? overlapParent + : boundaryAdjacent && + boundaryAdjacent.side === SegmentSide.Inserted && + classifySegment(ctx, boundaryAdjacent) === 'same-user' + ? boundaryAdjacent + : 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 }); +}; + +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); + + 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, + }; +}; + +// --------------------------------------------------------------------------- +// 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 + * - own-deletion → no-op (preserve) + * - other-deletion → preserve parent; child trackDelete with overlapParentId + * - live content → trackDelete mark + * + * @param {*} ctx + * @param {import('./edit-intent.js').TrackedEditIntent & { kind: 'text-delete' }} intent + */ +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) 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 }, + deletionMarks: result.deletionMarks, + }; +}; + +/** + * 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 }} options + */ +const applyTrackedDelete = ( + ctx, + from, + to, + { + replacementGroupId, + replacementSideId, + sharedDeletionId, + recordSharedDeletionId = false, + recordCollapsedIds = true, + }, +) => { + /** @type {Array} */ + const deletionMarks = []; + // 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'|'reassign'|'mark-delete'|'noop', from: number, to: number, changeId?: string, parentId?: string, parentSide?: string, parentReplacementGroupId?: string }>} */ + const ops = []; + + 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 ownership = classifySegment(ctx, segmentAtPos ?? { attrs: insertMark.attrs }); + if (ownership === 'same-user') { + // 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, parentId, parentSide: SegmentSide.Inserted }); + return; + } + + if (existingDelete) { + const ownership = classifySegment(ctx, { attrs: existingDelete.attrs }); + if (ownership === 'same-user') { + // Inside own deletion → no semantic change (preserve original). + ops.push({ kind: 'noop', from: segFrom, to: segTo }); + return; + } + // Inside different-user deletion → child trackDelete with overlapParentId. + ops.push({ + kind: 'mark-delete', + from: segFrom, + to: segTo, + parentId: existingDelete.attrs.id, + parentSide: SegmentSide.Deleted, + }); + return; + } + + // Live content. + ops.push({ kind: 'mark-delete', from: segFrom, to: segTo }); + }); + + 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 === '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 (!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, deletionId }; +}; + +// --------------------------------------------------------------------------- +// text-replace +// --------------------------------------------------------------------------- + +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) { + const deleteResult = applyTrackedDelete(ctx, intent.from, intent.to, { + replacementGroupId: '', + replacementSideId: '', + sharedDeletionId: null, + recordCollapsedIds: false, + }); + if (!deleteResult.ok) 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 }); + } + + // Paired vs independent: in paired mode share one id between insert+delete + // sides so the logical change projects as a `replacement` in the graph. + const sharedId = intent.replacements === 'paired' ? intent.replacementGroupHint || uuidv4() : null; + const replacementGroupId = sharedId ?? ''; + const replacementSideId = sharedId ? `${sharedId}#deleted` : ''; + const replacementParentId = getReplacementParentId(ctx, segments); + + // Step 1 — tracked delete (collapses own insertions, marks live/other content). + if (intent.from !== intent.to) { + const delResult = applyTrackedDelete(ctx, intent.from, intent.to, { + replacementGroupId, + replacementSideId, + sharedDeletionId: sharedId, + }); + if (!delResult.ok) return delResult; + if (sharedId && delResult.deletionMarks?.length) { + ctx.createdChangeIds.push(sharedId); + } + } + + // Step 2 — tracked insert at the original `from`. Recompute graph context + // after the deletion so own-insertion collapse adjustments don't push the + // insertion past the intended cursor. + if (sanitizedSlice.content.size) { + // We must re-resolve the insertion position because collapsed + // own-insertion content shrinks the doc. + const insertId = sharedId ?? intent.replacementGroupHint ?? uuidv4(); + const insertMark = makeInsertMark(ctx, { + id: insertId, + overlapParentId: replacementParentId, + replacementGroupId, + replacementSideId: sharedId ? `${sharedId}#inserted` : '', + }); + const insertPos = clampToDocSize(ctx.tr.doc.content.size, intent.from); + const insertResult = applyInsert(ctx, insertPos, sanitizedSlice, insertMark, insertId, { + create: sharedId ? false : true, + update: sharedId ? true : false, + }); + if (!insertResult.ok) return insertResult; + return { + ...insertResult, + selection: { kind: 'near', pos: insertResult.insertedTo, bias: 1 }, + }; + } + + 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 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 (SD-486 folding) +// --------------------------------------------------------------------------- + +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.`); + } + + // Walk segments in range. For each contiguous subrange: + // - if covered by same-user own insertion (or replacement inserted side), + // directly apply/remove the mark (SD-486 fold). + // - if covered by other-user inserted content, defer to trackFormat + // creation. To minimize compiler/legacy duplication we leave this to + // the existing addMarkStep/removeMarkStep helper by returning a hint; + // however since the compiler must drive consistent semantics, we + // directly create the formatting change here over the entire other- + // content range using the same canonical attrs. + // - if mixed structural ranges (paragraph boundaries we can't safely + // model under the tracked text scope), fail closed. + const subranges = computeFormatSubranges(ctx, intent.from, intent.to); + if (!subranges) return failure('CAPABILITY_UNAVAILABLE', 'format range crosses unsupported structural boundary.'); + + const trackFormatType = ctx.schema.marks[TrackFormatMarkName]; + if (!trackFormatType) return failure('CAPABILITY_UNAVAILABLE', 'schema is missing trackFormat mark.'); + + /** @type {Array} */ + const formatMarks = []; + + for (const range of subranges) { + if (intent.kind === 'format-apply') { + if (range.fold) { + ctx.tr.addMark(range.from, range.to, intent.mark); + } else { + ctx.tr.addMark(range.from, range.to, intent.mark); + const formatMark = trackFormatType.create({ + id: uuidv4(), + author: ctx.intent.user.name || '', + authorEmail: ctx.intent.user.email || '', + authorImage: ctx.intent.user.image || '', + date: ctx.intent.date, + before: [], + after: [{ type: intent.mark.type.name, attrs: intent.mark.attrs }], + sourceId: '', + importedAuthor: '', + revisionGroupId: '', + splitFromId: '', + changeType: CanonicalChangeType.Formatting, + replacementGroupId: '', + replacementSideId: '', + overlapParentId: range.parentId || '', + sourceIds: null, + origin: '', + }); + ctx.tr.addMark(range.from, range.to, formatMark); + formatMarks.push(formatMark); + ctx.createdChangeIds.push(formatMark.attrs.id); + } + } else { + if (range.fold) { + ctx.tr.removeMark(range.from, range.to, intent.mark); + } else { + ctx.tr.removeMark(range.from, range.to, intent.mark); + const formatMark = trackFormatType.create({ + id: uuidv4(), + author: ctx.intent.user.name || '', + authorEmail: ctx.intent.user.email || '', + authorImage: ctx.intent.user.image || '', + date: ctx.intent.date, + before: [{ type: intent.mark.type.name, attrs: intent.mark.attrs }], + after: [], + sourceId: '', + importedAuthor: '', + revisionGroupId: '', + splitFromId: '', + changeType: CanonicalChangeType.Formatting, + replacementGroupId: '', + replacementSideId: '', + overlapParentId: range.parentId || '', + sourceIds: null, + origin: '', + }); + ctx.tr.addMark(range.from, range.to, formatMark); + formatMarks.push(formatMark); + ctx.createdChangeIds.push(formatMark.attrs.id); + } + } + } + + return { + ok: true, + tr: ctx.tr, + createdChangeIds: ctx.createdChangeIds, + updatedChangeIds: ctx.updatedChangeIds, + removedChangeIds: ctx.removedChangeIds, + remappedChangeIds: ctx.remappedChangeIds, + formatMarks, + }; +}; + +/** + * Build the list of contiguous subranges inside [from, to] that share the + * same "format folding" decision. Returns null when the range crosses a + * boundary the compiler refuses to handle (e.g. a non-textblock structural + * node). + * + * @returns {Array<{ from: number, to: number, fold: boolean, parentId?: string }> | null} + */ +const computeFormatSubranges = (ctx, from, to) => { + /** @type {Array<{ from: number, to: number, fold: boolean, parentId?: string }>} */ + const out = []; + let boundaryCrossed = false; + let lastTextBlock = null; + + ctx.tr.doc.nodesBetween(from, to, (node, pos) => { + if (!node.isInline || node.type.name === 'run') return; + if (boundaryCrossed) return false; + // Identify the textblock parent of this inline leaf. + const $pos = ctx.tr.doc.resolve(pos); + const parent = $pos.parent?.type?.name ?? ''; + if (!parent) return; + if (lastTextBlock === null) lastTextBlock = parent; + // We do not consider crossing textblocks unsafe here; PM clips ranges + // to inline content. The compiler refuses only structural marks (handled + // by the SUPPORTED_KINDS check above). + + 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); + if (insertMark) { + const ownership = classifySegment(ctx, { attrs: insertMark.attrs }); + if (ownership === 'same-user') { + appendSubrange(out, { from: segFrom, to: segTo, fold: true }); + } else { + appendSubrange(out, { from: segFrom, to: segTo, fold: false, parentId: insertMark.attrs.id }); + } + return; + } + const deleteMark = node.marks.find((m) => m.type.name === TrackDeleteMarkName); + if (deleteMark) { + // Tracked-deleted content: do not modify formatting; legacy addMarkStep + // also skips this. Fail closed to avoid silent drift. + boundaryCrossed = true; + return false; + } + appendSubrange(out, { from: segFrom, to: segTo, fold: false }); + }); + + if (boundaryCrossed) return null; + return out; +}; + +const appendSubrange = (out, range) => { + const last = out[out.length - 1]; + if (last && last.fold === range.fold && last.parentId === range.parentId && last.to === range.from) { + last.to = range.to; + return; + } + out.push(range); +}; + +/** + * 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, computeFormatSubranges }; 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..5838881ac3 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.test.js @@ -0,0 +1,642 @@ +// @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 FIXED_DATE = '2026-05-21T00:00:00.000Z'; + +const schema = createReviewGraphTestSchema(); + +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('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('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); + }); +}); + +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('links both sides of a child replacement to 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 child = Array.from(graph.changes.values()).find((change) => change.id !== parentId); + expect(child).toBeDefined(); + expect(child.type).toBe(CanonicalChangeType.Replacement); + expect(child.replacement.inserted[0].attrs.overlapParentId).toBe(parentId); + expect(child.replacement.deleted[0].attrs.overlapParentId).toBe(parentId); + }); + + 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('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..b92f155872 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.js @@ -0,0 +1,770 @@ +// @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} 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 {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} 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; + 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, + 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 ?? '', + 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..372a21d542 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/test-fixtures.js @@ -0,0 +1,127 @@ +// @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'; + +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: '' }, + 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: '', + 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..19f9a9b3fa --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/word-id-allocator.js @@ -0,0 +1,146 @@ +// @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, + * __snapshot: () => Record, + * }} WordIdAllocator + * + * @typedef {{ + * reservedDecimal: Set, + * nextDecimal: number, + * assignedByLogicalId: Map, + * }} PartWordIdState + */ + +const DECIMAL = /^\d+$/; + +/** + * 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(), + }; + stateByPart.set(key, state); + } + return state; + }; + + /** @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)) { + return String(state.assignedByLogicalId.get(logicalId)); + } + + 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); + return String(n); + }; + + 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, + __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..c1e8cf6b40 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/word-id-allocator.test.js @@ -0,0 +1,123 @@ +// @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('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-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..0555eea2d1 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,21 @@ 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 { 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'; /** * Reads the `replacements` mode from editor.options.trackedChanges. @@ -20,14 +25,68 @@ import { hasExpandedSelection } from '@utils/selectionUtils.js'; const readReplacementsMode = (editor) => editor?.options?.trackedChanges?.replacements === 'independent' ? 'independent' : 'paired'; +/** + * 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 }) => { + if (editor?.storage?.trackChanges) { + editor.storage.trackChanges.lastDecisionFailure = null; + } + const result = decideTrackedChanges({ + state, + editor, + decision, + target, + replacements: readReplacementsMode(editor), + }); + if (!result.ok) { + // 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 (editor?.storage?.trackChanges) { + editor.storage.trackChanges.lastDecisionFailure = { + code: result.code, + message: result.message, + details: result.details, + }; + } + return { applied: false, failure: result }; + } + if (dispatch) { + dispatch(result.tr); + const events = buildDecisionBubbleEvents({ result, editor }); + if (editor?.emit && events.length) { + for (const event of events) editor.emit('commentsUpdate', event); + } + } + return { applied: true, result }; +}; + export const TrackChanges = Extension.create({ name: 'trackChanges', + addStorage() { + return { + lastCompilerFailure: null, + lastDecisionFailure: null, + }; + }, + addCommands() { return { acceptTrackedChangesBetween: (from, to) => ({ state, dispatch, editor }) => { + const reviewDecision = dispatchReviewDecision({ + editor, + state, + dispatch, + decision: 'accept', + target: { kind: 'range', from, to }, + }); + if (reviewDecision) return reviewDecision.applied; + const trackedChanges = collectTrackedChanges({ state, from, to }); if (!isTrackedChangeActionAllowed({ editor, action: 'accept', trackedChanges })) return false; @@ -74,6 +133,15 @@ export const TrackChanges = Extension.create({ rejectTrackedChangesBetween: (from, to) => ({ state, dispatch, editor }) => { + const reviewDecision = dispatchReviewDecision({ + editor, + state, + dispatch, + decision: 'reject', + target: { kind: 'range', from, to }, + }); + if (reviewDecision) return reviewDecision.applied; + const trackedChanges = collectTrackedChanges({ state, from, to }); if (!isTrackedChangeActionAllowed({ editor, action: 'reject', trackedChanges })) return false; @@ -188,7 +256,16 @@ export const TrackChanges = Extension.create({ acceptTrackedChangeById: (id) => - ({ state, tr, commands }) => { + ({ state, tr, dispatch, editor, commands }) => { + const reviewDecision = dispatchReviewDecision({ + editor, + state, + dispatch, + decision: 'accept', + target: { kind: 'id', id }, + }); + if (reviewDecision) return reviewDecision.applied; + const toResolve = getChangesByIdToResolve(state, id) || []; return toResolve @@ -202,7 +279,16 @@ export const TrackChanges = Extension.create({ acceptAllTrackedChanges: () => - ({ state, commands }) => { + ({ state, dispatch, editor, commands }) => { + const reviewDecision = dispatchReviewDecision({ + editor, + state, + dispatch, + decision: 'accept', + target: { kind: 'all' }, + }); + if (reviewDecision) return reviewDecision.applied; + const from = 0, to = state.doc.content.size; return commands.acceptTrackedChangesBetween(from, to); @@ -210,7 +296,16 @@ export const TrackChanges = Extension.create({ rejectTrackedChangeById: (id) => - ({ state, tr, commands }) => { + ({ state, tr, dispatch, editor, commands }) => { + const reviewDecision = dispatchReviewDecision({ + editor, + state, + dispatch, + decision: 'reject', + target: { kind: 'id', id }, + }); + if (reviewDecision) return reviewDecision.applied; + const toReject = getChangesByIdToResolve(state, id) || []; return toReject @@ -272,7 +367,16 @@ export const TrackChanges = Extension.create({ rejectAllTrackedChanges: () => - ({ state, commands }) => { + ({ state, dispatch, editor, commands }) => { + const reviewDecision = dispatchReviewDecision({ + editor, + state, + dispatch, + decision: 'reject', + target: { kind: 'all' }, + }); + if (reviewDecision) return reviewDecision.applied; + const from = 0, to = state.doc.content.size; return commands.rejectTrackedChangesBetween(from, to); @@ -316,108 +420,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: @@ -725,3 +742,136 @@ 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; + if (editor?.storage?.trackChanges) { + editor.storage.trackChanges.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) { + if (editor?.storage?.trackChanges) { + editor.storage.trackChanges.lastCompilerFailure = { + code: result.code, + message: result.message, + details: result.details, + }; + } + return false; + } + if (!dispatch) { + return true; + } + + const meta = { + insertedMark: result.insertedMark || null, + deletionMark: result.deletionMarks?.[0] || null, + deletionNodes: [], + step: result.insertedMark ? { slice: { content: { content: [] } } } : 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, + 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..1a7e67e205 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 @@ -85,6 +85,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..1706f2e464 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 @@ -120,6 +120,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..f8af8c29ec 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 @@ -85,6 +85,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..eb95797f13 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 @@ -12,6 +12,8 @@ import { 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,6 +26,46 @@ import { getLiveInlineMarksInRange } from './getLiveInlineMarksInRange.js'; * @param {string} options.date Date. */ export const addMarkStep = ({ state, step, newTr, doc, user, date }) => { + // Route tracked run-format intents through the compiler so formatting + // inside same-user own insertion/replacement folds into the inserted side + // instead of producing a separate trackFormat. + 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) { + if (result.formatMarks?.length) { + newTr.setMeta(TrackChangesBasePluginKey, { + formatMark: result.formatMarks[0], + step, + }); + } + newTr.setMeta(CommentsPluginKey, { type: 'force' }); + return; + } + if (result.code !== 'CAPABILITY_UNAVAILABLE') { + // Fail closed for typed errors; do not silently apply untracked. + return; + } + // Otherwise fall through to legacy path. + } + /** @type {{ formatMark?: import('prosemirror-model').Mark, step?: import('prosemirror-transform').AddMarkStep }} */ const meta = {}; /** @type {string | null} */ 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..aff29e1244 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,7 @@ import { Slice } from 'prosemirror-model'; import { v4 as uuidv4 } from 'uuid'; import { TrackDeleteMarkName, TrackInsertMarkName } from '../constants.js'; import { findTrackedMarkBetween } from './findTrackedMarkBetween.js'; +import { normalizeEmail } from '../review-model/identity.js'; /** * Mark deletion. @@ -17,18 +18,14 @@ 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); /** * @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; + // Missing identity is not same-user. Only a trusted authorEmail match counts. + if (!authorEmail || !userEmail) return false; return authorEmail === userEmail; }; 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..ec80c2b384 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 @@ -9,6 +9,8 @@ import { 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,6 +23,40 @@ import { getLiveInlineMarksInRange } from './getLiveInlineMarksInRange.js'; * @param {string} options.date Date. */ export const removeMarkStep = ({ state, step, newTr, doc, user, date }) => { + 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; + } + if (result.code !== 'CAPABILITY_UNAVAILABLE') return; + // Fall through to legacy on capability gaps. + } + const meta = {}; let sharedWid = null; 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..b89c6edeec 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 @@ -117,6 +117,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 +130,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 +204,7 @@ export const replaceAroundStep = ({ date, originalStep: charStep, originalStepIndex, + replacements, }); // Position the cursor at the deletion edge. The original transaction's 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..ae74d04181 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 @@ -7,6 +7,8 @@ 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,6 +175,26 @@ export const replaceStep = ({ step.to !== originalRange.to || step.slice.content.size !== originalRange.sliceSize; + const compiled = tryCompileStep({ + state, + tr, + newTr, + step, + stepWasNormalized, + originalStep, + 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. @@ -380,6 +402,103 @@ export const replaceStep = ({ * Selection taken from another transaction/document context. * @returns {void} */ +/** + * 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 falls through + * to the legacy path. + * + * @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, map, user, date, replacements }) => { + // Empty structural deletion handled by the existing legacy 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 }; + } + + // Build the intent. Pure inserts and pure deletes use the matching intent + // type; mixed (text-replace) carries the original slice. + let intent; + try { + if (step.from === step.to && step.slice.content.size > 0) { + intent = makeTextInsertIntent({ at: step.from, content: step.slice, user, date, source: 'native' }); + } else if (step.from !== step.to && step.slice.content.size === 0) { + intent = makeTextDeleteIntent({ from: step.from, to: step.to, user, date, source: 'native' }); + } 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', + }); + } else { + // Zero-op step; nothing to compile. + return { handled: false }; + } + } catch (error) { + return { failed: true, error }; + } + + const beforeSize = newTr.doc.content.size; + const beforeSteps = newTr.steps.length; + const result = compileTrackedEdit({ + state, + tr: newTr, + intent, + replacements, + }); + + if (!result.ok) { + // Structural fallback: when the compiler reports CAPABILITY_UNAVAILABLE + // for a content shape it cannot model (e.g. mixed structural slice), let + // the legacy path handle it. Otherwise — INVALID_TARGET or + // PRECONDITION_FAILED — fail closed. + if (result.code === 'CAPABILITY_UNAVAILABLE') return { handled: false }; + return { failed: true, error: new Error(result.message) }; + } + + // Track that we mutated newTr. We still need to update the outer mapping + // (`map`) so subsequent steps in the same transaction can map through. + for (let i = beforeSteps; i < newTr.steps.length; i += 1) { + map.appendMap(newTr.steps[i].getMap()); + } + + // Mirror the position-mapping behavior expected by trackedTransaction + // selection logic: when there is an inserted side, record `insertedTo`. + const meta = {}; + if (typeof result.insertedTo === 'number') { + meta.insertedTo = result.insertedTo; + } + if (result.insertedMark) { + meta.insertedMark = result.insertedMark; + } + if (result.deletionMarks?.length) { + meta.deletionMark = result.deletionMarks[0]; + } + if (result.selection?.kind === 'near' && stepWasNormalized && !result.insertedMark) { + meta.selectionPos = result.selection.pos; + } + newTr.setMeta(TrackChangesBasePluginKey, meta); + newTr.setMeta(CommentsPluginKey, { type: 'force' }); + + return { handled: true, sizeDelta: newTr.doc.content.size - beforeSize }; +}; + 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)); 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..b255c09eb3 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 @@ -415,6 +415,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/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..42e0c0d947 --- /dev/null +++ b/packages/super-editor/src/editors/v1/tests/import-export/trackChangesRoundtrip-overlap.test.js @@ -0,0 +1,328 @@ +// @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 DocxZipper from '@core/DocxZipper.js'; +import { parseXmlToJson } from '@converter/v2/docxHelper.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(); + } + }); +}); From e8468d82af30106ad324ba079419fe291b7c047d Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 21 May 2026 21:18:06 -0700 Subject: [PATCH 02/25] chore: tests for review issues --- .../Editor.track-changes-dispatch.test.js | 37 +++++++++++- .../src/stores/comments-store.test.js | 59 +++++++++++++++++++ .../floating-comments-virtualization.spec.ts | 16 +++++ 3 files changed, 111 insertions(+), 1 deletion(-) 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..db364da655 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,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { initTestEditor } from '@tests/helpers/helpers.js'; import { getTrackChanges } from '@extensions/track-changes/trackChangesHelpers/getTrackChanges.js'; import { TrackInsertMarkName } from '@extensions/track-changes/constants.js'; @@ -104,4 +104,39 @@ 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', + }), + ); + }); }); diff --git a/packages/superdoc/src/stores/comments-store.test.js b/packages/superdoc/src/stores/comments-store.test.js index 065aaa401e..2440913c54 100644 --- a/packages/superdoc/src/stores/comments-store.test.js +++ b/packages/superdoc/src/stores/comments-store.test.js @@ -1523,6 +1523,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/tests/behavior/tests/comments/floating-comments-virtualization.spec.ts b/tests/behavior/tests/comments/floating-comments-virtualization.spec.ts index 30b15798f2..9bea93f453 100644 --- a/tests/behavior/tests/comments/floating-comments-virtualization.spec.ts +++ b/tests/behavior/tests/comments/floating-comments-virtualization.spec.ts @@ -22,6 +22,22 @@ test('@behavior SD-1997: floating comment bubbles render after tracked changes', .poll(async () => (await listTrackChanges(superdoc.page, { type: 'insert' })).total) .toBeGreaterThanOrEqual(5); + // The live review/comment model should also contain one tracked-change + // comment per inserted suggestion before any visual sidebar assertion runs. + await expect + .poll(async () => + superdoc.page.evaluate(() => { + const comments = (window as any).superdoc?.commentsStore?.commentsList ?? []; + return comments.filter( + (comment: any) => + comment?.trackedChange === true && + comment?.trackedChangeType === 'trackInsert' && + String(comment?.trackedChangeText ?? '').includes('tracked change'), + ).length; + }), + ) + .toBeGreaterThanOrEqual(5); + // Verify floating comment placeholders appear in the sidebar const placeholders = superdoc.page.locator('.comment-placeholder'); await expect(placeholders.first()).toBeAttached({ timeout: 10_000 }); From 1b431b12a4f3946562ec0ff82e4359ae87be1806 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 21 May 2026 21:39:31 -0700 Subject: [PATCH 03/25] fix: floating comments fixes --- .../plan-engine/comments-wrappers.test.ts | 75 +++++++++++++ .../plan-engine/comments-wrappers.ts | 104 +++++++++++++++++- .../v1/extensions/comment/comments-plugin.js | 20 +++- .../floating-comments-virtualization.spec.ts | 33 +++--- 4 files changed, 211 insertions(+), 21 deletions(-) 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..a3e1d81804 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,6 +18,10 @@ 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(), })); @@ -33,6 +37,7 @@ vi.mock('../helpers/adapter-utils.js', async () => { 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'; function makeAnchor( overrides: Partial & { commentId: string; pos: number; end: number }, @@ -69,6 +74,11 @@ function mockTextBetweenSequence(editor: Editor, ...values: string[]): void { (editor.state!.doc as { textBetween: ReturnType }).textBetween = vi.fn(() => values[i++] ?? ''); } +beforeEach(() => { + vi.mocked(listCommentAnchors).mockReturnValue([]); + vi.mocked(getTrackedChangeIndex).mockReturnValue({ getAll: () => [] } as never); +}); + describe('comments-wrappers: anchoredText', () => { beforeEach(() => { vi.clearAllMocks(); @@ -175,6 +185,71 @@ describe('comments-wrappers: anchoredText', () => { 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([]); + vi.mocked(getTrackedChangeIndex).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([]); + vi.mocked(getTrackedChangeIndex).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', () => { 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..69d8b867c6 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 @@ -24,10 +24,12 @@ import type { ReplyToCommentInput, ResolveCommentInput, RevisionGuardOptions, + StoryLocator, SetCommentActiveInput, SetCommentInternalInput, TextSegment, TextTarget, + TrackChangeType, } from '@superdoc/document-api'; import { buildResolvedHandle, buildDiscoveryItem, buildDiscoveryResult } from '@superdoc/document-api'; import { TextSelection } from 'prosemirror-state'; @@ -50,6 +52,8 @@ import { } 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'; // --------------------------------------------------------------------------- // Internal helpers @@ -61,6 +65,15 @@ type EditorUserIdentity = { image?: string; }; +type TrackedChangeCommentInfo = CommentInfo & { + story?: StoryLocator; + trackedChange?: boolean; + trackedChangeType?: TrackChangeType; + trackedChangeAnchorKey?: string; + trackedChangeText?: string; + deletedText?: string | null; +}; + function toCommentAddress(commentId: string): { kind: 'entity'; entityType: 'comment'; entityId: string } { return { kind: 'entity', @@ -393,9 +406,83 @@ function mergeAnchorData( } } -function buildCommentInfos(editor: Editor): CommentInfo[] { +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 toTrackedChangeCommentInfo(snapshot: TrackedChangeSnapshot): TrackedChangeCommentInfo | null { + const commentId = toNonEmptyString(snapshot.address.entityId); + if (!commentId) return null; + + const { trackedChangeText, deletedText } = trackedChangeTextFields(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, + trackedChangeAnchorKey: snapshot.anchorKey, + trackedChangeText, + deletedText, + }; +} + +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, + 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, + }); + } +} + +function buildCommentInfos(editor: Editor): TrackedChangeCommentInfo[] { 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; @@ -404,6 +491,7 @@ function buildCommentInfos(editor: Editor): CommentInfo[] { } mergeAnchorData(editor, infosById, listCommentAnchorsSafe(editor)); + mergeTrackedChangeCommentInfos(editor, infosById); // Inherit target + anchoredText from nearest anchored ancestor for replies. // Walks up the parent chain so deep threads resolve regardless of iteration order. @@ -1105,6 +1193,12 @@ function listCommentsHandler(editor: Editor, query?: CommentsListQuery): Comment creatorName, creatorEmail, address, + story, + trackedChange, + trackedChangeType, + trackedChangeAnchorKey, + trackedChangeText, + deletedText, } = comment; return buildDiscoveryItem(comment.commentId, handle, { address, @@ -1118,6 +1212,12 @@ function listCommentsHandler(editor: Editor, query?: CommentsListQuery): Comment createdTime, creatorName, creatorEmail, + story, + trackedChange, + trackedChangeType, + trackedChangeAnchorKey, + trackedChangeText, + deletedText, }); }); 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..c30700ec92 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 @@ -918,6 +918,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 +945,7 @@ const handleTrackedChangeTransaction = (trackedChangeMeta, trackedChanges, newEd deletionNodes, nodes, newEditorState, - trackedChangesForId: getTrackChanges(newEditorState, trackedMarkId), + trackedChangesForId: getTrackedChangesForId(trackedMarkId), }); }; @@ -954,15 +963,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 +988,7 @@ const handleTrackedChangeTransaction = (trackedChangeMeta, trackedChanges, newEd : null; const deletionPayload = - deletionMark && (hasCandidateNodes || getTrackChanges(newEditorState, deletionId).length > 0) + deletionMark && hasDeletionCandidateNodes ? buildTrackedChangePayload({ event: isNewDeletion ? 'add' : 'update', marks: { diff --git a/tests/behavior/tests/comments/floating-comments-virtualization.spec.ts b/tests/behavior/tests/comments/floating-comments-virtualization.spec.ts index 9bea93f453..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,25 +19,27 @@ 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 inserted suggestion before any visual sidebar assertion runs. + // comment per live tracked insertion before any visual sidebar assertion runs. await expect - .poll(async () => - superdoc.page.evaluate(() => { - const comments = (window as any).superdoc?.commentsStore?.commentsList ?? []; - return comments.filter( - (comment: any) => - comment?.trackedChange === true && - comment?.trackedChangeType === 'trackInsert' && - String(comment?.trackedChangeText ?? '').includes('tracked change'), - ).length; - }), - ) - .toBeGreaterThanOrEqual(5); + .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'); From 95d4f042442ad3945c0b3a1a6e5a7674006c0ab5 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 21 May 2026 21:56:01 -0700 Subject: [PATCH 04/25] chore: add tests for metadata issue --- packages/document-api/README.md | 6 ++ .../src/contract/contract.test.ts | 72 +++++++++++++++++++ .../track-changes-wrappers.test.ts | 59 ++++++++++++++- 3 files changed, 136 insertions(+), 1 deletion(-) 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/contract/contract.test.ts b/packages/document-api/src/contract/contract.test.ts index e9f2fe449b..0a20e4741e 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); @@ -212,6 +232,58 @@ 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('includes global.history in capabilities.get output schema', () => { const schemas = buildInternalContractSchemas(); const capabilitiesOutput = schemas.operations['capabilities.get'].output as { 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 362fd0e3de..11c8a5e1cd 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(), @@ -41,6 +41,10 @@ import { const footnoteStory: StoryLocator = { kind: 'story', storyType: 'footnote', noteId: '5' }; +function expectTrackChangesDecideReceiptCodeDeclared(code: string): void { + expect(COMMAND_CATALOG['trackChanges.decide'].possibleFailureCodes).toContain(code); +} + function makeEditor(commands: Record = {}): Editor { return { commands, @@ -332,5 +336,58 @@ describe('track-changes-wrappers revision guard', () => { }, }, }); + 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'); }); }); From a0a90cba1695abbb2296472e34f8e38c70aa1c14 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 21 May 2026 22:07:01 -0700 Subject: [PATCH 05/25] chore: review fix, type fix --- .../reference/_generated-manifest.json | 2 +- .../reference/track-changes/decide.mdx | 11 ++- .../src/contract/operation-definitions.ts | 10 +- .../v3/handlers/w/del/del-translator.js | 13 ++- .../v3/handlers/w/ins/ins-translator.js | 13 ++- .../v1/core/types/EditorPublicSurfaces.ts | 8 +- .../review-model/decision-engine.js | 6 +- .../track-changes/review-model/identity.js | 4 +- .../review-model/mark-metadata.js | 7 ++ .../review-model/overlap-compiler.js | 93 ++++++++++++++----- .../review-model/test-fixtures.js | 1 + .../trackChangesHelpers/addMarkStep.js | 4 +- 12 files changed, 132 insertions(+), 40 deletions(-) diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index f14ad2c32b..690a439709 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -1077,5 +1077,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "8c6adfd1bbb1226a5847d6a791d7e07cfe994fffe9855fe845de5e196f1f3498" + "sourceHash": "a25eb8affb38731851a794b94a7bdbe8da1b5e5f3d4c21cc214ba454ad1ae898" } diff --git a/apps/docs/document-api/reference/track-changes/decide.mdx b/apps/docs/document-api/reference/track-changes/decide.mdx index 98764f09be..4cc7f7de74 100644 --- a/apps/docs/document-api/reference/track-changes/decide.mdx +++ b/apps/docs/document-api/reference/track-changes/decide.mdx @@ -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"`, `"CAPABILITY_UNAVAILABLE"`, `"PERMISSION_DENIED"`, `"COMMENT_CASCADE_PARTIAL"` | +| `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,8 +97,11 @@ 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 @@ -173,8 +176,11 @@ Returns a Receipt confirming the decision was applied; reports NO_OP if the chan "code": { "enum": [ "NO_OP", + "INVALID_TARGET", + "TARGET_NOT_FOUND", "CAPABILITY_UNAVAILABLE", "PERMISSION_DENIED", + "PRECONDITION_FAILED", "COMMENT_CASCADE_PARTIAL" ] }, @@ -223,8 +229,11 @@ Returns a Receipt confirming the decision was applied; reports NO_OP if the chan "code": { "enum": [ "NO_OP", + "INVALID_TARGET", + "TARGET_NOT_FOUND", "CAPABILITY_UNAVAILABLE", "PERMISSION_DENIED", + "PRECONDITION_FAILED", "COMMENT_CASCADE_PARTIAL" ] }, diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index 5a23fa6520..8790b6de19 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -2490,7 +2490,15 @@ export const OPERATION_DEFINITIONS = { idempotency: 'conditional', supportsDryRun: false, supportsTrackedMode: false, - possibleFailureCodes: ['NO_OP', 'CAPABILITY_UNAVAILABLE', 'PERMISSION_DENIED', 'COMMENT_CASCADE_PARTIAL'], + 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/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 983e8f8204..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 @@ -129,8 +129,17 @@ function decode(params) { */ function resolveExportWordId(params, attrs) { const sourceId = attrs?.sourceId; - const exportSourceId = - typeof sourceId === 'string' || typeof sourceId === 'number' || sourceId == null ? sourceId : String(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 }} */ ( 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 5528776bed..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 @@ -122,8 +122,17 @@ function decode(params) { */ function resolveExportWordId(params, attrs) { const sourceId = attrs?.sourceId; - const exportSourceId = - typeof sourceId === 'string' || typeof sourceId === 'number' || sourceId == null ? sourceId : String(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 }} */ ( 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..547780f92b 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'; @@ -24,6 +24,7 @@ import type { EditorExtension } from './EditorConfig.js'; import type { CommandProps } from './ChainedCommands.js'; import type { ExtensionAttribute } from '../Attribute.js'; import type { NumberingModel } from '../parts/adapters/numbering-transforms.js'; +import type { WordIdAllocator } from '../../extensions/track-changes/review-model/word-id-allocator.js'; /** * Loosely-typed OOXML part as held in `convertedXml`. Element trees @@ -47,7 +48,7 @@ export type HeaderFooterIdMap = Record void } & Record; + editor?: { destroy?: () => void; state?: { doc?: PmNode } } & Record; [key: string]: unknown; } @@ -67,6 +68,8 @@ export interface EditorConverterSurface { footerEditors: HeaderFooterEditorEntry[]; footerIds: HeaderFooterIdMap; footers: Record; + footnotes: unknown; + endnotes: unknown; footnoteProperties: unknown; headerEditors: HeaderFooterEditorEntry[]; headerFooterModified: boolean; @@ -114,6 +117,7 @@ export interface EditorConverterSurface { * helpers iterate both maps. */ translatedNumbering: { abstracts?: Record; definitions?: Record }; + wordIdAllocator?: WordIdAllocator | null; // --- Methods --- /** 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 index 4ec93f6271..77c2852972 100644 --- 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 @@ -383,7 +383,7 @@ const runPermissionPreflight = ({ editor, decision, selections }) => { /** * @typedef {Object} MutationPlan * @property {MutationOp[]} ops Document-order op list. - * @property {import('./comment-effects.js').CommentEffectsPlan} commentEffects + * @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 @@ -436,7 +436,6 @@ const buildMutationPlan = ({ state, graph, selections, decision, replacements }) decision, removedRanges, retired, - diagnostics, }); if (!partialResult.ok) return { ok: false, failure: partialResult.failure }; for (const id of partialResult.createdChangeIds) touched.add(id); @@ -448,7 +447,7 @@ const buildMutationPlan = ({ state, graph, selections, decision, replacements }) 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, state }); + planFormattingDecision({ ops, change, decision, retired }); } else { return { ok: false, @@ -880,6 +879,7 @@ const collectCreatedChangeIds = (plan) => { export const buildDecisionBubbleEvents = ({ result, editor }) => { const resolvedByEmail = editor?.options?.user?.email; const resolvedByName = editor?.options?.user?.name; + /** @type {Array<{ type: 'trackedChange', event: 'resolve'|'update', changeId: string, resolvedByEmail?: string, resolvedByName?: string }>} */ const events = []; for (const entry of result.receipt.removedChangeIds) { events.push({ type: 'trackedChange', event: 'resolve', changeId: entry.id, resolvedByEmail, resolvedByName }); 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 index 79d06930dd..5fa7f82689 100644 --- 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 @@ -27,6 +27,7 @@ export const normalizeEmail = (value) => { * @property {string} email normalized email, '' when unknown. * @property {string} name display name (may be empty). * @property {boolean} hasEmail true when normalized email is non-empty. + * @property {string} [importedAuthor] imported author provenance, when present. */ /** @@ -59,7 +60,8 @@ export const getChangeAuthorIdentity = (changeOrAttrs) => { const email = normalizeEmail(attrs?.authorEmail); const name = typeof attrs?.author === 'string' ? attrs.author : ''; - return { email, name, hasEmail: email.length > 0 }; + const importedAuthor = typeof attrs?.importedAuthor === 'string' ? attrs.importedAuthor : ''; + return { email, name, hasEmail: email.length > 0, importedAuthor }; }; /** 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 index 29643495a2..7f2d59d876 100644 --- 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 @@ -59,6 +59,7 @@ export const SegmentSide = Object.freeze({ Formatting: 'formatting', }); +/** @type {Set} */ const CHANGE_TYPE_VALUES = new Set(Object.values(CanonicalChangeType)); /** @@ -128,6 +129,7 @@ const canonicalizeForSerialization = (value) => { }); } if (typeof value === 'object') { + /** @type {Record} */ const out = {}; const keys = Object.keys(value).sort(); for (const key of keys) { @@ -175,7 +177,12 @@ export const canonicalizeSourceIds = (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) { 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 index c54a0909f9..a139791bd5 100644 --- 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 @@ -53,26 +53,32 @@ import { */ /** - * @typedef {{ - * ok: true, - * tr: import('prosemirror-state').Transaction, - * createdChangeIds: string[], - * updatedChangeIds: string[], - * removedChangeIds: string[], - * remappedChangeIds: Array<{ from: string, to: string }>, - * selection?: SelectionHint, - * diagnostics?: GraphDiagnostic[], - * insertedMark?: import('prosemirror-model').Mark, - * deletionMarks?: import('prosemirror-model').Mark[], - * formatMarks?: import('prosemirror-model').Mark[], - * insertedFrom?: number, - * insertedTo?: number, - * } | { - * ok: false, - * code: 'CAPABILITY_UNAVAILABLE'|'INVALID_TARGET'|'PRECONDITION_FAILED', - * message: string, - * details?: unknown, - * }} TrackedEditResult + * @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} [insertedMark] + * @property {import('prosemirror-model').Mark[]} [deletionMarks] + * @property {import('prosemirror-model').Mark[]} [formatMarks] + * @property {number} [insertedFrom] + * @property {number} [insertedTo] + */ + +/** + * @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']); @@ -119,7 +125,7 @@ export const compileTrackedEdit = ({ state, tr, intent, replacements = 'paired' case 'format-remove': return compileFormat(ctx, intent); default: - return failure('CAPABILITY_UNAVAILABLE', `Unsupported tracked edit kind ${intent.kind}.`); + return failure('CAPABILITY_UNAVAILABLE', 'Unsupported tracked edit kind.'); } } catch (error) { return failure('PRECONDITION_FAILED', /** @type {Error} */ (error).message ?? 'compile failed.', { error }); @@ -150,7 +156,18 @@ const makeContext = ({ state, tr, intent, replacements }) => { }; }; -const failure = (code, message, details) => ({ ok: false, code, message, ...(details ? { details } : {}) }); +/** + * @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 @@ -268,6 +285,11 @@ const stripTrackedMarksFromSlice = (slice, schema) => { // 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; @@ -325,6 +347,15 @@ const compileTextInsert = (ctx, intent) => { 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 { @@ -378,6 +409,7 @@ const applyInsert = (ctx, at, slice, insertMark, changeId, { update, create }) = * * @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; @@ -394,7 +426,7 @@ const compileTextDelete = (ctx, intent) => { sharedDeletionId: intent.replacementGroupHint || null, recordSharedDeletionId: Boolean(intent.replacementGroupHint), }); - if (!result.ok) return result; + 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. @@ -418,6 +450,7 @@ const compileTextDelete = (ctx, intent) => { * @param {number} from * @param {number} to * @param {{ replacementGroupId: string, replacementSideId: string, sharedDeletionId: string | null, recordSharedDeletionId?: boolean, recordCollapsedIds?: boolean }} options + * @returns {{ ok: true, deletionMarks: import('prosemirror-model').Mark[], deletionId: string } | TrackedEditFailure} */ const applyTrackedDelete = ( ctx, @@ -546,6 +579,11 @@ const applyTrackedDelete = ( // 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) { @@ -573,7 +611,7 @@ const compileTextReplace = (ctx, intent) => { sharedDeletionId: null, recordCollapsedIds: false, }); - if (!deleteResult.ok) return deleteResult; + if (deleteResult.ok === false) return deleteResult; if (!sanitizedSlice.content.size) { ctx.updatedChangeIds.push(ownInsertedTarget.changeId); @@ -640,7 +678,7 @@ const compileTextReplace = (ctx, intent) => { replacementSideId, sharedDeletionId: sharedId, }); - if (!delResult.ok) return delResult; + if (delResult.ok === false) return delResult; if (sharedId && delResult.deletionMarks?.length) { ctx.createdChangeIds.push(sharedId); } @@ -708,6 +746,11 @@ const getReplacementParentId = (ctx, segments) => { // format-apply / format-remove (SD-486 folding) // --------------------------------------------------------------------------- +/** + * @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.`); 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 index 372a21d542..9ebe68d833 100644 --- 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 @@ -15,6 +15,7 @@ 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: { 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 eb95797f13..7b14d8d772 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 @@ -49,7 +49,7 @@ export const addMarkStep = ({ state, step, newTr, doc, user, date }) => { tr: newTr, intent, }); - if (result.ok) { + if (result.ok === true) { if (result.formatMarks?.length) { newTr.setMeta(TrackChangesBasePluginKey, { formatMark: result.formatMarks[0], @@ -59,7 +59,7 @@ export const addMarkStep = ({ state, step, newTr, doc, user, date }) => { newTr.setMeta(CommentsPluginKey, { type: 'force' }); return; } - if (result.code !== 'CAPABILITY_UNAVAILABLE') { + if (result.ok === false && result.code !== 'CAPABILITY_UNAVAILABLE') { // Fail closed for typed errors; do not silently apply untracked. return; } From 1f7d8637a9d46cca79b734d629bba95d6d43326b Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 21 May 2026 22:51:40 -0700 Subject: [PATCH 06/25] chore: add dispatch test for collab bug --- .../Editor.track-changes-dispatch.test.js | 198 +++++++++++++++++- 1 file changed, 197 insertions(+), 1 deletion(-) 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 db364da655..8d76e05547 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,84 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { initTestEditor } from '@tests/helpers/helpers.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 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 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')); +}; + describe('Editor dispatch tracked-change meta', () => { let editor; @@ -139,4 +214,125 @@ describe('Editor dispatch tracked-change meta', () => { }), ); }); + + it('protects another user tracked insertion from 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(editor.state.doc.textContent).toContain(INSERTED_TEXT); + expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe(INSERTED_TEXT); + + const childDeletion = markEntries(editor, TrackDeleteMarkName).find(({ text }) => text === INSERTED_TAIL); + expect(childDeletion?.mark.attrs).toEqual( + expect.objectContaining({ + authorEmail: BOB.email, + overlapParentId: FOREIGN_INSERT_ID, + }), + ); + }); + + 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('emits review comment state for a protected child deletion instead of only truncating the parent', () => { + ({ 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).toEqual( + expect.objectContaining({ + deletedText: expect.stringContaining(INSERTED_TAIL), + authorEmail: BOB.email, + }), + ); + expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe(INSERTED_TEXT); + }); + + 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); + }); }); From 9d4574a3bf6668f18b1e7a9467f2c56dcd08632a Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 22 May 2026 06:20:45 -0700 Subject: [PATCH 07/25] fix: collab mode bug --- .../reference/_generated-manifest.json | 2 +- .../reference/track-changes/get.mdx | 8 ++ .../reference/track-changes/list.mdx | 6 + packages/document-api/src/contract/schemas.ts | 4 + .../src/types/track-changes.types.ts | 8 ++ .../Editor.track-changes-dispatch.test.js | 74 +++++++++++ .../src/editors/v1/core/Editor.ts | 122 +++++++++++++++++- .../helpers/text-offset-resolver.test.ts | 20 +++ .../helpers/text-offset-resolver.ts | 3 +- .../helpers/tracked-change-resolver.test.ts | 20 +++ .../helpers/tracked-change-resolver.ts | 2 +- .../plan-engine/executor.ts | 43 ++++++ .../plan-engine/track-changes-wrappers.ts | 32 ++++- .../review-model/overlap-compiler.js | 10 +- .../review-model/overlap-compiler.test.js | 16 ++- 15 files changed, 351 insertions(+), 19 deletions(-) diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 690a439709..ecbc575eb2 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -1077,5 +1077,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "a25eb8affb38731851a794b94a7bdbe8da1b5e5f3d4c21cc214ba454ad1ae898" + "sourceHash": "a9aff0330980d98962b9026299b98b684ce60e47e88b163e2d12040d40bf5b0c" } diff --git a/apps/docs/document-api/reference/track-changes/get.mdx b/apps/docs/document-api/reference/track-changes/get.mdx index f3d9ab8a54..3b26d367da 100644 --- a/apps/docs/document-api/reference/track-changes/get.mdx +++ b/apps/docs/document-api/reference/track-changes/get.mdx @@ -54,8 +54,10 @@ 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 | | | `id` | string | yes | | +| `insertedText` | string | no | | | `type` | enum | yes | `"insert"`, `"delete"`, `"format"` | | `wordRevisionIds` | object | no | | | `wordRevisionIds.delete` | string | no | | @@ -135,12 +137,18 @@ Returns a TrackChangeInfo object with the change type, author, date, affected co "date": { "type": "string" }, + "deletedText": { + "type": "string" + }, "excerpt": { "type": "string" }, "id": { "type": "string" }, + "insertedText": { + "type": "string" + }, "type": { "enum": [ "insert", diff --git a/apps/docs/document-api/reference/track-changes/list.mdx b/apps/docs/document-api/reference/track-changes/list.mdx index 6411a19b16..59ab4aa6d3 100644 --- a/apps/docs/document-api/reference/track-changes/list.mdx +++ b/apps/docs/document-api/reference/track-changes/list.mdx @@ -166,6 +166,9 @@ Returns a TrackChangesListResult with tracked change entries, total count, and r "date": { "type": "string" }, + "deletedText": { + "type": "string" + }, "excerpt": { "type": "string" }, @@ -175,6 +178,9 @@ Returns a TrackChangesListResult with tracked change entries, total count, and r "id": { "type": "string" }, + "insertedText": { + "type": "string" + }, "type": { "enum": [ "insert", diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index c0a919e361..010c7ecef6 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -1512,6 +1512,8 @@ const trackChangeInfoSchema = objectSchema( authorImage: { type: 'string' }, date: { type: 'string' }, excerpt: { type: 'string' }, + insertedText: { type: 'string' }, + deletedText: { type: 'string' }, }, ['address', 'id', 'type'], ); @@ -1526,6 +1528,8 @@ const trackChangeDomainItemSchema = discoveryItemSchema( authorImage: { type: 'string' }, date: { type: 'string' }, excerpt: { type: 'string' }, + insertedText: { type: 'string' }, + deletedText: { type: 'string' }, }, ['address', 'type'], ); diff --git a/packages/document-api/src/types/track-changes.types.ts b/packages/document-api/src/types/track-changes.types.ts index ce4c380840..2d785b4cd5 100644 --- a/packages/document-api/src/types/track-changes.types.ts +++ b/packages/document-api/src/types/track-changes.types.ts @@ -39,6 +39,10 @@ export interface TrackChangeInfo { 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; } export interface TrackChangesListQuery { @@ -67,6 +71,10 @@ export interface TrackChangeDomain { 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; } /** 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 8d76e05547..5cd08abf50 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 @@ -69,6 +69,31 @@ const setDocumentWithTrackedInsertion = (editor, { author = ALICE, id = FOREIGN_ ); }; +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')); @@ -79,6 +104,16 @@ const replaceText = (editor, text, replacement) => { 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; @@ -274,6 +309,45 @@ describe('Editor dispatch tracked-change meta', () => { ); }); + 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('emits review comment state for a protected child deletion instead of only truncating the parent', () => { ({ editor } = initTestEditor({ mode: 'text', diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index 5d4f93bfaf..c3d656436c 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -1,5 +1,5 @@ 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 { Doc as YDoc } from 'yjs'; @@ -35,6 +35,7 @@ import { createDocument } from './helpers/createDocument.js'; import { isActive } from './helpers/isActive.js'; import { trackedTransaction } from '@extensions/track-changes/trackChangesHelpers/trackedTransaction.js'; import { createWordIdAllocator, isDecimalWordId } 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'; @@ -105,6 +106,121 @@ 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 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) { + 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; @@ -2766,8 +2882,10 @@ export class Editor extends EventEmitter { const trackChangesState = TrackChangesBasePluginKey.getState(prevState); const isTrackChangesActive = trackChangesState?.isTrackChangesActive ?? false; const skipTrackChanges = transactionToApply.getMeta('skipTrackChanges') === true; + const protectsExistingTrackedReviewState = transactionTouchesTrackedReviewState(prevState, transactionToApply); - const shouldTrack = (isTrackChangesActive || forceTrackChanges) && !skipTrackChanges; + const shouldTrack = + ((isTrackChangesActive || forceTrackChanges) && !skipTrackChanges) || protectsExistingTrackedReviewState; if (shouldTrack && forceTrackChanges && !this.options.user) { throw new Error('forceTrackChanges requires a user to be configured on the editor instance.'); } 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-resolver.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.test.ts index 16f8266ae4..a17873b2df 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 @@ -189,6 +189,26 @@ describe('groupTrackedChanges', () => { 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('preserves significant Word revision whitespace in explicit excerpts', () => { const mark = makeTrackMark(TrackDeleteMarkName, 'delete-with-space', { sourceId: '4' }); vi.mocked(getTrackChanges).mockReturnValue([ 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 cdcee5d412..a9abfb41df 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 @@ -203,7 +203,7 @@ export function groupTrackedChanges(editor: Editor): GroupedTrackedChange[] { const nextHasFormat = markType === TrackFormatMarkName; const wordRevisionId = toNonEmptyString(attrs.sourceId); const wordRevisionIdKey = getWordRevisionIdKey(markType); - const contributesToExcerpt = !hasChildTrackedMarkOnNode(item, id); + const contributesToExcerpt = !wordRevisionId || !hasChildTrackedMarkOnNode(item, id); const excerptText = contributesToExcerpt ? getTrackedMarkText(editor, item) : ''; if (!existing) { 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.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/track-changes-wrappers.ts index 2769ff6e34..28c2fef919 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 @@ -57,6 +57,9 @@ function normalizeWordRevisionIds( } function snapshotToInfo(snapshot: TrackedChangeSnapshot): TrackChangeInfo { + const changedText = snapshot.excerpt + ? { [snapshot.type === 'delete' ? 'deletedText' : 'insertedText']: snapshot.excerpt } + : {}; return { address: snapshot.address, id: snapshot.address.entityId, @@ -67,6 +70,7 @@ function snapshotToInfo(snapshot: TrackedChangeSnapshot): TrackChangeInfo { authorImage: snapshot.authorImage, date: snapshot.date, excerpt: snapshot.excerpt, + ...(snapshot.type === 'format' ? {} : changedText), }; } @@ -144,7 +148,18 @@ export function trackChangesListWrapper(editor: Editor, input?: TrackChangesList 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 { + address, + type, + wordRevisionIds, + author, + authorEmail, + authorImage, + date, + excerpt, + insertedText, + deletedText, + } = info; return buildDiscoveryItem(info.id, handle, { address, type, @@ -154,6 +169,8 @@ export function trackChangesListWrapper(editor: Editor, input?: TrackChangesList authorImage, date, excerpt, + insertedText, + deletedText, }); }); @@ -186,6 +203,12 @@ export function trackChangesGetWrapper(editor: Editor, input: TrackChangesGetInp 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 changedText = excerpt ? { [type === 'delete' ? 'deletedText' : 'insertedText']: excerpt } : {}; + return { address: { kind: 'entity', @@ -194,15 +217,14 @@ export function trackChangesGetWrapper(editor: Editor, input: TrackChangesGetInp ...(storyKey === BODY_STORY_KEY ? {} : { story: resolved.story }), }, id: resolved.change.id, - type: resolveTrackedChangeType(resolved.change), + type, wordRevisionIds: normalizeWordRevisionIds(resolved.change.wordRevisionIds), 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: - (resolved.change.excerpt !== undefined ? resolved.change.excerpt : undefined) ?? - normalizeExcerpt(resolved.editor.state.doc.textBetween(resolved.change.from, resolved.change.to, ' ', '\ufffc')), + excerpt, + ...(type === 'format' ? {} : changedText), }; } 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 index a139791bd5..d6a7d0b15d 100644 --- 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 @@ -664,12 +664,16 @@ const compileTextReplace = (ctx, intent) => { return applyInsert(ctx, intent.from, sanitizedSlice, insertMark, insertId, { create: true }); } + const replacementParentId = getReplacementParentId(ctx, segments); // Paired vs independent: in paired mode share one id between insert+delete - // sides so the logical change projects as a `replacement` in the graph. - const sharedId = intent.replacements === 'paired' ? intent.replacementGroupHint || uuidv4() : null; + // 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 ?? ''; const replacementSideId = sharedId ? `${sharedId}#deleted` : ''; - const replacementParentId = getReplacementParentId(ctx, segments); // Step 1 — tracked delete (collapses own insertions, marks live/other content). if (intent.from !== intent.to) { 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 index 5838881ac3..0cd991837c 100644 --- 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 @@ -436,7 +436,7 @@ describe('overlap-compiler: text-replace produces paired replacement metadata', expect(change.replacement?.deleted.length).toBeGreaterThan(0); }); - it('links both sides of a child replacement to an other-user insertion parent', () => { + it('keeps both sides of a child replacement separately reviewable under an other-user insertion parent', () => { const parentId = 'ins-bob'; const { state } = stateFromTrackedSpans({ schema, @@ -457,11 +457,15 @@ describe('overlap-compiler: text-replace produces paired replacement metadata', const result = runCompile({ state, intent }); expect(result.ok).toBe(true); const graph = buildReviewGraph({ state: { doc: result.tr.doc }, replacementsMode: 'paired' }); - const child = Array.from(graph.changes.values()).find((change) => change.id !== parentId); - expect(child).toBeDefined(); - expect(child.type).toBe(CanonicalChangeType.Replacement); - expect(child.replacement.inserted[0].attrs.overlapParentId).toBe(parentId); - expect(child.replacement.deleted[0].attrs.overlapParentId).toBe(parentId); + 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', () => { From 9263504541b53182af1f8ba769e16c6d21a0a411 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 22 May 2026 06:51:10 -0700 Subject: [PATCH 08/25] chore: type fixes --- packages/super-editor/src/editors/v1/core/Editor.ts | 6 +++++- .../src/editors/v1/core/types/EditorPublicSurfaces.ts | 2 -- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index c3d656436c..d46bf76941 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -94,6 +94,10 @@ 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 ConverterWithInternalWordIdAllocator = EditorConverterSurface & { + wordIdAllocator?: unknown; +}; + declare const __APP_VERSION__: string | undefined; declare const version: string | undefined; @@ -3389,7 +3393,7 @@ export class Editor extends EventEmitter { reserveFromJson(this.converter.footnotes, 'word/footnotes.xml'); reserveFromJson(this.converter.endnotes, 'word/endnotes.xml'); - this.converter.wordIdAllocator = allocator; + (this.converter as ConverterWithInternalWordIdAllocator).wordIdAllocator = allocator; } /** 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 547780f92b..5e0883b0dc 100644 --- a/packages/super-editor/src/editors/v1/core/types/EditorPublicSurfaces.ts +++ b/packages/super-editor/src/editors/v1/core/types/EditorPublicSurfaces.ts @@ -24,7 +24,6 @@ import type { EditorExtension } from './EditorConfig.js'; import type { CommandProps } from './ChainedCommands.js'; import type { ExtensionAttribute } from '../Attribute.js'; import type { NumberingModel } from '../parts/adapters/numbering-transforms.js'; -import type { WordIdAllocator } from '../../extensions/track-changes/review-model/word-id-allocator.js'; /** * Loosely-typed OOXML part as held in `convertedXml`. Element trees @@ -117,7 +116,6 @@ export interface EditorConverterSurface { * helpers iterate both maps. */ translatedNumbering: { abstracts?: Record; definitions?: Record }; - wordIdAllocator?: WordIdAllocator | null; // --- Methods --- /** From 0c275fd9ca6f4edef0440f13dcb909fb43e3632c Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 22 May 2026 19:01:14 -0700 Subject: [PATCH 09/25] fix: tc fixes --- .../__tests__/lib/special-handlers.test.ts | 89 +++ apps/cli/src/lib/special-handlers.ts | 61 +- .../src/types/track-changes.types.ts | 17 + packages/layout-engine/contracts/src/index.ts | 5 + .../painters/dom/src/renderer.ts | 63 +- .../pm-adapter/src/marks/application.ts | 52 +- .../pm-adapter/src/marks/index.ts | 1 + .../pm-adapter/src/tracked-changes.ts | 27 +- .../context-menu/tests/utils.test.js | 14 + .../v1/components/context-menu/utils.js | 51 +- .../src/editors/v1/core/Editor.ts | 45 +- .../presentation-editor/PresentationEditor.ts | 87 ++- .../tests/PresentationEditor.test.ts | 45 ++ .../core/super-converter/SuperConverter.d.ts | 6 + .../v2/importer/docxImporter.js | 47 ++ .../v2/importer/importTrackingContext.js | 18 +- .../v2/importer/importTrackingContext.test.js | 31 + .../src/editors/v1/core/types/EditorConfig.ts | 2 + .../src/editors/v1/core/types/EditorEvents.ts | 4 + .../helpers/tracked-change-resolver.ts | 86 +++ .../plan-engine/track-changes-wrappers.ts | 4 + .../__tests__/tracked-change-index.test.ts | 39 + .../tracked-changes/tracked-change-index.ts | 76 +- .../tracked-change-snapshot.ts | 3 + .../v1/extensions/comment/comments-plugin.js | 20 +- .../normalize-comment-event-payload.js | 4 + .../permission-ranges.test.js | 24 + .../v1/extensions/protection/editability.js | 21 +- .../track-changes/permission-helpers.test.js | 17 + .../review-model/decision-engine.js | 143 ++-- .../review-model/decision-engine.test.js | 41 + .../track-changes/review-model/edit-intent.js | 4 + .../track-changes/review-model/identity.js | 140 +++- .../review-model/identity.test.js | 110 ++- .../review-model/mark-metadata.js | 3 + .../review-model/overlap-compiler.js | 716 ++++++++++++++---- .../review-model/overlap-compiler.test.js | 195 +++++ .../review-model/review-graph.js | 12 + .../review-model/test-fixtures.js | 2 + .../review-model/word-id-allocator.js | 35 +- .../review-model/word-id-allocator.test.js | 14 + .../track-changes-extension.test.js | 377 +++++---- .../extensions/track-changes/track-changes.js | 371 ++++----- .../extensions/track-changes/track-delete.js | 5 + .../extensions/track-changes/track-format.js | 5 + .../extensions/track-changes/track-insert.js | 5 + .../trackChangesHelpers/addMarkStep.js | 114 +-- .../findTrackedMarkBetween.js | 6 +- .../trackChangesHelpers/markDeletion.js | 29 +- .../trackChangesHelpers/markInsertion.js | 13 +- .../trackChangesHelpers/removeMarkStep.js | 98 +-- .../trackChangesHelpers/replaceAroundStep.js | 9 +- .../trackChangesHelpers/replaceStep.js | 302 ++------ .../trackChangesHelpers.test.js | 40 +- .../trackChangesHelpers/trackedTransaction.js | 14 +- .../v1/extensions/types/comment-commands.ts | 6 + .../v1/extensions/types/mark-attributes.ts | 6 + .../trackChangesRoundtrip-overlap.test.js | 86 +++ packages/superdoc/src/SuperDoc.vue | 13 +- .../CommentsLayer/CommentDialog.vue | 2 + .../CommentsLayer/CommentHeader.test.js | 126 +++ .../CommentsLayer/CommentHeader.vue | 19 +- .../CommentsLayer/CommentsLayer.vue | 1 + .../CommentsLayer/comment-schemas.js | 2 + .../CommentsLayer/comment-schemas.test.js | 3 +- .../use-comment-extended.test.js | 9 +- .../components/CommentsLayer/use-comment.js | 28 +- packages/superdoc/src/core/SuperDoc.ts | 26 +- .../collaboration/awareness-identity.test.js | 37 + .../core/collaboration/collaboration.test.js | 8 +- .../src/core/collaboration/helpers.js | 3 +- packages/superdoc/src/core/types/index.ts | 4 +- .../src/dev/components/SuperdocDev.vue | 15 +- .../superdoc/src/stores/comments-store.js | 22 +- .../src/stores/comments-store.test.js | 9 +- shared/common/collaboration/awareness.ts | 24 +- shared/common/comments-types.ts | 2 + shared/common/identity.ts | 96 +++ shared/common/index.ts | 3 + 79 files changed, 3052 insertions(+), 1260 deletions(-) create mode 100644 apps/cli/src/__tests__/lib/special-handlers.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v2/importer/importTrackingContext.test.js create mode 100644 packages/superdoc/src/components/CommentsLayer/CommentHeader.test.js create mode 100644 packages/superdoc/src/core/collaboration/awareness-identity.test.js create mode 100644 shared/common/identity.ts 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..fbff2a2e9e --- /dev/null +++ b/apps/cli/src/__tests__/lib/special-handlers.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from 'bun:test'; +import { POST_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); + }); +}); diff --git a/apps/cli/src/lib/special-handlers.ts b/apps/cli/src/lib/special-handlers.ts index 24029e20f6..e7b3dadb07 100644 --- a/apps/cli/src/lib/special-handlers.ts +++ b/apps/cli/src/lib/special-handlers.ts @@ -67,6 +67,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 +118,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 +136,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, @@ -207,6 +247,7 @@ const normalizeTrackChangeGetId: PostInvokeHook = (result, context) => { ...record, id: stableId, address: normalizedAddress ? { ...normalizedAddress, entityId: stableId } : record.address, + ...(record.overlap !== undefined ? { overlap: normalizeTrackChangeOverlap(record.overlap, rawToStableId) } : {}), }; }; diff --git a/packages/document-api/src/types/track-changes.types.ts b/packages/document-api/src/types/track-changes.types.ts index 2d785b4cd5..b33cf52d71 100644 --- a/packages/document-api/src/types/track-changes.types.ts +++ b/packages/document-api/src/types/track-changes.types.ts @@ -3,6 +3,19 @@ import type { DiscoveryOutput } from './discovery.js'; import type { StoryLocator } from './story.types.js'; export type TrackChangeType = 'insert' | 'delete' | 'format'; +export type TrackChangeOverlapRelationship = 'parent' | 'child' | 'standalone'; + +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 @@ -34,6 +47,8 @@ export interface TrackChangeInfo { type: TrackChangeType; /** 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; @@ -66,6 +81,8 @@ export interface TrackChangeDomain { type: TrackChangeType; /** 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; 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/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index f89b43b9d1..bf4d0ed883 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, @@ -1023,6 +1024,13 @@ type TrackedChangesRenderConfig = { enabled: boolean; }; +const getTrackedChangeLayers = (run: TextRun): TrackedChangeMeta[] => { + if (Array.isArray(run.trackedChanges) && run.trackedChanges.length > 0) { + return run.trackedChanges; + } + return run.trackedChange ? [run.trackedChange] : []; +}; + /** * Sanitize a URL to prevent XSS attacks. * Only allows http, https, mailto, tel, and internal anchors. @@ -7066,23 +7074,28 @@ export class DomPainter { } const textRun = run as TextRun; - const meta = textRun.trackedChange; - if (!meta) { + const layers = getTrackedChangeLayers(textRun); + if (layers.length === 0) { return; } + const meta = 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); + } + }); 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 +7877,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/pm-adapter/src/marks/application.ts b/packages/layout-engine/pm-adapter/src/marks/application.ts index 493b43232f..58f50df191 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,6 +506,25 @@ export const selectTrackedChangeMeta = ( return existing; }; +const trackedChangeLayerKey = (meta: TrackedChangeMeta): string => `${meta.kind}:${meta.id}`; + +const normalizeTrackedChangeLayers = (run: TextRun): TrackedChangeMeta[] => { + if (Array.isArray(run.trackedChanges) && run.trackedChanges.length > 0) { + return run.trackedChanges; + } + return run.trackedChange ? [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); + } + run.trackedChanges = layers; + run.trackedChange = selectTrackedChangeMeta(run.trackedChange, meta); +}; + /** * 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. @@ -511,11 +534,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); + }); }; /** @@ -534,6 +559,21 @@ export const collectTrackedChangeFromMarks = (marks?: PMMark[], storyKey?: strin }, undefined); }; +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 trackedChanges; +}; + /** * Normalizes underline style value from PM mark attributes. * Returns a valid UnderlineStyle, or undefined for explicit off values. @@ -862,7 +902,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/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.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index d46bf76941..5ef89ea42a 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -34,7 +34,11 @@ 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 } from '@extensions/track-changes/review-model/word-id-allocator.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'; @@ -95,7 +99,9 @@ import { resolveMainBodyEditor } from '../document-api-adapters/helpers/word-sta import { commitLiveStorySessionRuntimes } from '../document-api-adapters/story-runtime/live-story-session-runtime-registry.js'; type ConverterWithInternalWordIdAllocator = EditorConverterSurface & { - wordIdAllocator?: unknown; + wordIdAllocator?: { + getSourceIdMap?: () => Record>; + } | null; }; declare const __APP_VERSION__: string | undefined; @@ -3396,6 +3402,39 @@ export class Editor extends EventEmitter { (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. * @@ -3475,6 +3514,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/v2/importer/docxImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/docxImporter.js index 81dc17383f..b091226ba6 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 @@ -95,6 +96,51 @@ const detectDocumentOrigin = (docx) => { return 'unknown'; }; +const matchesElementName = (name, localName) => { + if (typeof name !== 'string') return false; + return name === localName || name.endsWith(`:${localName}`); +}; + +const 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 +171,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(); 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 index 2ec19303fd..1441ad1024 100644 --- 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 @@ -7,6 +7,7 @@ const contextsByConverter = new WeakMap(); * @typedef {{ * trackedChangeIdMapsByPart?: Map>, * trackedChangeIdMap?: Map, + * trackedChangeSourceIdMapByPart?: Map>, * trackedChangesOptions?: { replacements?: 'paired' | 'independent' }, * }} ConverterLike * @@ -46,6 +47,20 @@ export function getTrackedChangeIdMapForPart(params = {}, partPath = resolveTrac 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 @@ -54,10 +69,11 @@ export function getTrackedChangeIdMapForPart(params = {}, partPath = resolveTrac 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: id, + sourceId: restoredSourceId, logicalId: id && trackedChangeIdMap?.has(id) ? (trackedChangeIdMap.get(id) ?? id) : id, }; } 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/types/EditorConfig.ts b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts index 0e73cb976f..9e039bf953 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; 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/document-api-adapters/helpers/tracked-change-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts index a9abfb41df..33c2b6fc8c 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, @@ -41,10 +43,15 @@ export type GroupedTrackedChange = { attrs: Record; excerpt?: string; wordRevisionIds?: TrackChangeWordRevisionIds; + overlap?: TrackChangeOverlapInfo; }; type ChangeTypeInput = Pick; type GroupedTrackedChangeDraft = Omit & { excerptParts: string[] }; +type InternalTrackChangeOverlapLayer = TrackChangeOverlapLayer & { + rawId?: string; + commandRawId?: string; +}; function getRawTrackedMarks(editor: Editor): RawTrackedMark[] { try { @@ -154,6 +161,84 @@ 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.sourceId), + ]; + return Array.from(new Set(candidates.filter((value): value is string => Boolean(value)))); +} + +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>; @@ -261,6 +346,7 @@ export function groupTrackedChanges(editor: Editor): GroupedTrackedChange[] { 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; 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 28c2fef919..c412d4d0b2 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 @@ -65,6 +65,7 @@ function snapshotToInfo(snapshot: TrackedChangeSnapshot): TrackChangeInfo { id: snapshot.address.entityId, type: snapshot.type, wordRevisionIds: normalizeWordRevisionIds(snapshot.wordRevisionIds), + overlap: snapshot.overlap, author: snapshot.author, authorEmail: snapshot.authorEmail, authorImage: snapshot.authorImage, @@ -152,6 +153,7 @@ export function trackChangesListWrapper(editor: Editor, input?: TrackChangesList address, type, wordRevisionIds, + overlap, author, authorEmail, authorImage, @@ -164,6 +166,7 @@ export function trackChangesListWrapper(editor: Editor, input?: TrackChangesList address, type, wordRevisionIds, + overlap, author, authorEmail, authorImage, @@ -219,6 +222,7 @@ export function trackChangesGetWrapper(editor: Editor, input: TrackChangesGetInp id: resolved.change.id, type, 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), 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 1e33501464..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([ 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 0b461cfc46..b9099ce24a 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,6 +261,7 @@ 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); @@ -210,6 +281,7 @@ class TrackedChangeIndexImpl implements TrackedChangeIndex { date: toNonEmptyString(change.attrs.date), excerpt, wordRevisionIds: change.wordRevisionIds ? { ...change.wordRevisionIds } : undefined, + overlap: copyOverlapInfo(change.overlap, canonicalIdByAlias), storyLabel, storyKind, anchorKey: makeTrackedChangeAnchorKey(runtimeRef), 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..0e7cbcf672 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 @@ -8,6 +8,7 @@ import type { StoryLocator, TrackedChangeAddress, TrackChangeType, + TrackChangeOverlapInfo, TrackChangeWordRevisionIds, } from '@superdoc/document-api'; import type { TrackedChangeRuntimeRef } from '../helpers/tracked-change-runtime-ref.js'; @@ -33,6 +34,8 @@ export interface TrackedChangeSnapshot { excerpt?: string; /** Raw imported Word revision IDs, if present. */ wordRevisionIds?: TrackChangeWordRevisionIds; + /** 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). */ 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 c30700ec92..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(), @@ -1163,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; @@ -1245,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.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/permission-helpers.test.js index 5baf4447a6..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 @@ -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; 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 index 77c2852972..6cd64f2836 100644 --- 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 @@ -331,7 +331,12 @@ const runPermissionPreflight = ({ editor, decision, selections }) => { const change = selection.change; const classification = classifyOwnership({ currentUser: currentIdentity, - change: getChangeAuthorIdentity(change.author ? { author: change.author, authorEmail: change.authorEmail } : {}), + change: getChangeAuthorIdentity({ + author: change.author, + authorId: change.authorId, + authorEmail: change.authorEmail, + importedAuthor: change.importedAuthor, + }), }); const isOwn = isSameUserHighConfidence(classification); const permission = @@ -344,7 +349,7 @@ const runPermissionPreflight = ({ editor, decision, selections }) => { trackedChange: { id: change.id, type: change.type, - attrs: { author: change.author, authorEmail: change.authorEmail, date: change.date }, + 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 })), @@ -515,16 +520,12 @@ const planInsertionDecision = ({ ops, change, selection, decision, removedRanges // 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) { - const segment = - change.insertedSegments.find((s) => s.from <= range.from && s.to >= range.to) ?? change.insertedSegments[0]; - if (!segment) continue; - ops.push({ - kind: 'removeMark', - from: range.from, - to: range.to, + pushRemoveMarkOpsForRange({ + ops, + segments: change.insertedSegments, + range, changeId: change.id, side: SegmentSide.Inserted, - mark: segment.mark, }); } if (isFull) retired.add(change.id); @@ -566,16 +567,12 @@ const planDeletionDecision = ({ ops, change, selection, decision, removedRanges, // 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) { - const segment = - change.deletedSegments.find((s) => s.from <= range.from && s.to >= range.to) ?? change.deletedSegments[0]; - if (!segment) continue; - ops.push({ - kind: 'removeMark', - from: range.from, - to: range.to, + pushRemoveMarkOpsForRange({ + ops, + segments: change.deletedSegments, + range, changeId: change.id, side: SegmentSide.Deleted, - mark: segment.mark, }); } if (isFull) retired.add(change.id); @@ -596,13 +593,11 @@ const planReplacementDecision = ({ ops, change, decision, removedRanges, retired removedRanges.push({ from: seg.from, to: seg.to, cause: `accept-replacement-deleted:${change.id}` }); } for (const seg of inserted) { - ops.push({ - kind: 'removeMark', - from: seg.from, - to: seg.to, + pushRemoveMarkOpsForSegment({ + ops, + segment: seg, changeId: change.id, side: SegmentSide.Inserted, - mark: seg.mark, }); } } else { @@ -612,13 +607,11 @@ const planReplacementDecision = ({ ops, change, decision, removedRanges, retired removedRanges.push({ from: seg.from, to: seg.to, cause: `reject-replacement-inserted:${change.id}` }); } for (const seg of deleted) { - ops.push({ - kind: 'removeMark', - from: seg.from, - to: seg.to, + pushRemoveMarkOpsForSegment({ + ops, + segment: seg, changeId: change.id, side: SegmentSide.Deleted, - mark: seg.mark, }); } } @@ -629,25 +622,25 @@ const planReplacementDecision = ({ ops, change, decision, removedRanges, retired const planFormattingDecision = ({ ops, change, decision, retired }) => { for (const seg of change.formattingSegments) { if (decision === 'accept') { - ops.push({ - kind: 'removeMark', - from: seg.from, - to: seg.to, + pushRemoveMarkOpsForSegment({ + ops, + segment: seg, changeId: change.id, side: SegmentSide.Formatting, - mark: seg.mark, }); } else { - ops.push({ - kind: 'restoreFormat', - from: seg.from, - to: seg.to, - changeId: change.id, - side: SegmentSide.Formatting, - mark: seg.mark, - beforeMarks: seg.mark.attrs?.before ?? [], - afterMarks: seg.mark.attrs?.after ?? [], - }); + 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); @@ -666,7 +659,12 @@ const planPartialTextDecision = ({ ops, change, selection, decision, removedRang let successorOrdinal = 0; for (const segment of segments) { - ops.push({ kind: 'removeMark', from: segment.from, to: segment.to, changeId: change.id, side, mark: segment.mark }); + pushRemoveMarkOpsForSegment({ + ops, + segment, + changeId: change.id, + side, + }); const pieces = subtractRanges({ from: segment.from, to: segment.to }, selectedRanges); for (const piece of pieces) { @@ -708,6 +706,40 @@ const planPartialTextDecision = ({ ops, change, selection, decision, removedRang 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) @@ -874,18 +906,33 @@ const collectCreatedChangeIds = (plan) => { * @param {Object} input * @param {DecisionResult} input.result * @param {object} input.editor - * @returns {Array<{ type: 'trackedChange', event: 'resolve'|'update', changeId: string, resolvedByEmail?: string, resolvedByName?: string }>} + * @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, resolvedByEmail?: string, resolvedByName?: string }>} */ + /** @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, resolvedByEmail, resolvedByName }); + 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, resolvedByEmail, resolvedByName }); + events.push({ + type: 'trackedChange', + event: 'resolve', + changeId: child.changeId, + resolvedById, + resolvedByEmail, + resolvedByName, + }); } return events; }; 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 index 9f5a719d84..cf14ff45cf 100644 --- 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 @@ -88,6 +88,47 @@ describe('decideTrackedChanges overlap behavior', () => { }); }); + 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({ 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 index f7e4df66d5..57e2580587 100644 --- 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 @@ -32,6 +32,10 @@ import { Slice, Fragment } from 'prosemirror-model'; * @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. */ /** 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 index 5fa7f82689..9bd91d2d91 100644 --- 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 @@ -1,13 +1,12 @@ // @ts-check +import { normalizeActorEmail, normalizeActorId, normalizeActorName } from '@superdoc/common'; + /** * Identity helpers for the review graph. * - * Same-user behavior requires high-confidence identity. A trusted author email - * match on both the current editor user and the change author's stored email - * is the only signal that returns `same-user`. Every other combination — - * missing email on either side, only display-name match, imported author - * strings without a trusted email, or mismatched email — returns a - * different-user classification. + * 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. */ /** @@ -16,16 +15,29 @@ * @returns {string} */ export const normalizeEmail = (value) => { - if (typeof value !== 'string') return ''; - const trimmed = value.trim(); - if (!trimmed) return ''; - return trimmed.toLowerCase(); + 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. */ @@ -35,14 +47,15 @@ export const normalizeEmail = (value) => { * Tolerates a missing editor / options / user so callers can pass a partial * editor (or a snapshot for tests). * - * @param {{ options?: { user?: { name?: unknown, email?: unknown } } } | null | undefined} editor + * @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 { email, name, hasEmail: email.length > 0 }; + return { id, email, name, hasId: id.length > 0, hasEmail: email.length > 0 }; }; /** @@ -53,15 +66,30 @@ export const getCurrentUserIdentity = (editor) => { * @returns {UserIdentity} */ export const getChangeAuthorIdentity = (changeOrAttrs) => { - if (!changeOrAttrs) return { email: '', name: '', hasEmail: false }; + 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 : ''; - return { email, name, hasEmail: email.length > 0, 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; }; /** @@ -77,8 +105,9 @@ export const getChangeAuthorIdentity = (changeOrAttrs) => { /** * Classify ownership between the current editor user and a change author. * - * Rules (per plan): - * - normalized authorEmail match is high-confidence => `same-user`. + * 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`. @@ -94,28 +123,21 @@ export const getChangeAuthorIdentity = (changeOrAttrs) => { * @returns {OwnershipClassification} */ export const classifyOwnership = ({ currentUser, change }) => { - const cur = currentUser ?? { email: '', name: '', hasEmail: false }; - const auth = change ?? { email: '', name: '', hasEmail: false }; + 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) { - // Same email but obviously different display name pattern is still - // 'same-user' — display name is not a security signal. Only flag - // `conflicting` if the change carries an explicit `importedAuthor` - // mismatch with a different display, which is an import-provenance - // signal that should NOT be treated as ordinary same-user refinement. - if ( - typeof change?.importedAuthor === 'string' && - change.importedAuthor.trim() && - cur.name && - change.importedAuthor.trim().toLowerCase() !== cur.name.trim().toLowerCase() && - change.name && - change.name !== cur.name - ) { - return 'conflicting'; - } return 'same-user'; } @@ -130,3 +152,55 @@ export const classifyOwnership = ({ currentUser, change }) => { * @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; + + 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 index d73e9d0908..0c7cc59660 100644 --- 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 @@ -5,6 +5,7 @@ import { getChangeAuthorIdentity, classifyOwnership, isSameUserHighConfidence, + matchesSameUserRefinement, } from './identity.js'; describe('review-model/identity', () => { @@ -25,61 +26,111 @@ describe('review-model/identity', () => { describe('getCurrentUserIdentity', () => { it('extracts identity from a configured editor', () => { - const editor = { options: { user: { name: 'Alice', email: 'Alice@example.com' } } }; + 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({ email: '', name: '', hasEmail: false }); - expect(getCurrentUserIdentity({ options: {} })).toEqual({ email: '', name: '', hasEmail: false }); + 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', authorEmail: 'BOB@example.com' } }; - expect(getChangeAuthorIdentity(mark)).toEqual({ email: 'bob@example.com', name: 'Bob', hasEmail: true }); + 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', authorEmail: 'carol@example.com' })).toEqual({ + 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({ email: '', name: '', hasEmail: false }); + expect(getChangeAuthorIdentity(null)).toEqual({ id: '', email: '', name: '', hasId: false, hasEmail: false }); }); }); describe('classifyOwnership', () => { - const alice = { email: 'alice@example.com', name: 'Alice', hasEmail: true }; - const bob = { email: 'bob@example.com', name: 'Bob', hasEmail: true }; + 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: alice, change: { ...alice } })).toBe('same-user'); + 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: alice, change: bob })).toBe('different-user'); + 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: { email: '', name: '', hasEmail: false }, change: bob })).toBe( - 'unknown-current-user', - ); + 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: alice, change: { email: '', name: 'B', hasEmail: false } })).toBe( - 'unknown-change-author', - ); + 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: { email: '', name: 'Alice', hasEmail: false }, - change: { email: '', name: 'Alice', hasEmail: false }, + currentUser: { id: '', email: '', name: 'Alice', hasId: false, hasEmail: false }, + change: { id: '', email: '', name: 'Alice', hasId: false, hasEmail: false }, }), ).toBe('unknown-current-user'); }); @@ -98,5 +149,26 @@ describe('review-model/identity', () => { 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); + }); }); }); 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 index 7f2d59d876..2a8c3cb05c 100644 --- 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 @@ -221,6 +221,7 @@ const canonicalSourceIdsFromObject = (obj) => { * @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. @@ -304,6 +305,7 @@ export const readTrackedAttrs = (markOrAttrs, markName) => { 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), @@ -357,6 +359,7 @@ export const normalizedAttrsEqual = (a, b) => { 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; 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 index d6a7d0b15d..0f46f4ea47 100644 --- 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 @@ -20,7 +20,7 @@ */ import { Slice, Fragment } from 'prosemirror-model'; -import { ReplaceStep } from 'prosemirror-transform'; +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'; @@ -30,7 +30,20 @@ import { 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 @@ -62,11 +75,17 @@ import { * @property {Array<{ from: string, to: string }>} remappedChangeIds * @property {SelectionHint} [selection] * @property {GraphDiagnostic[]} [diagnostics] - * @property {import('prosemirror-model').Mark} [insertedMark] + * @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] */ /** @@ -188,6 +207,28 @@ const classifySegment = (ctx, segment) => { 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 @@ -211,6 +252,7 @@ const makeInsertMark = (ctx, { id, overlapParentId = '', replacementGroupId = '' 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, @@ -234,6 +276,7 @@ const makeDeleteMark = (ctx, { id, overlapParentId = '', replacementGroupId = '' 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, @@ -309,14 +352,18 @@ const compileTextInsert = (ctx, intent) => { !overlapParent && containing && (containing.to === at || containing.from === at) ? containing : null; // Same-user refinement targets: own insertion that strictly contains `at`, - // OR an own-insertion edge we are adjacent to. Adjacent same-user own - // insertion still refines the same id (extend the run). + // OR an own-insertion edge we are adjacent to. 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 && classifySegment(ctx, overlapParent) === 'same-user' + overlapParent && overlapParent.side === SegmentSide.Inserted && isSameUserForRefinement(ctx, overlapParent) ? overlapParent : boundaryAdjacent && boundaryAdjacent.side === SegmentSide.Inserted && - classifySegment(ctx, boundaryAdjacent) === 'same-user' + isSameUserForRefinement(ctx, boundaryAdjacent) ? boundaryAdjacent : null; @@ -379,6 +426,12 @@ const applyInsert = (ctx, at, slice, insertMark, changeId, { update, create }) = 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, @@ -390,6 +443,7 @@ const applyInsert = (ctx, at, slice, insertMark, changeId, { update, create }) = insertedMark: insertMark, insertedFrom, insertedTo, + insertedNodes, }; }; @@ -438,7 +492,9 @@ const compileTextDelete = (ctx, intent) => { 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, }; }; @@ -449,8 +505,8 @@ const compileTextDelete = (ctx, intent) => { * @param {*} ctx * @param {number} from * @param {number} to - * @param {{ replacementGroupId: string, replacementSideId: string, sharedDeletionId: string | null, recordSharedDeletionId?: boolean, recordCollapsedIds?: boolean }} options - * @returns {{ ok: true, deletionMarks: import('prosemirror-model').Mark[], deletionId: string } | TrackedEditFailure} + * @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, @@ -462,16 +518,31 @@ const applyTrackedDelete = ( 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'|'reassign'|'mark-delete'|'noop', from: number, to: number, changeId?: string, parentId?: string, parentSide?: string, parentReplacementGroupId?: string }>} */ + /** @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; @@ -484,20 +555,42 @@ const applyTrackedDelete = ( if (insertMark) { const segmentAtPos = ctx.graph.overlapAt(pos)[0] ?? null; - const ownership = classifySegment(ctx, segmentAtPos ?? { attrs: insertMark.attrs }); - if (ownership === 'same-user') { + const classification = classifyOwnership({ + currentUser: ctx.currentIdentity, + change: getChangeAuthorIdentity(segmentAtPos?.attrs ?? insertMark.attrs), + }); + const ownership = isSameUserHighConfidence(classification) ? 'same-user' : 'different-user'; + if (ownership === 'same-user' || isImportedOwnInsertion(insertMark)) { // 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, parentId, parentSide: SegmentSide.Inserted }); + ops.push({ + kind: 'mark-delete', + from: segFrom, + to: segTo, + node, + parentId, + parentSide: SegmentSide.Inserted, + }); return; } if (existingDelete) { const ownership = classifySegment(ctx, { attrs: existingDelete.attrs }); + const allExistingDeletes = node.marks.filter((m) => m.type.name === TrackDeleteMarkName); + if (reassignExistingDeletions) { + ops.push({ + kind: 'reassign', + from: segFrom, + to: segTo, + node, + existingDeleteMarks: allExistingDeletes, + }); + return; + } if (ownership === 'same-user') { // Inside own deletion → no semantic change (preserve original). ops.push({ kind: 'noop', from: segFrom, to: segTo }); @@ -508,6 +601,7 @@ const applyTrackedDelete = ( kind: 'mark-delete', from: segFrom, to: segTo, + node, parentId: existingDelete.attrs.id, parentSide: SegmentSide.Deleted, }); @@ -515,7 +609,7 @@ const applyTrackedDelete = ( } // Live content. - ops.push({ kind: 'mark-delete', from: segFrom, to: segTo }); + ops.push({ kind: 'mark-delete', from: segFrom, to: segTo, node }); }); if (!ops.length) { @@ -541,6 +635,29 @@ const applyTrackedDelete = ( 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, @@ -551,6 +668,7 @@ const applyTrackedDelete = ( 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; @@ -572,7 +690,7 @@ const applyTrackedDelete = ( } } - return { ok: true, deletionMarks, deletionId }; + return { ok: true, deletionMarks, deletionNodes, deletionId, mintedThisCall }; }; // --------------------------------------------------------------------------- @@ -664,54 +782,202 @@ const compileTextReplace = (ctx, intent) => { 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); - // Paired vs independent: 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. + + // 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 ?? ''; - const replacementSideId = sharedId ? `${sharedId}#deleted` : ''; - // Step 1 — tracked delete (collapses own insertions, marks live/other content). + // 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, + replacementSideId: sharedId ? `${sharedId}#deleted` : '', sharedDeletionId: sharedId, + reassignExistingDeletions: Boolean(sharedId), }); if (delResult.ok === false) return delResult; - if (sharedId && delResult.deletionMarks?.length) { - ctx.createdChangeIds.push(sharedId); + 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); } } - // Step 2 — tracked insert at the original `from`. Recompute graph context - // after the deletion so own-insertion collapse adjustments don't push the - // insertion past the intended cursor. - if (sanitizedSlice.content.size) { - // We must re-resolve the insertion position because collapsed - // own-insertion content shrinks the doc. - const insertId = sharedId ?? intent.replacementGroupHint ?? uuidv4(); - const insertMark = makeInsertMark(ctx, { - id: insertId, - overlapParentId: replacementParentId, - replacementGroupId, - replacementSideId: sharedId ? `${sharedId}#inserted` : '', - }); - const insertPos = clampToDocSize(ctx.tr.doc.content.size, intent.from); - const insertResult = applyInsert(ctx, insertPos, sanitizedSlice, insertMark, insertId, { - create: sharedId ? false : true, - update: sharedId ? true : false, - }); - if (!insertResult.ok) return insertResult; - return { - ...insertResult, - selection: { kind: 'near', pos: insertResult.insertedTo, bias: 1 }, - }; - } + /** @type {SelectionHint} */ + const selection = + insertedLength > 0 ? { kind: 'near', pos: insertedToAbs, bias: 1 } : { kind: 'near', pos: intent.from, bias: -1 }; return { ok: true, @@ -720,7 +986,15 @@ const compileTextReplace = (ctx, intent) => { updatedChangeIds: ctx.updatedChangeIds, removedChangeIds: ctx.removedChangeIds, remappedChangeIds: ctx.remappedChangeIds, - selection: { kind: 'near', pos: intent.from, bias: -1 }, + selection, + insertedMark, + insertedFrom: insertedFromAbs, + insertedTo: insertedToAbs, + insertedNodes, + insertedStep: condensedStep, + deletionMark, + deletionMarks, + deletionNodes, }; }; @@ -747,7 +1021,7 @@ const getReplacementParentId = (ctx, segments) => { }; // --------------------------------------------------------------------------- -// format-apply / format-remove (SD-486 folding) +// format-apply / format-remove // --------------------------------------------------------------------------- /** @@ -760,82 +1034,39 @@ const compileFormat = (ctx, intent) => { return failure('CAPABILITY_UNAVAILABLE', `Mark ${intent.mark.type.name} is not a tracked formatting mark.`); } - // Walk segments in range. For each contiguous subrange: - // - if covered by same-user own insertion (or replacement inserted side), - // directly apply/remove the mark (SD-486 fold). - // - if covered by other-user inserted content, defer to trackFormat - // creation. To minimize compiler/legacy duplication we leave this to - // the existing addMarkStep/removeMarkStep helper by returning a hint; - // however since the compiler must drive consistent semantics, we - // directly create the formatting change here over the entire other- - // content range using the same canonical attrs. - // - if mixed structural ranges (paragraph boundaries we can't safely - // model under the tracked text scope), fail closed. - const subranges = computeFormatSubranges(ctx, intent.from, intent.to); - if (!subranges) return failure('CAPABILITY_UNAVAILABLE', 'format range crosses unsupported structural boundary.'); + 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]; - if (!trackFormatType) return failure('CAPABILITY_UNAVAILABLE', 'schema is missing trackFormat mark.'); + 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 (intent.kind === 'format-apply') { - if (range.fold) { - ctx.tr.addMark(range.from, range.to, intent.mark); - } else { - ctx.tr.addMark(range.from, range.to, intent.mark); - const formatMark = trackFormatType.create({ - id: uuidv4(), - author: ctx.intent.user.name || '', - authorEmail: ctx.intent.user.email || '', - authorImage: ctx.intent.user.image || '', - date: ctx.intent.date, - before: [], - after: [{ type: intent.mark.type.name, attrs: intent.mark.attrs }], - sourceId: '', - importedAuthor: '', - revisionGroupId: '', - splitFromId: '', - changeType: CanonicalChangeType.Formatting, - replacementGroupId: '', - replacementSideId: '', - overlapParentId: range.parentId || '', - sourceIds: null, - origin: '', - }); - ctx.tr.addMark(range.from, range.to, formatMark); - formatMarks.push(formatMark); - ctx.createdChangeIds.push(formatMark.attrs.id); - } - } else { - if (range.fold) { - ctx.tr.removeMark(range.from, range.to, intent.mark); - } else { - ctx.tr.removeMark(range.from, range.to, intent.mark); - const formatMark = trackFormatType.create({ - id: uuidv4(), - author: ctx.intent.user.name || '', - authorEmail: ctx.intent.user.email || '', - authorImage: ctx.intent.user.image || '', - date: ctx.intent.date, - before: [{ type: intent.mark.type.name, attrs: intent.mark.attrs }], - after: [], - sourceId: '', - importedAuthor: '', - revisionGroupId: '', - splitFromId: '', - changeType: CanonicalChangeType.Formatting, - replacementGroupId: '', - replacementSideId: '', - overlapParentId: range.parentId || '', - sourceIds: null, - origin: '', - }); - ctx.tr.addMark(range.from, range.to, formatMark); - formatMarks.push(formatMark); - ctx.createdChangeIds.push(formatMark.attrs.id); + 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); } } } @@ -852,68 +1083,235 @@ const compileFormat = (ctx, intent) => { }; /** - * Build the list of contiguous subranges inside [from, to] that share the - * same "format folding" decision. Returns null when the range crosses a - * boundary the compiler refuses to handle (e.g. a non-textblock structural - * node). + * 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 }> | null} + * @returns {Array<{ from: number, to: number, fold: boolean, parentId?: string, node: import('prosemirror-model').Node }> | null} */ -const computeFormatSubranges = (ctx, from, to) => { - /** @type {Array<{ from: number, to: number, fold: boolean, parentId?: string }>} */ - const out = []; - let boundaryCrossed = false; - let lastTextBlock = 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; - if (boundaryCrossed) return false; - // Identify the textblock parent of this inline leaf. - const $pos = ctx.tr.doc.resolve(pos); - const parent = $pos.parent?.type?.name ?? ''; - if (!parent) return; - if (lastTextBlock === null) lastTextBlock = parent; - // We do not consider crossing textblocks unsafe here; PM clips ranges - // to inline content. The compiler refuses only structural marks (handled - // by the SUPPORTED_KINDS check above). 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 }); - if (ownership === 'same-user') { - appendSubrange(out, { from: segFrom, to: segTo, fold: true }); + 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 { - appendSubrange(out, { from: segFrom, to: segTo, fold: false, parentId: insertMark.attrs.id }); + before = [...beforeSnapshots]; + after = upsertMarkSnapshotByType(afterSnapshots, { + type: intent.mark.type.name, + attrs: intent.mark.attrs, + }); } - return; + } 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)]; } - const deleteMark = node.marks.find((m) => m.type.name === TrackDeleteMarkName); - if (deleteMark) { - // Tracked-deleted content: do not modify formatting; legacy addMarkStep - // also skips this. Fail closed to avoid silent drift. - boundaryCrossed = true; - return false; + + 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 }; } - appendSubrange(out, { from: segFrom, to: segTo, fold: false }); - }); - if (boundaryCrossed) return null; - return out; + if (formatChangeMark) ctx.tr.removeMark(range.from, range.to, formatChangeMark); + } + + return { sharedWid: wid, formatMark: null }; }; -const appendSubrange = (out, range) => { - const last = out[out.length - 1]; - if (last && last.fold === range.fold && last.parentId === range.parentId && last.to === range.from) { - last.to = range.to; - return; +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 }; } - out.push(range); + + 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 @@ -925,4 +1323,4 @@ const findContainingSegment = (ctx, pos) => findSegmentAt(ctx, pos); // Diagnostics surfaced for telemetry/tests. // --------------------------------------------------------------------------- -export const compilerInternalsForTest = { stripTrackedMarksFromSlice, classifySegment, computeFormatSubranges }; +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 index 0cd991837c..01aa8220e7 100644 --- 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 @@ -21,6 +21,8 @@ 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'; @@ -342,6 +344,102 @@ describe('overlap-compiler: text-delete', () => { 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: '', 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('no-ops when deleting inside own deletion', () => { const delId = 'del-alice'; const { state } = stateFromTrackedSpans({ @@ -361,6 +459,59 @@ describe('overlap-compiler: text-delete', () => { }); }); +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'; @@ -535,6 +686,50 @@ describe('overlap-compiler: format folding (SD-486)', () => { 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([ 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 index b92f155872..4ce6600194 100644 --- 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 @@ -32,6 +32,13 @@ 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 @@ -42,6 +49,7 @@ import { BODY_STORY, buildStoryKey } from './story-locator.js'; * @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 @@ -71,6 +79,7 @@ import { BODY_STORY, buildStoryKey } from './story-locator.js'; * @property {Array} formattingSegments * @property {LogicalReplacementProjection | null} replacement * @property {string} author + * @property {string} authorId * @property {string} authorEmail * @property {string} authorImage * @property {string} date @@ -327,6 +336,7 @@ const mergeAdjacentSpans = (normalized) => { if (canMerge) { last.to = span.to; + last.markRuns.push({ from: span.from, to: span.to, mark: span.mark }); continue; } @@ -346,6 +356,7 @@ const mergeAdjacentSpans = (normalized) => { to: span.to, text: '', mark: span.mark, + markRuns: [{ from: span.from, to: span.to, mark: span.mark }], attrs, parentId: attrs.overlapParentId || '', parentSide: '', @@ -474,6 +485,7 @@ const buildLogicalChange = ({ changeId, segments, doc, story, replacementsMode } formattingSegments: formatting, replacement, author: primary?.author ?? '', + authorId: primary?.authorId ?? '', authorEmail: primary?.authorEmail ?? '', authorImage: primary?.authorImage ?? '', date: primary?.date ?? '', 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 index 9ebe68d833..0eee3a748e 100644 --- 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 @@ -30,6 +30,7 @@ const NODES = { const MARK_DEFS_WITH_GRAPH_ATTRS = { id: { default: '' }, author: { default: '' }, + authorId: { default: '' }, authorEmail: { default: '' }, authorImage: { default: '' }, date: { default: '' }, @@ -111,6 +112,7 @@ export const stateFromTrackedSpans = ({ schema, spans }) => { export const markAttrs = (attrs) => ({ id: '', author: '', + authorId: '', authorEmail: '', authorImage: '', date: '', 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 index 19f9a9b3fa..d4e623c7b3 100644 --- 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 @@ -25,6 +25,7 @@ * 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 * @@ -32,11 +33,14 @@ * 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`. @@ -70,12 +74,25 @@ export function createWordIdAllocator() { 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; @@ -113,7 +130,11 @@ export function createWordIdAllocator() { // so both sides emit the same `w:id` on export, matching Word's pairing // convention. if (logicalId && state.assignedByLogicalId.has(logicalId)) { - return String(state.assignedByLogicalId.get(logicalId)); + const assigned = state.assignedByLogicalId.get(logicalId); + if (typeof assigned === 'number') { + recordSourceIdRewrite(state, sourceId, assigned); + return String(assigned); + } } let n = state.nextDecimal; @@ -121,9 +142,20 @@ export function createWordIdAllocator() { 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 = {}; @@ -141,6 +173,7 @@ export function createWordIdAllocator() { 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 index c1e8cf6b40..185ddfe19d 100644 --- 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 @@ -83,6 +83,20 @@ describe('createWordIdAllocator', () => { 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'); 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.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js index 0555eea2d1..17743e0ed1 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,12 +1,9 @@ import { Extension } from '@core/Extension.js'; -import { Slice } from 'prosemirror-model'; -import { Mapping, ReplaceStep, AddMarkStep, RemoveMarkStep } from 'prosemirror-transform'; import { TrackDeleteMarkName, TrackInsertMarkName, TrackFormatMarkName } from './constants.js'; import { TrackChangesBasePlugin, TrackChangesBasePluginKey } from './plugins/index.js'; import { getTrackChanges } from './trackChangesHelpers/getTrackChanges.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 { @@ -54,15 +51,130 @@ const dispatchReviewDecision = ({ editor, state, dispatch, decision, target }) = return { applied: false, failure: result }; } 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); - const events = buildDecisionBubbleEvents({ result, editor }); - if (editor?.emit && events.length) { - for (const event of events) editor.emit('commentsUpdate', event); + + if (editor?.emit) { + // 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); + } + + 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: editor.options?.documentId, + originalId: changeId, + remaining, + }); + if (payload) { + editor.emit('commentsUpdate', payload); + emittedFor.add(changeId); + } + } } } return { applied: true, result }; }; +/** + * 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 {Array<{ from: number, to: number, mark: import('prosemirror-model').Mark, node: import('prosemirror-model').Node }>} + */ +const collectRemainingForLogicalId = ({ state, originalId }) => { + const all = getTrackChanges(state); + return all.filter(({ mark }) => mark.attrs?.id === originalId || mark.attrs?.splitFromId === originalId); +}; + +const isSuccessorOf = ({ state, id, originalId }) => { + const all = getTrackChanges(state); + return all.some(({ mark }) => mark.attrs?.id === id && mark.attrs?.splitFromId === originalId); +}; + +/** + * 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; + + 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 } }), + }; +}; + export const TrackChanges = Extension.create({ name: 'trackChanges', @@ -85,49 +197,7 @@ export const TrackChanges = Extension.create({ decision: 'accept', target: { kind: 'range', from, to }, }); - if (reviewDecision) return reviewDecision.applied; - - const trackedChanges = collectTrackedChanges({ state, from, to }); - if (!isTrackedChangeActionAllowed({ editor, action: 'accept', trackedChanges })) return false; - - let { tr, doc } = state; - - // if (from === to) { - // to += 1; - // } - - // tr.setMeta('acceptReject', true); - tr.setMeta('inputType', 'acceptReject'); - const touchedChangeIds = new Set(); - 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) { - const deletionStep = new ReplaceStep(mappedFrom, mappedTo, Slice.empty); - tr.step(deletionStep); - map.appendMap(deletionStep.getMap()); - return; - } - - tr.step(new RemoveMarkStep(mappedFrom, mappedTo, trackedMark)); - }); - - return dispatchTrackedChangeResolution({ - state, - tr, - dispatch, - editor, - touchedChangeIds, - }); + return reviewDecision.applied; }, rejectTrackedChangesBetween: @@ -140,70 +210,7 @@ export const TrackChanges = Extension.create({ decision: 'reject', target: { kind: 'range', from, to }, }); - if (reviewDecision) return reviewDecision.applied; - - 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({ - state, - tr, - dispatch, - editor, - touchedChangeIds, - }); + return reviewDecision.applied; }, acceptTrackedChange: @@ -256,7 +263,7 @@ export const TrackChanges = Extension.create({ acceptTrackedChangeById: (id) => - ({ state, tr, dispatch, editor, commands }) => { + ({ state, dispatch, editor }) => { const reviewDecision = dispatchReviewDecision({ editor, state, @@ -264,22 +271,12 @@ export const TrackChanges = Extension.create({ decision: 'accept', target: { kind: 'id', id }, }); - if (reviewDecision) return reviewDecision.applied; - - 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); + return reviewDecision.applied; }, acceptAllTrackedChanges: () => - ({ state, dispatch, editor, commands }) => { + ({ state, dispatch, editor }) => { const reviewDecision = dispatchReviewDecision({ editor, state, @@ -287,16 +284,12 @@ export const TrackChanges = Extension.create({ decision: 'accept', target: { kind: 'all' }, }); - if (reviewDecision) return reviewDecision.applied; - - const from = 0, - to = state.doc.content.size; - return commands.acceptTrackedChangesBetween(from, to); + return reviewDecision.applied; }, rejectTrackedChangeById: (id) => - ({ state, tr, dispatch, editor, commands }) => { + ({ state, dispatch, editor }) => { const reviewDecision = dispatchReviewDecision({ editor, state, @@ -304,17 +297,7 @@ export const TrackChanges = Extension.create({ decision: 'reject', target: { kind: 'id', id }, }); - if (reviewDecision) return reviewDecision.applied; - - 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); + return reviewDecision.applied; }, rejectTrackedChange: @@ -367,7 +350,7 @@ export const TrackChanges = Extension.create({ rejectAllTrackedChanges: () => - ({ state, dispatch, editor, commands }) => { + ({ state, dispatch, editor }) => { const reviewDecision = dispatchReviewDecision({ editor, state, @@ -375,11 +358,7 @@ export const TrackChanges = Extension.create({ decision: 'reject', target: { kind: 'all' }, }); - if (reviewDecision) return reviewDecision.applied; - - const from = 0, - to = state.doc.content.size; - return commands.rejectTrackedChangesBetween(from, to); + return reviewDecision.applied; }, insertTrackedChange: @@ -530,10 +509,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)) { @@ -624,76 +599,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); @@ -843,11 +748,20 @@ const dispatchCompiledInsertTrackedChange = ({ 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.deletionMarks?.[0] || null, - deletionNodes: [], - step: result.insertedMark ? { slice: { content: { content: [] } } } : 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); @@ -868,6 +782,7 @@ const dispatchCompiledInsertTrackedChange = ({ parentId: changeId, content: comment, author: resolvedUser.name, + authorId: resolvedUser.id, authorEmail: resolvedUser.email, authorImage: resolvedUser.image, }); 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 1a7e67e205..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'), 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 1706f2e464..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'), 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 f8af8c29ec..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'), 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 7b14d8d772..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,7 @@ // @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'; @@ -26,9 +16,6 @@ import { makeFormatIntent } from '../review-model/edit-intent.js'; * @param {string} options.date Date. */ export const addMarkStep = ({ state, step, newTr, doc, user, date }) => { - // Route tracked run-format intents through the compiler so formatting - // inside same-user own insertion/replacement folds into the inserted side - // instead of producing a separate trackFormat. if (TrackedFormatMarkNames.includes(step.mark.type.name)) { const intentUser = { name: user?.name || '', @@ -59,18 +46,10 @@ export const addMarkStep = ({ state, step, newTr, doc, user, date }) => { newTr.setMeta(CommentsPluginKey, { type: 'force' }); return; } - if (result.ok === false && result.code !== 'CAPABILITY_UNAVAILABLE') { - // Fail closed for typed errors; do not silently apply untracked. - return; - } - // Otherwise fall through to legacy path. + // Fail closed for tracked formatting; do not silently apply untracked. + return; } - /** @type {{ formatMark?: import('prosemirror-model').Mark, step?: import('prosemirror-transform').AddMarkStep }} */ - const meta = {}; - /** @type {string | null} */ - let sharedWid = null; - doc.nodesBetween(step.from, step.to, (node, pos) => { if (!node.isInline || node.type.name === 'run') { return; @@ -80,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..89c98873ec 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 @@ -27,6 +27,7 @@ export const findTrackedMarkBetween = ({ to, markName, attrs = {}, + predicate = null, offset = 1, // To get non-inclusive marks. }) => { const { doc } = tr; @@ -43,7 +44,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/markDeletion.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/markDeletion.js index aff29e1244..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,7 +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 { normalizeEmail } from '../review-model/identity.js'; +import { + getCurrentUserIdentity, + getChangeAuthorIdentity, + matchesSameUserRefinement, + shouldCollapseNoEmailInsertion, +} from '../review-model/identity.js'; /** * Mark deletion. @@ -18,15 +23,20 @@ import { normalizeEmail } from '../review-model/identity.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 }) => { - 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); - // Missing identity is not same-user. Only a trusted authorEmail match counts. - if (!authorEmail || !userEmail) return false; - 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 = @@ -36,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), + }), }) ); @@ -53,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 ec80c2b384..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,6 @@ -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'; @@ -53,13 +45,10 @@ export const removeMarkStep = ({ state, step, newTr, doc, user, date }) => { newTr.setMeta(CommentsPluginKey, { type: 'force' }); return; } - if (result.code !== 'CAPABILITY_UNAVAILABLE') return; - // Fall through to legacy on capability gaps. + // Fail closed for tracked formatting; do not silently apply untracked. + return; } - const meta = {}; - let sharedWid = null; - doc.nodesBetween(step.from, step.to, (node, pos) => { if (!node.isInline || node.type.name === 'run') { return true; @@ -69,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 b89c6edeec..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 @@ -223,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; @@ -236,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 ae74d04181..b81075f60c 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,7 @@ 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'; @@ -182,6 +177,7 @@ export const replaceStep = ({ step, stepWasNormalized, originalStep, + originalStepIndex, map, user, date, @@ -197,13 +193,13 @@ export const replaceStep = ({ // 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) => { @@ -220,188 +216,11 @@ export const replaceStep = ({ return; } } - - 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; - } - } - - // 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; - } - - 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 }; - }; - - const openSlice = Slice.maxOpen(step.slice.content, true); - const insertion = tryInsert(step.slice) || tryInsert(openSlice); - - // 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()); - } - return; - } - - 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); - } - - // 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; - } - - // 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 (!newTr.selection.eq(tempTr.selection)) { - syncSelectionFromTransaction({ targetTr: newTr, sourceSelection: tempTr.selection }); - } - - 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); - } - - // Add meta to the new transaction. - newTr.setMeta(TrackChangesBasePluginKey, meta); - newTr.setMeta(CommentsPluginKey, { type: 'force' }); + // 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. }; -/** - * 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} - */ /** * Try to route a text-shaped ReplaceStep through the overlap-aware compiler. * @@ -410,13 +229,25 @@ export const replaceStep = ({ * - `{ 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 falls through - * to the legacy path. + * 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, map, user, date, replacements }) => { - // Empty structural deletion handled by the existing legacy branch above. +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) => { @@ -446,6 +277,11 @@ const tryCompileStep = ({ state, tr, newTr, step, stepWasNormalized, originalSte date, source: 'native', }); + // 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 }; @@ -456,6 +292,7 @@ const tryCompileStep = ({ state, tr, newTr, step, stepWasNormalized, originalSte const beforeSize = newTr.doc.content.size; const beforeSteps = newTr.steps.length; + const newTrDocBeforeCompile = newTr.doc; const result = compileTrackedEdit({ state, tr: newTr, @@ -464,22 +301,44 @@ const tryCompileStep = ({ state, tr, newTr, step, stepWasNormalized, originalSte }); if (!result.ok) { - // Structural fallback: when the compiler reports CAPABILITY_UNAVAILABLE - // for a content shape it cannot model (e.g. mixed structural slice), let - // the legacy path handle it. Otherwise — INVALID_TARGET or - // PRECONDITION_FAILED — fail closed. - if (result.code === 'CAPABILITY_UNAVAILABLE') return { handled: false }; return { failed: true, error: new Error(result.message) }; } - // Track that we mutated newTr. We still need to update the outer mapping - // (`map`) so subsequent steps in the same transaction can map through. - for (let i = beforeSteps; i < newTr.steps.length; i += 1) { - map.appendMap(newTr.steps[i].getMap()); + // 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 (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()); + } } - // Mirror the position-mapping behavior expected by trackedTransaction - // selection logic: when there is an inserted side, record `insertedTo`. + // 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 = {}; if (typeof result.insertedTo === 'number') { meta.insertedTo = result.insertedTo; @@ -487,9 +346,23 @@ const tryCompileStep = ({ state, tr, newTr, step, stepWasNormalized, originalSte if (result.insertedMark) { meta.insertedMark = result.insertedMark; } - if (result.deletionMarks?.length) { + if (result.deletionMark) { + meta.deletionMark = result.deletionMark; + } else if (result.deletionMarks?.length) { meta.deletionMark = result.deletionMarks[0]; } + if (result.deletionNodes?.length) { + meta.deletionNodes = result.deletionNodes; + } + 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; } @@ -498,20 +371,3 @@ const tryCompileStep = ({ state, tr, newTr, step, stepWasNormalized, originalSte return { handled: true, sizeDelta: newTr.doc.content.size - beforeSize }; }; - -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)); -}; 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 b255c09eb3..a97118a116 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, }; }; 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/import-export/trackChangesRoundtrip-overlap.test.js b/packages/super-editor/src/editors/v1/tests/import-export/trackChangesRoundtrip-overlap.test.js index 42e0c0d947..9929d75dcf 100644 --- 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 @@ -20,8 +20,10 @@ 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']); @@ -325,4 +327,88 @@ describe('overlap export — allocator collision behavior', () => { 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/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index b9df414235..77f3cb526d 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -866,6 +866,7 @@ const REPLAY_MUTABLE_COMMENT_FIELDS = new Set([ 'trackedChangeDisplayType', 'deletedText', 'resolvedTime', + 'resolvedById', 'resolvedByEmail', 'resolvedByName', 'importedAuthor', @@ -874,18 +875,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 +1015,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..1e57fb3a33 --- /dev/null +++ b/packages/superdoc/src/components/CommentsLayer/CommentHeader.test.js @@ -0,0 +1,126 @@ +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('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..8769074984 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 } 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,12 @@ 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) => + actorIdentitiesMatch({ + current: proxy.$superdoc.config.user, + other: { id: comment?.creatorId, email: comment?.creatorEmail }, + }); +const isOwnComment = computed(() => isCommentOwnedByCurrentUser(props.comment)); const { uiFontFamily } = useUiFontFamily(); @@ -70,7 +76,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 +93,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 +116,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 +150,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..de56c792e3 100644 --- a/packages/superdoc/src/stores/comments-store.js +++ b/packages/superdoc/src/stores/comments-store.js @@ -216,17 +216,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 +521,7 @@ export const useCommentsStore = defineStore('comments', () => { trackedChangeType, trackedChangeDisplayType, deletedText, + authorId, authorEmail, authorImage, date, @@ -557,6 +561,7 @@ export const useCommentsStore = defineStore('comments', () => { trackedChangeDisplayType, deletedText, createdTime: date, + creatorId: authorId ?? null, creatorName: authorName, creatorEmail: authorEmail, creatorImage: authorImage, @@ -606,6 +611,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 +641,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 +657,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 +698,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, @@ -988,6 +1003,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 +1202,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 +1212,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 +1422,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 +1612,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 2440913c54..ba1d9a01d9 100644 --- a/packages/superdoc/src/stores/comments-store.test.js +++ b/packages/superdoc/src/stores/comments-store.test.js @@ -604,7 +604,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 +614,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 +635,7 @@ describe('comments-store', () => { }); expect(existingComment.resolveComment).toHaveBeenCalledWith({ + id: 'reviewer-id', email: 'reviewer@example.com', name: 'Reviewer', superdoc, @@ -654,7 +656,7 @@ describe('comments-store', () => { it('cascades resolve to user comments anchored to the same tracked change (SD-2528)', 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 = { @@ -699,6 +701,7 @@ describe('comments-store', () => { await Promise.resolve(); expect(linkedUserComment.resolveComment).toHaveBeenCalledTimes(1); expect(linkedUserComment.resolveComment).toHaveBeenCalledWith({ + id: 'reviewer-id', email: 'reviewer@example.com', name: 'Reviewer', superdoc, 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..acd0b29f25 --- /dev/null +++ b/shared/common/identity.ts @@ -0,0 +1,96 @@ +type IdentityLike = 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 ?? {}) as Record; + 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'; From 0afa7e7ad64582522c99d2aaacca02850db6541b Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 22 May 2026 19:39:25 -0700 Subject: [PATCH 10/25] fix: more cases --- .../Editor.track-changes-dispatch.test.js | 104 ++++++++++++++++++ .../src/editors/v1/core/Editor.ts | 6 +- .../track-changes/review-model/edit-intent.js | 32 +++++- .../track-changes/review-model/identity.js | 24 ++++ .../review-model/identity.test.js | 17 +++ .../review-model/overlap-compiler.js | 6 +- .../review-model/overlap-compiler.test.js | 43 +++++++- .../trackChangesHelpers/replaceStep.js | 20 +++- .../trackChangesHelpers/trackedTransaction.js | 7 +- 9 files changed, 250 insertions(+), 9 deletions(-) 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 5cd08abf50..8aceeb7d04 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 @@ -276,6 +276,110 @@ describe('Editor dispatch tracked-change meta', () => { ); }); + it('protects anonymous live tracked insertion from 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(editor.state.doc.textContent).toContain(INSERTED_TEXT); + expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe(INSERTED_TEXT); + + const childDeletion = markEntries(editor, TrackDeleteMarkName).find(({ text }) => text === INSERTED_TAIL); + expect(childDeletion?.mark.attrs).toEqual( + expect.objectContaining({ + author: '', + authorEmail: '', + overlapParentId: FOREIGN_INSERT_ID, + }), + ); + }); + + it('protects anonymous live tracked insertion 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(editor.state.doc.textContent).toContain(INSERTED_TEXT); + expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe(INSERTED_TEXT); + + const childDeletion = markEntries(editor, TrackDeleteMarkName).find(({ text }) => text === INSERTED_TAIL); + expect(childDeletion?.mark.attrs).toEqual( + expect.objectContaining({ + author: '', + authorEmail: '', + overlapParentId: FOREIGN_INSERT_ID, + }), + ); + }); + + it('protects 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-review-comment'); + + expect(textForMarkId(editor, TrackInsertMarkName, insertMark?.mark.attrs.id)).toBe('live-review-comment'); + + const childDeletion = markEntries(editor, TrackDeleteMarkName).find(({ text }) => text === 'review'); + expect(childDeletion?.mark.attrs).toEqual( + expect.objectContaining({ + author: 'CLI', + authorId: 'cli', + authorEmail: '', + overlapParentId: insertMark?.mark.attrs.id, + }), + ); + + const trackedChanges = editor.doc.trackChanges.list(); + expect(trackedChanges.total).toBe(2); + expect(trackedChanges.items.map((item) => item.raw?.type ?? item.type).sort()).toEqual(['delete', 'insert']); + + const comments = editor.doc.comments.list(); + expect(comments.total).toBe(2); + expect(comments.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ trackedChangeType: 'insert', trackedChangeText: 'live-review-comment' }), + expect.objectContaining({ trackedChangeType: 'delete', deletedText: 'review' }), + ]), + ); + }); + it('protects another user tracked insertion from direct replace while local track mode is off', () => { ({ editor } = initTestEditor({ mode: 'text', diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index 5ef89ea42a..3bb7960ecb 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -2893,6 +2893,9 @@ export class Editor extends EventEmitter { const isTrackChangesActive = trackChangesState?.isTrackChangesActive ?? false; const skipTrackChanges = transactionToApply.getMeta('skipTrackChanges') === true; const protectsExistingTrackedReviewState = transactionTouchesTrackedReviewState(prevState, transactionToApply); + if (protectsExistingTrackedReviewState && skipTrackChanges) { + transactionToApply.setMeta('protectTrackedReviewState', true); + } const shouldTrack = ((isTrackChangesActive || forceTrackChanges) && !skipTrackChanges) || protectsExistingTrackedReviewState; @@ -2900,11 +2903,12 @@ export class Editor extends EventEmitter { 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; 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 index 57e2580587..c7b1acff4d 100644 --- 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 @@ -36,6 +36,10 @@ import { Slice, Fragment } from 'prosemirror-model'; * 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. */ /** @@ -102,10 +106,20 @@ export const toSliceContent = (schema, content) => { * date: string, * source: EditIntentSource, * replacementGroupHint?: string, + * preserveExistingReviewState?: boolean, * }} input * @returns {TrackedEditIntent} */ -export const makeTextInsertIntent = ({ at, content, schema, user, date, source, replacementGroupHint }) => { +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'); } @@ -119,6 +133,7 @@ export const makeTextInsertIntent = ({ at, content, schema, user, date, source, date, source, ...(replacementGroupHint ? { replacementGroupHint } : {}), + ...(preserveExistingReviewState ? { preserveExistingReviewState: true } : {}), }; }; @@ -132,10 +147,19 @@ export const makeTextInsertIntent = ({ at, content, schema, user, date, source, * date: string, * source: EditIntentSource, * replacementGroupHint?: string, + * preserveExistingReviewState?: boolean, * }} input * @returns {TrackedEditIntent} */ -export const makeTextDeleteIntent = ({ from, to, user, date, source, replacementGroupHint }) => { +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'); } @@ -148,6 +172,7 @@ export const makeTextDeleteIntent = ({ from, to, user, date, source, replacement date, source, ...(replacementGroupHint ? { replacementGroupHint } : {}), + ...(preserveExistingReviewState ? { preserveExistingReviewState: true } : {}), }; }; @@ -164,6 +189,7 @@ export const makeTextDeleteIntent = ({ from, to, user, date, source, replacement * date: string, * source: EditIntentSource, * replacementGroupHint?: string, + * preserveExistingReviewState?: boolean, * }} input * @returns {TrackedEditIntent} */ @@ -177,6 +203,7 @@ export const makeTextReplaceIntent = ({ date, source, replacementGroupHint, + preserveExistingReviewState, }) => { if (!isFiniteNonNeg(from) || !isFiniteNonNeg(to)) { throw new Error('makeTextReplaceIntent: `from`/`to` must be non-negative finite numbers'); @@ -194,6 +221,7 @@ export const makeTextReplaceIntent = ({ date, source, ...(replacementGroupHint ? { replacementGroupHint } : {}), + ...(preserveExistingReviewState ? { preserveExistingReviewState: true } : {}), }; }; 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 index 9bd91d2d91..0871012543 100644 --- 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 @@ -92,6 +92,28 @@ const hasImportedAuthorConflict = ({ currentUser, change }) => { 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' @@ -197,6 +219,8 @@ export const shouldCollapseNoEmailInsertion = ({ currentUser, insertionAttrs }) 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; 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 index 0c7cc59660..76fcea2d77 100644 --- 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 @@ -6,6 +6,7 @@ import { classifyOwnership, isSameUserHighConfidence, matchesSameUserRefinement, + shouldCollapseNoEmailInsertion, } from './identity.js'; describe('review-model/identity', () => { @@ -170,5 +171,21 @@ describe('review-model/identity', () => { }), ).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/overlap-compiler.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/overlap-compiler.js index 0f46f4ea47..bb07ac4a7e 100644 --- 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 @@ -560,7 +560,9 @@ const applyTrackedDelete = ( change: getChangeAuthorIdentity(segmentAtPos?.attrs ?? insertMark.attrs), }); const ownership = isSameUserHighConfidence(classification) ? 'same-user' : 'different-user'; - if (ownership === 'same-user' || isImportedOwnInsertion(insertMark)) { + 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; @@ -722,7 +724,7 @@ const compileTextReplace = (ctx, intent) => { // 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) { + if (ownInsertedTarget && !intent.preserveExistingReviewState) { const deleteResult = applyTrackedDelete(ctx, intent.from, intent.to, { replacementGroupId: '', replacementSideId: '', 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 index 01aa8220e7..166ece1fe4 100644 --- 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 @@ -429,7 +429,12 @@ describe('overlap-compiler: text-delete', () => { const parentId = 'ins-unattributed'; const { state } = stateFromTrackedSpans({ schema, - spans: [{ text: 'draft', marks: [insertMark({ id: parentId, author: '', authorEmail: '', date: FIXED_DATE })] }], + 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 }); @@ -440,6 +445,42 @@ describe('overlap-compiler: text-delete', () => { 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({ 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 b81075f60c..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 @@ -263,10 +263,25 @@ const tryCompileStep = ({ // 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' }); + 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' }); + 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, @@ -276,6 +291,7 @@ const tryCompileStep = ({ 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 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 a97118a116..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 @@ -348,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 ( From 70d0aa7b7b47e7fa0399ee7907fbfa401cdd6706 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 22 May 2026 19:51:23 -0700 Subject: [PATCH 11/25] fix: expose tracked mark predicate option --- .../track-changes/trackChangesHelpers/findTrackedMarkBetween.js | 2 ++ 1 file changed, 2 insertions(+) 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 89c98873ec..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 From 72fe04f08adc36d8d4e02510b7232af874d20834 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 22 May 2026 20:27:16 -0700 Subject: [PATCH 12/25] fix: restore tracked change comment interactions --- .../review-model/overlap-compiler.js | 22 ++---- .../review-model/overlap-compiler.test.js | 27 +++++++ .../CommentsLayer/CommentHeader.test.js | 14 ++++ .../CommentsLayer/CommentHeader.vue | 25 ++++-- .../superdoc/src/stores/comments-store.js | 78 ++++++++++++++++++- .../src/stores/comments-store.test.js | 29 +++++++ 6 files changed, 169 insertions(+), 26 deletions(-) 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 index bb07ac4a7e..42f87dcfad 100644 --- 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 @@ -457,8 +457,7 @@ const applyInsert = (ctx, at, slice, insertMark, changeId, { update, create }) = * Behavior per segment: * - own-insertion (covered/partial) → collapse: remove the inserted slice * - other-insertion → child trackDelete with overlapParentId - * - own-deletion → no-op (preserve) - * - other-deletion → preserve parent; child trackDelete with overlapParentId + * - existing deletion → no-op (plain delete preserves existing review ids) * - live content → trackDelete mark * * @param {*} ctx @@ -581,7 +580,6 @@ const applyTrackedDelete = ( } if (existingDelete) { - const ownership = classifySegment(ctx, { attrs: existingDelete.attrs }); const allExistingDeletes = node.marks.filter((m) => m.type.name === TrackDeleteMarkName); if (reassignExistingDeletions) { ops.push({ @@ -593,20 +591,10 @@ const applyTrackedDelete = ( }); return; } - if (ownership === 'same-user') { - // Inside own deletion → no semantic change (preserve original). - ops.push({ kind: 'noop', from: segFrom, to: segTo }); - return; - } - // Inside different-user deletion → child trackDelete with overlapParentId. - ops.push({ - kind: 'mark-delete', - from: segFrom, - to: segTo, - node, - parentId: existingDelete.attrs.id, - parentSide: SegmentSide.Deleted, - }); + // 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; } 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 index 166ece1fe4..cbc95fb9b9 100644 --- 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 @@ -498,6 +498,33 @@ describe('overlap-compiler: text-delete', () => { 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', () => { diff --git a/packages/superdoc/src/components/CommentsLayer/CommentHeader.test.js b/packages/superdoc/src/components/CommentsLayer/CommentHeader.test.js index 1e57fb3a33..5875ca3fb4 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentHeader.test.js +++ b/packages/superdoc/src/components/CommentsLayer/CommentHeader.test.js @@ -115,6 +115,20 @@ describe('CommentHeader.vue', () => { ); }); + 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' }, diff --git a/packages/superdoc/src/components/CommentsLayer/CommentHeader.vue b/packages/superdoc/src/components/CommentsLayer/CommentHeader.vue index 8769074984..00c56453ef 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentHeader.vue +++ b/packages/superdoc/src/components/CommentsLayer/CommentHeader.vue @@ -2,7 +2,7 @@ import { formatDate } from './helpers'; import { superdocIcons } from '@superdoc/icons.js'; import { computed, getCurrentInstance } from 'vue'; -import { actorIdentitiesMatch } from '@superdoc/common'; +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'; @@ -38,11 +38,24 @@ const props = defineProps({ const { proxy } = getCurrentInstance(); const role = proxy.$superdoc.config.role; const isInternal = proxy.$superdoc.config.isInternal; -const isCommentOwnedByCurrentUser = (comment) => - actorIdentitiesMatch({ - current: proxy.$superdoc.config.user, - other: { id: comment?.creatorId, email: comment?.creatorEmail }, - }); +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(); diff --git a/packages/superdoc/src/stores/comments-store.js b/packages/superdoc/src/stores/comments-store.js index de56c792e3..29e600b968 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. diff --git a/packages/superdoc/src/stores/comments-store.test.js b/packages/superdoc/src/stores/comments-store.test.js index ba1d9a01d9..6f7fa9a538 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', From 4a3af769b8b9d2d7c0d1cd3cbc7496bb5a047c08 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 22 May 2026 20:37:40 -0700 Subject: [PATCH 13/25] chore: generated files update From 3b96d8f356e3e188c64aceff919c1d1cee28c197 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 22 May 2026 20:51:41 -0700 Subject: [PATCH 14/25] fix: coalesce tracked inserts across run gaps --- .../review-model/overlap-compiler.js | 54 ++++++++++- .../review-model/overlap-compiler.test.js | 96 +++++++++++++++++++ 2 files changed, 148 insertions(+), 2 deletions(-) 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 index 42f87dcfad..ced0855fed 100644 --- 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 @@ -101,6 +101,7 @@ import { getLiveInlineMarksInRange } from '../trackChangesHelpers/getLiveInlineM */ 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. @@ -243,6 +244,49 @@ const findSegmentAt = (ctx, pos) => { 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]; @@ -350,9 +394,11 @@ const compileTextInsert = (ctx, intent) => { 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`, - // OR an own-insertion edge we are adjacent to. Refinement uses the + // 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: '' })` @@ -365,7 +411,11 @@ const compileTextInsert = (ctx, intent) => { boundaryAdjacent.side === SegmentSide.Inserted && isSameUserForRefinement(ctx, boundaryAdjacent) ? boundaryAdjacent - : null; + : emptyGapAdjacent && + emptyGapAdjacent.side === SegmentSide.Inserted && + isSameUserForRefinement(ctx, emptyGapAdjacent) + ? emptyGapAdjacent + : null; if (refinementTarget) { const refinedId = refinementTarget.changeId; 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 index cbc95fb9b9..b74633eb24 100644 --- 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 @@ -27,6 +27,30 @@ 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) }); @@ -145,6 +169,78 @@ describe('overlap-compiler: same-user own-insertion refinement (SD-486-adjacent 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({ From 74d69d313e1aa9b12bc6b9870761f4f444954fbb Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 22 May 2026 22:33:40 -0700 Subject: [PATCH 15/25] fix: remaining collab bugs --- .../src/__tests__/lib/error-mapping.test.ts | 11 + .../__tests__/lib/validate-type-spec.test.ts | 33 + apps/cli/src/lib/error-mapping.ts | 7 + .../reference/_generated-manifest.json | 2 +- .../reference/comments/create.mdx | 18 +- .../document-api/reference/comments/get.mdx | 62 ++ .../document-api/reference/comments/list.mdx | 54 ++ .../document-api/reference/comments/patch.mdx | 22 +- .../reference/track-changes/get.mdx | 25 +- .../reference/track-changes/list.mdx | 23 +- apps/mcp/src/generated/catalog.ts | 25 +- .../src/comments/comments.test.ts | 20 +- .../document-api/src/comments/comments.ts | 75 +- .../src/comments/comments.types.ts | 44 +- .../src/contract/contract.test.ts | 47 ++ .../src/contract/operation-definitions.ts | 2 +- .../src/contract/operation-registry.ts | 3 +- packages/document-api/src/contract/schemas.ts | 68 +- packages/document-api/src/format/format.ts | 17 + packages/document-api/src/index.test.ts | 182 ++++- packages/document-api/src/index.ts | 46 +- .../document-api/src/invoke/invoke.test.ts | 12 +- .../src/overview-examples.test.ts | 12 +- .../src/types/track-changes.types.ts | 5 + .../src/__tests__/handle-and-tools.test.ts | 40 ++ packages/sdk/langs/node/src/index.ts | 18 + .../src/editors/v1/core/Editor.ts | 7 +- .../helpers/comment-entity-store.test.ts | 27 +- .../helpers/comment-entity-store.ts | 136 ++-- .../helpers/tracked-change-resolver.ts | 29 +- .../plan-engine/comments-wrappers.test.ts | 188 +++-- .../plan-engine/comments-wrappers.ts | 657 ++++++++++++++++-- .../track-changes-wrappers.test.ts | 44 +- .../plan-engine/track-changes-wrappers.ts | 197 +++++- .../tracked-changes/tracked-change-index.ts | 6 + .../tracked-change-snapshot.ts | 9 + .../review-model/comment-effects.js | 26 +- .../review-model/decision-engine.js | 15 +- .../extensions/track-changes/track-changes.js | 43 +- .../exportDocx.commentsFallback.test.js | 37 + .../src/editors/v1/utils/comment-content.ts | 35 + packages/superdoc/src/SuperDoc.vue | 4 + .../superdoc/src/stores/comments-store.js | 40 +- .../src/stores/comments-store.test.js | 27 +- 44 files changed, 2072 insertions(+), 328 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/utils/comment-content.ts diff --git a/apps/cli/src/__tests__/lib/error-mapping.test.ts b/apps/cli/src/__tests__/lib/error-mapping.test.ts index cbb0f165da..52f5384b25 100644 --- a/apps/cli/src/__tests__/lib/error-mapping.test.ts +++ b/apps/cli/src/__tests__/lib/error-mapping.test.ts @@ -14,6 +14,17 @@ 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' } }); + }); }); // --------------------------------------------------------------------------- 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/lib/error-mapping.ts b/apps/cli/src/lib/error-mapping.ts index 4a24bc3a75..d45a9fe287 100644 --- a/apps/cli/src/lib/error-mapping.ts +++ b/apps/cli/src/lib/error-mapping.ts @@ -41,6 +41,10 @@ function mapTrackChangesError(operationId: CliExposedOperationId, error: unknown const message = extractErrorMessage(error); const details = extractErrorDetails(error); + if (operationId === 'trackChanges.decide' && code === 'TARGET_NOT_FOUND') { + 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 }); } @@ -439,6 +443,9 @@ export function mapFailedReceipt(operationId: CliExposedOperationId, result: unk // Track-changes family if (family === 'trackChanges') { + if (operationId === 'trackChanges.decide' && failureCode === 'TARGET_NOT_FOUND') { + 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/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index ecbc575eb2..76fd594f32 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -1077,5 +1077,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "a9aff0330980d98962b9026299b98b684ce60e47e88b163e2d12040d40bf5b0c" + "sourceHash": "b2a628b73f6cb983a78e8068179f0c18cc99024112b143c1a832b95b4c2ad914" } 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/track-changes/get.mdx b/apps/docs/document-api/reference/track-changes/get.mdx index 3b26d367da..2b63b81477 100644 --- a/apps/docs/document-api/reference/track-changes/get.mdx +++ b/apps/docs/document-api/reference/track-changes/get.mdx @@ -56,8 +56,10 @@ Returns a TrackChangeInfo object with the change type, author, date, affected co | `date` | string | no | | | `deletedText` | string | no | | | `excerpt` | string | no | | +| `grouping` | enum | no | `"standalone"`, `"replacement-pair"`, `"aggregate"`, `"unknown"` | | `id` | string | yes | | | `insertedText` | string | no | | +| `pairedWithChangeId` | any | no | | | `type` | enum | yes | `"insert"`, `"delete"`, `"format"` | | `wordRevisionIds` | object | no | | | `wordRevisionIds.delete` | string | no | | @@ -77,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" } ``` @@ -143,12 +142,26 @@ Returns a TrackChangeInfo object with the change type, author, date, affected co "excerpt": { "type": "string" }, + "grouping": { + "enum": [ + "standalone", + "replacement-pair", + "aggregate", + "unknown" + ] + }, "id": { "type": "string" }, "insertedText": { "type": "string" }, + "pairedWithChangeId": { + "type": [ + "string", + "null" + ] + }, "type": { "enum": [ "insert", diff --git a/apps/docs/document-api/reference/track-changes/list.mdx b/apps/docs/document-api/reference/track-changes/list.mdx index 59ab4aa6d3..51216270d0 100644 --- a/apps/docs/document-api/reference/track-changes/list.mdx +++ b/apps/docs/document-api/reference/track-changes/list.mdx @@ -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": { @@ -172,6 +169,14 @@ Returns a TrackChangesListResult with tracked change entries, total count, and r "excerpt": { "type": "string" }, + "grouping": { + "enum": [ + "standalone", + "replacement-pair", + "aggregate", + "unknown" + ] + }, "handle": { "$ref": "#/$defs/ResolvedHandle" }, @@ -181,6 +186,12 @@ Returns a TrackChangesListResult with tracked change entries, total count, and r "insertedText": { "type": "string" }, + "pairedWithChangeId": { + "type": [ + "string", + "null" + ] + }, "type": { "enum": [ "insert", diff --git a/apps/mcp/src/generated/catalog.ts b/apps/mcp/src/generated/catalog.ts index 4cd0a69ba6..e95c818b37 100644 --- a/apps/mcp/src/generated/catalog.ts +++ b/apps/mcp/src/generated/catalog.ts @@ -2327,16 +2327,35 @@ export const MCP_TOOL_CATALOG = { { $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.", + "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.", + "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', 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 0a20e4741e..a061509e3b 100644 --- a/packages/document-api/src/contract/contract.test.ts +++ b/packages/document-api/src/contract/contract.test.ts @@ -214,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 { diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index 8790b6de19..37cec1a83b 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -2374,7 +2374,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', diff --git a/packages/document-api/src/contract/operation-registry.ts b/packages/document-api/src/contract/operation-registry.ts index 2bd05fab8f..64b10bda58 100644 --- a/packages/document-api/src/contract/operation-registry.ts +++ b/packages/document-api/src/contract/operation-registry.ts @@ -46,6 +46,7 @@ import type { FormatInlineAliasInput, StyleApplyInput } from '../format/format.j import type { InlineRunPatchKey } from '../format/inline-run-patch.js'; import type { StylesApplyInput, StylesApplyOptions, StylesApplyReceipt } from '../styles/index.js'; import type { + CommentsCreateReceipt, CommentsCreateInput, CommentsPatchInput, CommentsDeleteInput, @@ -867,7 +868,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 010c7ecef6..bb16029e7b 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'], ); @@ -1506,6 +1558,8 @@ const trackChangeInfoSchema = objectSchema( address: trackedChangeAddressSchema, id: { type: 'string' }, type: { enum: ['insert', 'delete', 'format'] }, + grouping: { enum: ['standalone', 'replacement-pair', 'aggregate', 'unknown'] }, + pairedWithChangeId: { type: ['string', 'null'] }, wordRevisionIds: trackChangeWordRevisionIdsSchema, author: { type: 'string' }, authorEmail: { type: 'string' }, @@ -1522,6 +1576,8 @@ const trackChangeDomainItemSchema = discoveryItemSchema( { address: trackedChangeAddressSchema, type: { enum: ['insert', 'delete', 'format'] }, + grouping: { enum: ['standalone', 'replacement-pair', 'aggregate', 'unknown'] }, + pairedWithChangeId: { type: ['string', 'null'] }, wordRevisionIds: trackChangeWordRevisionIdsSchema, author: { type: 'string' }, authorEmail: { type: 'string' }, @@ -4876,9 +4932,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', @@ -4887,8 +4943,8 @@ const operationSchemas: Record = { }, ['text'], ), - output: receiptResultSchemaFor('comments.create'), - success: receiptSuccessSchema, + output: commentsCreateResultSchemaFor('comments.create'), + success: commentsCreateSuccessSchema, failure: receiptFailureResultSchemaFor('comments.create'), }, 'comments.patch': { @@ -4896,7 +4952,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: 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 6afcce9348..3644439682 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 })), @@ -544,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); }); @@ -567,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); }); @@ -794,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; @@ -2079,6 +2117,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' }); @@ -2135,7 +2191,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', ); }); @@ -2190,6 +2246,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', () => { @@ -2267,6 +2375,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' }); @@ -2318,7 +2444,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', ); }); @@ -2388,6 +2514,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 b35e2084b2..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, @@ -1445,6 +1455,7 @@ export type { SectionsSetVerticalAlignInput, } from './sections/sections.types.js'; export type { + CommentsCreateReceipt, CommentsCreateInput, CommentsPatchInput, CommentsDeleteInput, @@ -1462,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'; @@ -1643,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). */ @@ -2020,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 { @@ -2045,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/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/types/track-changes.types.ts b/packages/document-api/src/types/track-changes.types.ts index b33cf52d71..87d9b3a685 100644 --- a/packages/document-api/src/types/track-changes.types.ts +++ b/packages/document-api/src/types/track-changes.types.ts @@ -4,6 +4,7 @@ import type { StoryLocator } from './story.types.js'; export type TrackChangeType = 'insert' | 'delete' | 'format'; export type TrackChangeOverlapRelationship = 'parent' | 'child' | 'standalone'; +export type TrackChangeGrouping = 'standalone' | 'replacement-pair' | 'aggregate' | 'unknown'; export interface TrackChangeOverlapLayer { id: string; @@ -45,6 +46,8 @@ 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. */ @@ -79,6 +82,8 @@ 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. */ 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/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index 3bb7960ecb..09d2ed487a 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -71,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'; @@ -3484,9 +3485,13 @@ 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, }; }); 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..61712ea60c 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 @@ -287,7 +287,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 +298,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..ef380d3e57 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,5 +1,14 @@ 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'; @@ -19,6 +28,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; } @@ -109,21 +128,6 @@ 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 collectTextFragments(value: unknown, sink: string[]): void { if (!value) return; @@ -146,6 +150,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 +184,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 +202,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 +235,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 +277,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 +290,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 +348,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 +389,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/tracked-change-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts index 33c2b6fc8c..e163db05ae 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 @@ -46,6 +46,8 @@ export type GroupedTrackedChange = { overlap?: TrackChangeOverlapInfo; }; +export type TrackedChangeProjectedSide = 'inserted' | 'deleted'; + type ChangeTypeInput = Pick; type GroupedTrackedChangeDraft = Omit & { excerptParts: string[] }; type InternalTrackChangeOverlapLayer = TrackChangeOverlapLayer & { @@ -353,13 +355,18 @@ export function groupTrackedChanges(editor: Editor): GroupedTrackedChange[] { } 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 { baseId, side } = splitProjectedTrackedChangeId(rawId); const grouped = groupTrackedChanges(editor); - return grouped.find((item) => item.rawId === rawId || item.commandRawId === rawId)?.id ?? null; + const canonical = + grouped.find((item) => item.rawId === baseId || item.commandRawId === baseId || item.id === baseId)?.id ?? null; + if (!canonical) return null; + return side ? `${canonical}#${side}` : canonical; } export function buildTrackedChangeCanonicalIdMap(editor: Editor): Map { @@ -368,10 +375,25 @@ export function buildTrackedChangeCanonicalIdMap(editor: Editor): Map item.id === id || item.rawId === id || item.commandRawId === 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 a3e1d81804..3cb7828b68 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 @@ -26,19 +26,26 @@ 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 }, ): CommentAnchor { @@ -75,8 +82,8 @@ function mockTextBetweenSequence(editor: Editor, ...values: string[]): void { } beforeEach(() => { - vi.mocked(listCommentAnchors).mockReturnValue([]); - vi.mocked(getTrackedChangeIndex).mockReturnValue({ getAll: () => [] } as never); + listCommentAnchorsMock.mockReturnValue([]); + getTrackedChangeIndexMock.mockReturnValue({ getAll: () => [] } as never); }); describe('comments-wrappers: anchoredText', () => { @@ -86,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(); @@ -96,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(); @@ -112,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(); @@ -125,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' }); @@ -137,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(); @@ -153,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(); @@ -168,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(); @@ -177,9 +184,7 @@ 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 }); @@ -188,7 +193,7 @@ describe('comments-wrappers: anchoredText', () => { it('projects live tracked changes as tracked-change comments', () => { const editor = makeEditor([]); - vi.mocked(getTrackedChangeIndex).mockReturnValue({ + getTrackedChangeIndexMock.mockReturnValue({ getAll: () => [ { address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-live' }, @@ -226,7 +231,7 @@ describe('comments-wrappers: anchoredText', () => { it('does not add synthetic comments for imported Word tracked changes', () => { const editor = makeEditor([]); - vi.mocked(getTrackedChangeIndex).mockReturnValue({ + getTrackedChangeIndexMock.mockReturnValue({ getAll: () => [ { address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-imported' }, @@ -259,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, @@ -280,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, @@ -314,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, @@ -340,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(); @@ -351,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, @@ -379,7 +384,7 @@ describe('comments-wrappers: multi-segment TextTarget', () => { ]); mockTextBetweenSequence(editor, 'abc', 'def'); - vi.mocked(listCommentAnchors).mockReturnValue([ + listCommentAnchorsMock.mockReturnValue([ makeAnchor({ commentId: 'c1', pos: 0, @@ -417,7 +422,7 @@ describe('comments-wrappers: multi-segment TextTarget', () => { return 'second segment'; }); - vi.mocked(listCommentAnchors).mockReturnValue([ + listCommentAnchorsMock.mockReturnValue([ makeAnchor({ commentId: 'c1', pos: 999, @@ -444,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, @@ -473,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, @@ -502,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, @@ -532,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, @@ -566,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, @@ -628,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; @@ -658,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; @@ -688,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; @@ -696,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); @@ -718,14 +723,103 @@ 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('creates a tracked-change-linked comment without a text selection when a deletion 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); + const receipt = wrapper.add({ + text: 'comment on deletion', + target: { + trackedChangeId: 'tc-del-1#deleted', + } as Parameters[0]['target'], + }); + + expect(receipt.success).toBe(true); + expect(editor.commands!.setTextSelection as ReturnType).not.toHaveBeenCalled(); + expect(editor.commands!.addComment as ReturnType).not.toHaveBeenCalled(); + expect(editor.converter!.comments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + commentId: receipt.id, + commentText: 'comment on deletion', + trackedChange: true, + trackedChangeParentId: 'tc-del-1', + trackedChangeType: 'delete', + }), + ]), + ); + expect((editor as { emit: ReturnType }).emit).toHaveBeenCalledWith( + 'commentsUpdate', + expect.objectContaining({ + type: 'add', + activeCommentId: receipt.id, + comment: expect.objectContaining({ + commentId: receipt.id, + trackedChangeParentId: 'tc-del-1', + trackedChangeType: 'delete', + }), + }), + ); + }); + 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); @@ -762,8 +856,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); @@ -797,8 +891,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); @@ -823,8 +917,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); @@ -852,7 +946,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; @@ -882,7 +976,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 69d8b867c6..d96ec2f329 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,6 +27,7 @@ import type { ReplyToCommentInput, ResolveCommentInput, RevisionGuardOptions, + SelectionTarget, StoryLocator, SetCommentActiveInput, SetCommentInternalInput, @@ -37,12 +41,14 @@ 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, @@ -54,6 +60,9 @@ import { listCommentAnchors, resolveCommentAnchorsById } from '../helpers/commen 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, splitProjectedTrackedChangeId } from '../helpers/tracked-change-resolver.js'; +import { BODY_STORY_KEY, buildStoryKey } from '../story-runtime/story-key.js'; // --------------------------------------------------------------------------- // Internal helpers @@ -69,9 +78,12 @@ 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 } { @@ -88,13 +100,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 @@ -152,6 +157,132 @@ function targetToSegments( 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); @@ -183,6 +314,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') { @@ -204,6 +345,58 @@ function applyTextSelection(editor: Editor, from: number, to: number): boolean { return false; } +function addDetachedTrackedChangeComment( + editor: Editor, + input: AddCommentInput, + target: Extract, + options?: RevisionGuardOptions, +): CommentsCreateReceipt { + if (options?.expectedRevision) { + checkRevision(editor, options.expectedRevision); + } + + const commentId = uuidv4(); + const now = Date.now(); + const store = getCommentEntityStore(editor); + const user = (editor.options?.user ?? {}) as EditorUserIdentity; + const { baseId, side } = splitProjectedTrackedChangeId(target.trackedChangeId); + const trackedChangeType = side === 'deleted' ? 'delete' : side === 'inserted' ? 'insert' : null; + const trackedChangeStory = target.story ?? ({ kind: 'story', storyType: 'body' } as StoryLocator); + + upsertCommentEntity(store, commentId, { + commentId, + commentText: input.text, + commentJSON: buildCommentJsonFromText(input.text), + parentCommentId: undefined, + createdTime: now, + creatorName: user.name, + creatorEmail: user.email, + creatorImage: user.image, + isDone: false, + isInternal: false, + fileId: editor.options?.documentId, + documentId: editor.options?.documentId, + trackedChange: true, + trackedChangeParentId: baseId, + trackedChangeType, + trackedChangeDisplayType: null, + trackedChangeStory, + trackedChangeStoryKind: trackedChangeStory.kind === 'story' ? trackedChangeStory.storyType : null, + trackedChangeStoryLabel: + trackedChangeStory.kind === 'story' && trackedChangeStory.storyType === 'body' ? 'Body' : null, + trackedChangeAnchorKey: null, + trackedChangeText: trackedChangeType === 'delete' ? '' : null, + deletedText: trackedChangeType === 'delete' ? '' : null, + }); + + const stored = findCommentEntity(store, commentId); + if (stored) { + emitCommentAdd(editor, buildCommentLifecyclePayload(stored), commentId); + } + + return { success: true, id: commentId, inserted: [toCommentAddress(commentId)] }; +} + function resolveCommentIdentity( editor: Editor, commentId: string, @@ -264,6 +457,8 @@ type CanonicalAnchor = { end: number; }; +type CanonicalAnchorMap = Map; + /** * Merges same-block adjacent/overlapping anchors into canonical segments. * @@ -361,8 +556,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); @@ -374,6 +570,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); @@ -404,6 +601,8 @@ function mergeAnchorData( ), ); } + + return canonicalByCommentId; } function parseCreatedTime(value: string | undefined): number | undefined { @@ -427,6 +626,7 @@ function toTrackedChangeCommentInfo(snapshot: TrackedChangeSnapshot): TrackedCha if (!commentId) return null; const { trackedChangeText, deletedText } = trackedChangeTextFields(snapshot); + const trackedChangeLink = buildTrackedChangeLink(snapshot); return { address: toCommentAddress(commentId), @@ -440,9 +640,11 @@ function toTrackedChangeCommentInfo(snapshot: TrackedChangeSnapshot): TrackedCha story: snapshot.story, trackedChange: true, trackedChangeType: snapshot.type, + trackedChangeStory: snapshot.story, trackedChangeAnchorKey: snapshot.anchorKey, trackedChangeText, deletedText, + trackedChangeLink, }; } @@ -469,6 +671,7 @@ function mergeTrackedChangeCommentInfos(editor: Editor, infosById: Map(); let cursor: CommentInfo | undefined = info; while (cursor?.parentCommentId && !visited.has(cursor.parentCommentId)) { @@ -505,6 +721,9 @@ function buildCommentInfos(editor: Editor): TrackedChangeCommentInfo[] { 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; @@ -527,50 +746,254 @@ function buildCommentInfos(editor: Editor): TrackedChangeCommentInfo[] { 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.', @@ -595,7 +1018,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.', @@ -603,19 +1026,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: @@ -628,18 +1042,67 @@ 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.', }, }; } + let resolvedTarget: CommentTargetResolution; + try { + resolvedTarget = resolveCommentTarget(editor, target); + } catch (error) { + if ( + isTrackedChangeCommentTargetShape(target) && + error instanceof DocumentApiAdapterError && + error.code === 'TARGET_NOT_FOUND' + ) { + return addDetachedTrackedChangeComment(editor, input, target, options); + } + throw error; + } + if (!resolvedTarget.ok) { + 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: { @@ -651,6 +1114,7 @@ function addCommentHandler(editor: Editor, input: AddCommentInput, options?: Rev } const commentId = uuidv4(); + let trackedPayload: Record | null = null; const receipt = executeDomainCommand( editor, @@ -675,7 +1139,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; }, @@ -689,7 +1158,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 { @@ -736,7 +1209,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) { @@ -750,7 +1227,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, @@ -763,7 +1246,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, @@ -777,7 +1259,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); }, @@ -791,30 +1278,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) { + return { success: false, failure: resolvedTarget.failure }; } + const store = getCommentEntityStore(editor); const identity = resolveCommentIdentity(editor, input.commentId); if (!identity.anchors.length) { return { @@ -833,17 +1312,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 }, ); @@ -854,6 +1353,10 @@ function moveCommentHandler(editor: Editor, input: MoveCommentInput, options?: R }; } + if (trackedPayload) { + emitCommentLifecycleUpdate(editor, 'update', trackedPayload); + } + return { success: true, updated: [toCommentAddress(identity.commentId)] }; } @@ -1196,9 +1699,12 @@ function listCommentsHandler(editor: Editor, query?: CommentsListQuery): Comment story, trackedChange, trackedChangeType, + trackedChangeDisplayType, + trackedChangeStory, trackedChangeAnchorKey, trackedChangeText, deletedText, + trackedChangeLink, } = comment; return buildDiscoveryItem(comment.commentId, handle, { address, @@ -1215,9 +1721,12 @@ function listCommentsHandler(editor: Editor, query?: CommentsListQuery): Comment story, trackedChange, trackedChangeType, + trackedChangeDisplayType, + trackedChangeStory, trackedChangeAnchorKey, trackedChangeText, deletedText, + trackedChangeLink, }); }); 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 11c8a5e1cd..8ee30f44b9 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 @@ -2,14 +2,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Editor } from '../../core/Editor.js'; import { COMMAND_CATALOG, type StoryLocator } from '@superdoc/document-api'; -const mocks = vi.hoisted(() => ({ +const mocks = { checkRevision: vi.fn(), getRevision: vi.fn(() => '0'), executeDomainCommand: vi.fn(), resolveTrackedChangeInStory: vi.fn(), getTrackedChangeIndex: vi.fn(), resolveStoryRuntime: vi.fn(), -})); +}; vi.mock('./revision-tracker.js', () => ({ checkRevision: mocks.checkRevision, @@ -37,6 +37,8 @@ import { trackChangesAcceptAllWrapper, trackChangesAcceptWrapper, trackChangesDecideRangeWrapper, + trackChangesListWrapper, + getCachedProjectedTrackedChangeSnapshot, } from './track-changes-wrappers.js'; const footnoteStory: StoryLocator = { kind: 'story', storyType: 'footnote', noteId: '5' }; @@ -391,3 +393,41 @@ describe('track-changes-wrappers revision guard', () => { expectTrackChangesDecideReceiptCodeDeclared('INVALID_TARGET'); }); }); + +describe('track-changes-wrappers projected id cache', () => { + 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 c412d4d0b2..385ed46fd4 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 @@ -35,7 +35,11 @@ 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'; @@ -56,31 +60,162 @@ 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(); + +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; const changedText = snapshot.excerpt - ? { [snapshot.type === 'delete' ? 'deletedText' : 'insertedText']: snapshot.excerpt } + ? { [type === 'delete' ? 'deletedText' : 'insertedText']: snapshot.excerpt } : {}; return { - address: snapshot.address, - id: snapshot.address.entityId, - type: snapshot.type, - wordRevisionIds: normalizeWordRevisionIds(snapshot.wordRevisionIds), - overlap: snapshot.overlap, - author: snapshot.author, - authorEmail: snapshot.authorEmail, - authorImage: snapshot.authorImage, - date: snapshot.date, - excerpt: snapshot.excerpt, - ...(snapshot.type === 'format' ? {} : changedText), + 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, + ...(type === 'format' ? {} : changedText), + }, + handleKey: `${snapshot.anchorKey}${options.handleSuffix ?? ''}`, + snapshot, }; } -function filterByType( - snapshots: ReadonlyArray, +function isCombinedReplacementSnapshot(snapshot: TrackedChangeSnapshot): boolean { + return snapshot.hasInsert && snapshot.hasDelete && !snapshot.hasFormat; +} + +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 snapshotToInfo(snapshot: TrackedChangeSnapshot): TrackChangeInfo { + return buildProjectedInfo(snapshot, { grouping: 'standalone', pairedWithChangeId: null }).info; +} + +export function projectSnapshots(snapshots: ReadonlyArray): 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 pairedById = new Map(); + for (const group of byPairKey.values()) { + const inserts = group.filter((snapshot) => snapshot.type === 'insert'); + const deletes = group.filter((snapshot) => snapshot.type === 'delete'); + if (inserts.length !== 1 || deletes.length !== 1) continue; + pairedById.set(inserts[0].address.entityId, deletes[0].address.entityId); + pairedById.set(deletes[0].address.entityId, inserts[0].address.entityId); + } + + const projected: ProjectedTrackChange[] = []; + for (const snapshot of snapshots) { + if (isCombinedReplacementSnapshot(snapshot)) { + const insertedId = `${snapshot.address.entityId}#inserted`; + const deletedId = `${snapshot.address.entityId}#deleted`; + projected.push( + buildProjectedInfo(snapshot, { + id: insertedId, + type: 'insert', + grouping: 'replacement-pair', + pairedWithChangeId: deletedId, + handleSuffix: '#inserted', + }), + ); + projected.push( + buildProjectedInfo(snapshot, { + id: deletedId, + type: 'delete', + grouping: 'replacement-pair', + pairedWithChangeId: insertedId, + handleSuffix: '#deleted', + }), + ); + continue; + } + + const pairedWithChangeId = pairedById.get(snapshot.address.entityId) ?? null; + projected.push( + buildProjectedInfo(snapshot, { + grouping: pairedWithChangeId ? 'replacement-pair' : 'standalone', + pairedWithChangeId, + }), + ); + } + + 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 { @@ -139,19 +274,23 @@ export function trackChangesListWrapper(editor: Editor, input?: TrackChangesList rawSnapshots = index.get(scope.story); } - const filtered = filterByType(rawSnapshots, input?.type); + const projected = projectSnapshots(rawSnapshots); + 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); - const items = paged.items.map((snapshot) => { - const info = snapshotToInfo(snapshot); - const handle = buildResolvedHandle(snapshot.anchorKey, 'stable', 'trackedChange'); + 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, @@ -165,6 +304,8 @@ export function trackChangesListWrapper(editor: Editor, input?: TrackChangesList return buildDiscoveryItem(info.id, handle, { address, type, + grouping, + pairedWithChangeId, wordRevisionIds, overlap, author, @@ -202,7 +343,13 @@ 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); + 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); 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 b9099ce24a..5bd2bebb72 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 @@ -285,6 +285,12 @@ class TrackedChangeIndexImpl implements TrackedChangeIndex { 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 0e7cbcf672..fb307f6d8a 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 @@ -42,6 +42,15 @@ export interface TrackedChangeSnapshot { 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/extensions/track-changes/review-model/comment-effects.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/comment-effects.js index 4f7f1ad09d..0d5eb6d178 100644 --- 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 @@ -34,6 +34,7 @@ * @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 */ @@ -96,16 +97,19 @@ export const enumerateCommentAnchors = (doc) => { * @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 }) => { +export const planCommentEffects = ({ doc, removedRanges, resolvedRanges = [] }) => { /** @type {CommentEffectsPlan} */ - const plan = { nodeDeletes: [], entityDeletes: [], entityShrinks: [], diagnostics: [] }; - if (!doc || !removedRanges?.length) return plan; + 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; @@ -114,7 +118,9 @@ export const planCommentEffects = ({ doc, removedRanges }) => { 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 cause = sortedRemoved[0]?.cause ?? 'trackedChange'; + 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); @@ -122,7 +128,7 @@ export const planCommentEffects = ({ doc, removedRanges }) => { const fullyCovered = sortedRemoved.some((r) => r.from <= anchorStart && r.to >= anchorEnd); if (fullyCovered || removesEnd || removesRef) { - plan.entityDeletes.push({ id: anchor.commentId, cause }); + 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) @@ -137,11 +143,19 @@ export const planCommentEffects = ({ doc, removedRanges }) => { if (overlaps) { plan.entityShrinks.push({ id: anchor.commentId, - cause, + 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 }); } } 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 index 6cd64f2836..19d81f0346 100644 --- 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 @@ -63,6 +63,7 @@ import { planCommentEffects } from './comment-effects.js'; * @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. */ @@ -399,6 +400,8 @@ const buildMutationPlan = ({ state, graph, selections, decision, replacements }) 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} */ @@ -432,6 +435,15 @@ const buildMutationPlan = ({ state, graph, selections, decision, replacements }) } } 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({ @@ -493,7 +505,7 @@ const buildMutationPlan = ({ state, graph, selections, decision, replacements }) } } - const commentEffects = planCommentEffects({ doc: state.doc, removedRanges }); + 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 @@ -879,6 +891,7 @@ const buildReceipt = ({ 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 ?? [], }; 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 17743e0ed1..34ec5ee8c5 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 @@ -58,6 +58,7 @@ const dispatchReviewDecision = ({ editor, state, dispatch, decision, target }) = 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 @@ -90,7 +91,7 @@ const dispatchReviewDecision = ({ editor, state, dispatch, decision, target }) = if (!remaining.length) continue; const payload = buildPartialUpdatePayload({ state: nextState, - documentId: editor.options?.documentId, + documentId, originalId: changeId, remaining, }); @@ -99,6 +100,18 @@ const dispatchReviewDecision = ({ editor, state, dispatch, decision, target }) = 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 }; @@ -175,6 +188,34 @@ const buildPartialUpdatePayload = ({ state, documentId, originalId, remaining }) }; }; +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, + }, +}); + +const buildDeletedCommentPayload = ({ commentId, documentId }) => ({ + type: 'deleted', + comment: { + commentId, + documentId, + fileId: documentId ?? null, + }, +}); + export const TrackChanges = Extension.create({ name: 'trackChanges', 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/utils/comment-content.ts b/packages/super-editor/src/editors/v1/utils/comment-content.ts new file mode 100644 index 0000000000..73ed8b71e3 --- /dev/null +++ b/packages/super-editor/src/editors/v1/utils/comment-content.ts @@ -0,0 +1,35 @@ +/** + * 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. + */ +export function stripHtmlToText(value: string): string { + return value + .replace(/<[^>]+>/g, ' ') + .replace(/ /gi, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +export function buildCommentJsonFromText(text: string): unknown[] { + const normalized = stripHtmlToText(text); + + return [ + { + type: 'paragraph', + content: [ + { + type: 'run', + content: [ + { + type: 'text', + text: normalized, + }, + ], + }, + ], + }, + ]; +} diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index 77f3cb526d..67e6ef93fe 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -864,6 +864,10 @@ const REPLAY_MUTABLE_COMMENT_FIELDS = new Set([ 'trackedChangeType', 'trackedChangeText', 'trackedChangeDisplayType', + 'trackedChangeStory', + 'trackedChangeStoryKind', + 'trackedChangeStoryLabel', + 'trackedChangeAnchorKey', 'deletedText', 'resolvedTime', 'resolvedById', diff --git a/packages/superdoc/src/stores/comments-store.js b/packages/superdoc/src/stores/comments-store.js index 29e600b968..8b46743a7f 100644 --- a/packages/superdoc/src/stores/comments-store.js +++ b/packages/superdoc/src/stores/comments-store.js @@ -782,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. } }; diff --git a/packages/superdoc/src/stores/comments-store.test.js b/packages/superdoc/src/stores/comments-store.test.js index 6f7fa9a538..de7bc80cee 100644 --- a/packages/superdoc/src/stores/comments-store.test.js +++ b/packages/superdoc/src/stores/comments-store.test.js @@ -682,7 +682,7 @@ 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: { id: 'reviewer-id', email: 'reviewer@example.com', name: 'Reviewer' }, @@ -726,25 +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({ - id: 'reviewer-id', - 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' }, @@ -789,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 = { @@ -824,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 () => { From 3032d56ea4ab8b54138b18cdba214fbb65b91d2e Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 22 May 2026 22:48:23 -0700 Subject: [PATCH 16/25] chore: ci fixes --- .../document-api/available-operations.mdx | 3 +- .../reference/_generated-manifest.json | 4 +- .../reference/capabilities/get.mdx | 46 +++++++++++++++++++ .../document-api/reference/format/index.mdx | 1 + apps/docs/document-api/reference/index.mdx | 3 +- .../src/contract/operation-definitions.ts | 17 +++++++ .../src/contract/operation-registry.ts | 3 +- packages/document-api/src/contract/schemas.ts | 28 +++++++++++ packages/document-api/src/invoke/invoke.ts | 1 + .../plan-engine/comments-wrappers.ts | 10 ++-- 10 files changed, 106 insertions(+), 10 deletions(-) diff --git a/apps/docs/document-api/available-operations.mdx b/apps/docs/document-api/available-operations.mdx index 61e9ce9491..fc2370f907 100644 --- a/apps/docs/document-api/available-operations.mdx +++ b/apps/docs/document-api/available-operations.mdx @@ -28,7 +28,7 @@ Use the tables below to see what operations are available and where each one is | Diff | 3 | 0 | 3 | [Reference](/document-api/reference/diff/index) | | Fields | 5 | 0 | 5 | [Reference](/document-api/reference/fields/index) | | Footnotes | 6 | 0 | 6 | [Reference](/document-api/reference/footnotes/index) | -| Format | 44 | 1 | 45 | [Reference](/document-api/reference/format/index) | +| Format | 45 | 1 | 46 | [Reference](/document-api/reference/format/index) | | Headers & Footers | 9 | 0 | 9 | [Reference](/document-api/reference/header-footers/index) | | History | 3 | 0 | 3 | [Reference](/document-api/reference/history/index) | | Hyperlinks | 6 | 0 | 6 | [Reference](/document-api/reference/hyperlinks/index) | @@ -192,6 +192,7 @@ Use the tables below to see what operations are available and where each one is | editor.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/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 76fd594f32..ec0b18a6bd 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": "b2a628b73f6cb983a78e8068179f0c18cc99024112b143c1a832b95b4c2ad914" + "sourceHash": "cecf48b9ef043255c73faeed6b35f236bca0cd969d19779a21952f9c15037705" } 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/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 7cbeee530f..f3a0afcbee 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. | diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index 37cec1a83b..ce232460cb 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -954,6 +954,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', diff --git a/packages/document-api/src/contract/operation-registry.ts b/packages/document-api/src/contract/operation-registry.ts index 64b10bda58..0f78543249 100644 --- a/packages/document-api/src/contract/operation-registry.ts +++ b/packages/document-api/src/contract/operation-registry.ts @@ -42,7 +42,7 @@ 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 { @@ -572,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 }; diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index bb16029e7b..25b78fbc2f 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -3272,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( 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/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 d96ec2f329..08539ad15d 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 @@ -146,12 +146,10 @@ 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; @@ -1096,7 +1094,7 @@ function addCommentHandler( } throw error; } - if (!resolvedTarget.ok) { + if (resolvedTarget.ok === false) { return { success: false, failure: resolvedTarget.failure }; } @@ -1289,7 +1287,7 @@ function moveCommentHandler(editor: Editor, input: MoveCommentInput, options?: R const moveComment = requireEditorCommand(editor.commands?.moveComment, 'comments.patch (moveComment)'); const resolvedTarget = resolveCommentTarget(editor, input.target); - if (!resolvedTarget.ok) { + if (resolvedTarget.ok === false) { return { success: false, failure: resolvedTarget.failure }; } From 4aee2c84df3ddd246e930b6cef4ae22d0f7bf4fb Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Sat, 23 May 2026 08:51:40 -0700 Subject: [PATCH 17/25] chore: more fixes --- apps/cli/src/cli/operation-hints.ts | 5 ++ apps/docs/document-engine/sdks.mdx | 2 + .../w/p/helpers/translate-paragraph-node.js | 55 +++++++++++++++++-- .../helpers/translate-paragraph-node.test.js | 45 ++++++++++++++- 4 files changed, 99 insertions(+), 8 deletions(-) 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/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index f94c069e0e..1d2513fd33 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. | @@ -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. | 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')); + }); }); }); From e2aa548636ff52419885434eb5e63e8b430e8589 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Sat, 23 May 2026 10:25:59 -0700 Subject: [PATCH 18/25] chore: more fixes --- .../src/__tests__/conformance/scenarios.ts | 37 ++++++- .../src/__tests__/lib/error-mapping.test.ts | 33 ++++++ apps/cli/src/lib/error-mapping.ts | 44 +++++++- apps/cli/src/lib/generic-dispatch.ts | 1 + apps/cli/src/lib/mutation-orchestrator.ts | 11 +- apps/cli/src/lib/operation-executor.ts | 1 + apps/cli/src/lib/read-orchestrator.ts | 9 +- .../track-changes-wrappers.test.ts | 103 +++++++++++++++++- .../plan-engine/track-changes-wrappers.ts | 31 ++---- 9 files changed, 227 insertions(+), 43 deletions(-) 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/error-mapping.test.ts b/apps/cli/src/__tests__/lib/error-mapping.test.ts index 52f5384b25..88b3303fd9 100644 --- a/apps/cli/src/__tests__/lib/error-mapping.test.ts +++ b/apps/cli/src/__tests__/lib/error-mapping.test.ts @@ -25,6 +25,21 @@ describe('mapInvokeError', () => { 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'); + }); }); // --------------------------------------------------------------------------- @@ -216,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/lib/error-mapping.ts b/apps/cli/src/lib/error-mapping.ts index d45a9fe287..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,11 +47,19 @@ 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 }); } @@ -358,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, @@ -389,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); } // --------------------------------------------------------------------------- @@ -422,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; @@ -444,6 +475,9 @@ 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') { 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/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 8ee30f44b9..a57e7aff90 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 @@ -2,14 +2,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Editor } from '../../core/Editor.js'; import { COMMAND_CATALOG, type StoryLocator } from '@superdoc/document-api'; -const mocks = { +const mocks = vi.hoisted(() => ({ checkRevision: vi.fn(), getRevision: vi.fn(() => '0'), executeDomainCommand: vi.fn(), resolveTrackedChangeInStory: vi.fn(), getTrackedChangeIndex: vi.fn(), resolveStoryRuntime: vi.fn(), -}; +})); vi.mock('./revision-tracker.js', () => ({ checkRevision: mocks.checkRevision, @@ -395,6 +395,105 @@ describe('track-changes-wrappers revision guard', () => { }); describe('track-changes-wrappers projected id cache', () => { + it('keeps default paired replacements as one aggregate 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: 'insert', + 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: 'insert', + grouping: 'aggregate', + wordRevisionIds: { insert: '11', delete: '10' }, + }); + expect(result.items[0]?.id).not.toContain('#'); + + const deleteFiltered = trackChangesListWrapper(editor, { type: 'delete' }); + expect(deleteFiltered.total).toBe(0); + expect(deleteFiltered.items).toEqual([]); + }); + + it('keeps independent replacement sides paired without aggregating them', () => { + 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(2); + expect(result.items.map((item) => [item.id, item.grouping, item.pairedWithChangeId])).toEqual([ + ['tc-insert', 'replacement-pair', 'tc-delete'], + ['tc-delete', 'replacement-pair', 'tc-insert'], + ]); + }); + it('caches list ids for reuse on the same editor revision', () => { const editor = makeEditor(); const snapshot = { 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 385ed46fd4..d3d22cbd4b 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 @@ -127,8 +127,16 @@ function replacementPairKey(snapshot: TrackedChangeSnapshot): string | null { return null; } +function snapshotGrouping(snapshot: TrackedChangeSnapshot): TrackChangeInfo['grouping'] { + return isCombinedReplacementSnapshot(snapshot) ? 'aggregate' : 'standalone'; +} + +function snapshotToProjected(snapshot: TrackedChangeSnapshot): ProjectedTrackChange { + return buildProjectedInfo(snapshot, { grouping: snapshotGrouping(snapshot), pairedWithChangeId: null }); +} + function snapshotToInfo(snapshot: TrackedChangeSnapshot): TrackChangeInfo { - return buildProjectedInfo(snapshot, { grouping: 'standalone', pairedWithChangeId: null }).info; + return snapshotToProjected(snapshot).info; } export function projectSnapshots(snapshots: ReadonlyArray): ProjectedTrackChange[] { @@ -154,26 +162,7 @@ export function projectSnapshots(snapshots: ReadonlyArray const projected: ProjectedTrackChange[] = []; for (const snapshot of snapshots) { if (isCombinedReplacementSnapshot(snapshot)) { - const insertedId = `${snapshot.address.entityId}#inserted`; - const deletedId = `${snapshot.address.entityId}#deleted`; - projected.push( - buildProjectedInfo(snapshot, { - id: insertedId, - type: 'insert', - grouping: 'replacement-pair', - pairedWithChangeId: deletedId, - handleSuffix: '#inserted', - }), - ); - projected.push( - buildProjectedInfo(snapshot, { - id: deletedId, - type: 'delete', - grouping: 'replacement-pair', - pairedWithChangeId: insertedId, - handleSuffix: '#deleted', - }), - ); + projected.push(snapshotToProjected(snapshot)); continue; } From bc5c83a57faab6e291268aa9038d4e7be3f58cce Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Sat, 23 May 2026 10:37:24 -0700 Subject: [PATCH 19/25] chore: type fixes --- shared/common/identity.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/shared/common/identity.ts b/shared/common/identity.ts index acd0b29f25..d5c171c243 100644 --- a/shared/common/identity.ts +++ b/shared/common/identity.ts @@ -1,4 +1,10 @@ -type IdentityLike = Readonly> | null | undefined; +type IdentityFields = Readonly<{ + id?: unknown; + email?: unknown; + name?: unknown; +}>; + +type IdentityLike = IdentityFields | Readonly> | null | undefined; export interface NormalizedActorIdentity { readonly id: string; @@ -37,7 +43,7 @@ export const normalizeActorName = (value: unknown): string => { }; export const getActorIdentity = (value: IdentityLike): NormalizedActorIdentity => { - const record = (value ?? {}) as Record; + const record = value ?? {}; const id = normalizeActorId(record.id); const email = normalizeActorEmail(record.email); const name = typeof record.name === 'string' ? record.name : ''; From 7c5c6e90b134db7415abcbffdbf4e5927c385f47 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Sat, 23 May 2026 14:41:51 -0700 Subject: [PATCH 20/25] fix: replacement pair --- .../track-changes-wrappers.test.ts | 29 +++++++++++------ .../plan-engine/track-changes-wrappers.ts | 31 +++++++++++++------ 2 files changed, 41 insertions(+), 19 deletions(-) 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 a57e7aff90..36db781c64 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 @@ -395,7 +395,7 @@ describe('track-changes-wrappers revision guard', () => { }); describe('track-changes-wrappers projected id cache', () => { - it('keeps default paired replacements as one aggregate public list item', () => { + it('projects combined replacement snapshots as public paired insert/delete rows', () => { const editor = makeEditor(); const replacementSnapshot = { address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-replacement-1' }, @@ -424,19 +424,30 @@ describe('track-changes-wrappers projected id cache', () => { const result = trackChangesListWrapper(editor); - expect(result.total).toBe(1); - expect(result.items).toHaveLength(1); + expect(result.total).toBe(2); + expect(result.items).toHaveLength(2); + expect(result.items.map((item) => [item.id, item.type, item.grouping, item.pairedWithChangeId])).toEqual([ + ['tc-replacement-1#inserted', 'insert', 'replacement-pair', 'tc-replacement-1#deleted'], + ['tc-replacement-1#deleted', 'delete', 'replacement-pair', 'tc-replacement-1#inserted'], + ]); + expect(result.items.map((item) => item.handle.ref)).toEqual([ + 'tc::body::tc-replacement-1#inserted', + 'tc::body::tc-replacement-1#deleted', + ]); expect(result.items[0]).toMatchObject({ - id: 'tc-replacement-1', - type: 'insert', - grouping: 'aggregate', + address: { entityId: 'tc-replacement-1#inserted' }, + wordRevisionIds: { insert: '11', delete: '10' }, + }); + expect(result.items[1]).toMatchObject({ + address: { entityId: 'tc-replacement-1#deleted' }, wordRevisionIds: { insert: '11', delete: '10' }, }); - expect(result.items[0]?.id).not.toContain('#'); const deleteFiltered = trackChangesListWrapper(editor, { type: 'delete' }); - expect(deleteFiltered.total).toBe(0); - expect(deleteFiltered.items).toEqual([]); + expect(deleteFiltered.total).toBe(1); + expect(deleteFiltered.items.map((item) => [item.id, item.type, item.grouping, item.pairedWithChangeId])).toEqual([ + ['tc-replacement-1#deleted', 'delete', 'replacement-pair', 'tc-replacement-1#inserted'], + ]); }); it('keeps independent replacement sides paired without aggregating them', () => { 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 d3d22cbd4b..385ed46fd4 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 @@ -127,16 +127,8 @@ function replacementPairKey(snapshot: TrackedChangeSnapshot): string | null { return null; } -function snapshotGrouping(snapshot: TrackedChangeSnapshot): TrackChangeInfo['grouping'] { - return isCombinedReplacementSnapshot(snapshot) ? 'aggregate' : 'standalone'; -} - -function snapshotToProjected(snapshot: TrackedChangeSnapshot): ProjectedTrackChange { - return buildProjectedInfo(snapshot, { grouping: snapshotGrouping(snapshot), pairedWithChangeId: null }); -} - function snapshotToInfo(snapshot: TrackedChangeSnapshot): TrackChangeInfo { - return snapshotToProjected(snapshot).info; + return buildProjectedInfo(snapshot, { grouping: 'standalone', pairedWithChangeId: null }).info; } export function projectSnapshots(snapshots: ReadonlyArray): ProjectedTrackChange[] { @@ -162,7 +154,26 @@ export function projectSnapshots(snapshots: ReadonlyArray const projected: ProjectedTrackChange[] = []; for (const snapshot of snapshots) { if (isCombinedReplacementSnapshot(snapshot)) { - projected.push(snapshotToProjected(snapshot)); + const insertedId = `${snapshot.address.entityId}#inserted`; + const deletedId = `${snapshot.address.entityId}#deleted`; + projected.push( + buildProjectedInfo(snapshot, { + id: insertedId, + type: 'insert', + grouping: 'replacement-pair', + pairedWithChangeId: deletedId, + handleSuffix: '#inserted', + }), + ); + projected.push( + buildProjectedInfo(snapshot, { + id: deletedId, + type: 'delete', + grouping: 'replacement-pair', + pairedWithChangeId: insertedId, + handleSuffix: '#deleted', + }), + ); continue; } From aa9ce97afea2b01417be7f5fe55b40bd057699c8 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Sat, 23 May 2026 15:22:03 -0700 Subject: [PATCH 21/25] chore: fix regression --- .../src/track-changes/track-changes.ts | 8 ++--- .../track-changes-wrappers.test.ts | 29 ++++++----------- .../plan-engine/track-changes-wrappers.ts | 31 ++++++------------- 3 files changed, 23 insertions(+), 45 deletions(-) diff --git a/packages/document-api/src/track-changes/track-changes.ts b/packages/document-api/src/track-changes/track-changes.ts index facf82203d..fd5f303b96 100644 --- a/packages/document-api/src/track-changes/track-changes.ts +++ b/packages/document-api/src/track-changes/track-changes.ts @@ -48,10 +48,10 @@ export interface TrackChangesRangeInput { /** * Canonical decide input shape per - * `plans/tracked-changes-comments/tracked-changes-spec.md` § 9. The legacy - * `{ id }` and `{ scope: 'all' }` aliases are preserved during the migration - * window so existing headless callers keep working; the executor normalizes - * them into the canonical `{ kind: ... }` form before dispatch. + * `../labs/tests/requirements/specs/tracked-changes-comments/v3/tracked-changes-spec.md` + * § 9. The legacy `{ id }` and `{ scope: 'all' }` aliases are preserved during + * the migration window so existing headless callers keep working; the executor + * normalizes them into the canonical `{ kind: ... }` form before dispatch. */ export type ReviewDecisionTarget = | { kind: 'id'; id: string; story?: StoryLocator } 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 36db781c64..a57e7aff90 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 @@ -395,7 +395,7 @@ describe('track-changes-wrappers revision guard', () => { }); describe('track-changes-wrappers projected id cache', () => { - it('projects combined replacement snapshots as public paired insert/delete rows', () => { + it('keeps default paired replacements as one aggregate public list item', () => { const editor = makeEditor(); const replacementSnapshot = { address: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc-replacement-1' }, @@ -424,30 +424,19 @@ describe('track-changes-wrappers projected id cache', () => { const result = trackChangesListWrapper(editor); - expect(result.total).toBe(2); - expect(result.items).toHaveLength(2); - expect(result.items.map((item) => [item.id, item.type, item.grouping, item.pairedWithChangeId])).toEqual([ - ['tc-replacement-1#inserted', 'insert', 'replacement-pair', 'tc-replacement-1#deleted'], - ['tc-replacement-1#deleted', 'delete', 'replacement-pair', 'tc-replacement-1#inserted'], - ]); - expect(result.items.map((item) => item.handle.ref)).toEqual([ - 'tc::body::tc-replacement-1#inserted', - 'tc::body::tc-replacement-1#deleted', - ]); + expect(result.total).toBe(1); + expect(result.items).toHaveLength(1); expect(result.items[0]).toMatchObject({ - address: { entityId: 'tc-replacement-1#inserted' }, - wordRevisionIds: { insert: '11', delete: '10' }, - }); - expect(result.items[1]).toMatchObject({ - address: { entityId: 'tc-replacement-1#deleted' }, + id: 'tc-replacement-1', + type: 'insert', + grouping: 'aggregate', wordRevisionIds: { insert: '11', delete: '10' }, }); + expect(result.items[0]?.id).not.toContain('#'); const deleteFiltered = trackChangesListWrapper(editor, { type: 'delete' }); - expect(deleteFiltered.total).toBe(1); - expect(deleteFiltered.items.map((item) => [item.id, item.type, item.grouping, item.pairedWithChangeId])).toEqual([ - ['tc-replacement-1#deleted', 'delete', 'replacement-pair', 'tc-replacement-1#inserted'], - ]); + expect(deleteFiltered.total).toBe(0); + expect(deleteFiltered.items).toEqual([]); }); it('keeps independent replacement sides paired without aggregating them', () => { 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 385ed46fd4..d3d22cbd4b 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 @@ -127,8 +127,16 @@ function replacementPairKey(snapshot: TrackedChangeSnapshot): string | null { return null; } +function snapshotGrouping(snapshot: TrackedChangeSnapshot): TrackChangeInfo['grouping'] { + return isCombinedReplacementSnapshot(snapshot) ? 'aggregate' : 'standalone'; +} + +function snapshotToProjected(snapshot: TrackedChangeSnapshot): ProjectedTrackChange { + return buildProjectedInfo(snapshot, { grouping: snapshotGrouping(snapshot), pairedWithChangeId: null }); +} + function snapshotToInfo(snapshot: TrackedChangeSnapshot): TrackChangeInfo { - return buildProjectedInfo(snapshot, { grouping: 'standalone', pairedWithChangeId: null }).info; + return snapshotToProjected(snapshot).info; } export function projectSnapshots(snapshots: ReadonlyArray): ProjectedTrackChange[] { @@ -154,26 +162,7 @@ export function projectSnapshots(snapshots: ReadonlyArray const projected: ProjectedTrackChange[] = []; for (const snapshot of snapshots) { if (isCombinedReplacementSnapshot(snapshot)) { - const insertedId = `${snapshot.address.entityId}#inserted`; - const deletedId = `${snapshot.address.entityId}#deleted`; - projected.push( - buildProjectedInfo(snapshot, { - id: insertedId, - type: 'insert', - grouping: 'replacement-pair', - pairedWithChangeId: deletedId, - handleSuffix: '#inserted', - }), - ); - projected.push( - buildProjectedInfo(snapshot, { - id: deletedId, - type: 'delete', - grouping: 'replacement-pair', - pairedWithChangeId: insertedId, - handleSuffix: '#deleted', - }), - ); + projected.push(snapshotToProjected(snapshot)); continue; } From f18f9545e8ce3dfe8185c9772e4cdd8f753c3487 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Sun, 24 May 2026 00:32:07 -0700 Subject: [PATCH 22/25] chore: ui and more --- .../_open-document-track-changes-worker.ts | 81 ++++++ ...-document-track-changes-forwarding.test.ts | 32 +++ .../lib/operation-runtime-metadata.test.ts | 28 ++ apps/cli/src/cli/operation-params.ts | 19 +- apps/cli/src/cli/types.ts | 2 + apps/cli/src/commands/open.ts | 12 +- apps/cli/src/lib/document.ts | 7 + .../reference/_generated-manifest.json | 2 +- apps/docs/document-api/reference/extract.mdx | 3 +- .../reference/track-changes/get.mdx | 8 +- .../reference/track-changes/list.mdx | 9 +- apps/mcp/src/generated/catalog.ts | 6 +- .../src/contract/contract.test.ts | 48 ++++ .../src/contract/operation-definitions.ts | 10 +- packages/document-api/src/contract/schemas.ts | 16 +- .../src/track-changes/track-changes.ts | 2 +- .../document-api/src/types/extract.types.ts | 12 +- .../document-api/src/types/sd-sections.ts | 2 +- .../src/types/track-changes.types.ts | 4 +- .../painters/dom/src/index.test.ts | 123 +++++++++ .../painters/dom/src/renderer.ts | 26 +- .../layout-engine/painters/dom/src/styles.ts | 26 ++ .../pm-adapter/src/marks/application.test.ts | 76 ++++++ .../pm-adapter/src/marks/application.ts | 117 ++++++-- .../__tests__/transport-common.test.ts | 22 ++ .../Editor.track-changes-dispatch.test.js | 54 ++++ .../src/editors/v1/core/Editor.ts | 21 +- .../src/editors/v1/core/types/EditorConfig.ts | 9 + ...xtract-adapter.consumer-simulation.test.ts | 28 +- .../extract-adapter.test.ts | 52 +++- .../helpers/tracked-change-resolver.test.ts | 73 ++++- .../helpers/tracked-change-resolver.ts | 70 ++++- .../track-changes-wrappers.test.ts | 258 +++++++++++++++++- .../plan-engine/track-changes-wrappers.ts | 116 ++++++-- .../super-editor/src/ui/entity-at.test.ts | 55 ++++ packages/super-editor/src/ui/entity-at.ts | 30 +- .../behavior/helpers/story-tracked-changes.ts | 38 ++- .../programmatic-tracked-change.spec.ts | 6 +- ...ment-update-preserves-deleted-text.spec.ts | 2 +- .../tracked-change-replacement-bubble.spec.ts | 12 +- ...ed-change-sidebar-targeted-refresh.spec.ts | 2 +- 41 files changed, 1376 insertions(+), 143 deletions(-) create mode 100644 apps/cli/src/__tests__/lib/_open-document-track-changes-worker.ts create mode 100644 apps/cli/src/__tests__/lib/open-document-track-changes-forwarding.test.ts 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/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/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/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index ec0b18a6bd..6ad45e09d8 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -1079,5 +1079,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "cecf48b9ef043255c73faeed6b35f236bca0cd969d19779a21952f9c15037705" + "sourceHash": "1a411ac1bb94bc869de87173008a88a06a95f73dbf3bbc0906ec420af73ee88d" } 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/track-changes/get.mdx b/apps/docs/document-api/reference/track-changes/get.mdx index 2b63b81477..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 @@ -56,11 +56,11 @@ Returns a TrackChangeInfo object with the change type, author, date, affected co | `date` | string | no | | | `deletedText` | string | no | | | `excerpt` | string | no | | -| `grouping` | enum | no | `"standalone"`, `"replacement-pair"`, `"aggregate"`, `"unknown"` | +| `grouping` | enum | no | `"standalone"`, `"replacement-pair"`, `"unknown"` | | `id` | string | yes | | | `insertedText` | string | no | | | `pairedWithChangeId` | any | no | | -| `type` | enum | yes | `"insert"`, `"delete"`, `"format"` | +| `type` | enum | yes | `"insert"`, `"delete"`, `"replacement"`, `"format"` | | `wordRevisionIds` | object | no | | | `wordRevisionIds.delete` | string | no | | | `wordRevisionIds.format` | string | no | | @@ -146,7 +146,6 @@ Returns a TrackChangeInfo object with the change type, author, date, affected co "enum": [ "standalone", "replacement-pair", - "aggregate", "unknown" ] }, @@ -166,6 +165,7 @@ Returns a TrackChangeInfo object with the change type, author, date, affected co "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 51216270d0..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 @@ -123,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" ] } @@ -173,7 +174,6 @@ Returns a TrackChangesListResult with tracked change entries, total count, and r "enum": [ "standalone", "replacement-pair", - "aggregate", "unknown" ] }, @@ -196,6 +196,7 @@ Returns a TrackChangesListResult with tracked change entries, total count, and r "enum": [ "insert", "delete", + "replacement", "format" ] }, diff --git a/apps/mcp/src/generated/catalog.ts b/apps/mcp/src/generated/catalog.ts index e95c818b37..26ec5cffd8 100644 --- a/apps/mcp/src/generated/catalog.ts +++ b/apps/mcp/src/generated/catalog.ts @@ -2423,7 +2423,7 @@ export const MCP_TOOL_CATALOG = { { 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"}}', + '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"}. 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":{"scope":"all"}}', inputSchema: { type: 'object', properties: { @@ -2442,9 +2442,9 @@ export const MCP_TOOL_CATALOG = { "Number of tracked changes to skip for pagination. Only for action 'list'. Omit for other actions.", }, type: { - enum: ['insert', 'delete', 'format'], + enum: ['insert', 'delete', 'replacement', 'format'], description: - "Filter by change type: 'insert', 'delete', or 'format'. Only for action 'list'. Omit for other actions.", + "Filter by change type: 'insert', 'delete', 'replacement', or 'format'. Only for action 'list'. Omit for other actions.", }, force: { type: 'boolean', diff --git a/packages/document-api/src/contract/contract.test.ts b/packages/document-api/src/contract/contract.test.ts index a061509e3b..c6ce294626 100644 --- a/packages/document-api/src/contract/contract.test.ts +++ b/packages/document-api/src/contract/contract.test.ts @@ -331,6 +331,54 @@ describe('document-api contract catalog', () => { ); }); + 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 ce232460cb..c2bf48f17a 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -416,14 +416,14 @@ 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"}. ' + '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: { scope: 'all' } }, ], @@ -2473,7 +2473,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', @@ -2488,7 +2488,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', diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index 25b78fbc2f..9f902ca0dc 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -1557,8 +1557,8 @@ const trackChangeInfoSchema = objectSchema( { address: trackedChangeAddressSchema, id: { type: 'string' }, - type: { enum: ['insert', 'delete', 'format'] }, - grouping: { enum: ['standalone', 'replacement-pair', 'aggregate', 'unknown'] }, + type: { enum: ['insert', 'delete', 'replacement', 'format'] }, + grouping: { enum: ['standalone', 'replacement-pair', 'unknown'] }, pairedWithChangeId: { type: ['string', 'null'] }, wordRevisionIds: trackChangeWordRevisionIdsSchema, author: { type: 'string' }, @@ -1575,8 +1575,8 @@ const trackChangeInfoSchema = objectSchema( const trackChangeDomainItemSchema = discoveryItemSchema( { address: trackedChangeAddressSchema, - type: { enum: ['insert', 'delete', 'format'] }, - grouping: { enum: ['standalone', 'replacement-pair', 'aggregate', 'unknown'] }, + type: { enum: ['insert', 'delete', 'replacement', 'format'] }, + grouping: { enum: ['standalone', 'replacement-pair', 'unknown'] }, pairedWithChangeId: { type: ['string', 'null'] }, wordRevisionIds: trackChangeWordRevisionIdsSchema, author: { type: 'string' }, @@ -3165,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', @@ -5025,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' }], diff --git a/packages/document-api/src/track-changes/track-changes.ts b/packages/document-api/src/track-changes/track-changes.ts index fd5f303b96..e7b74b47fa 100644 --- a/packages/document-api/src/track-changes/track-changes.ts +++ b/packages/document-api/src/track-changes/track-changes.ts @@ -48,7 +48,7 @@ export interface TrackChangesRangeInput { /** * Canonical decide input shape per - * `../labs/tests/requirements/specs/tracked-changes-comments/v3/tracked-changes-spec.md` + * `../labs/tests/requirements/specs/tracked-changes-comments/tracked-changes-spec.md` * § 9. The legacy `{ id }` and `{ scope: 'all' }` aliases are preserved during * the migration window so existing headless callers keep working; the executor * normalizes them into the canonical `{ kind: ... }` form before dispatch. 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/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 87d9b3a685..9b054cef8c 100644 --- a/packages/document-api/src/types/track-changes.types.ts +++ b/packages/document-api/src/types/track-changes.types.ts @@ -2,9 +2,9 @@ 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' | 'aggregate' | 'unknown'; +export type TrackChangeGrouping = 'standalone' | 'replacement-pair' | 'unknown'; export interface TrackChangeOverlapLayer { id: string; 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 bf4d0ed883..1610775655 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -996,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> = { @@ -1024,6 +1025,11 @@ 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; @@ -1031,6 +1037,19 @@ const getTrackedChangeLayers = (run: TextRun): TrackedChangeMeta[] => { 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. @@ -7078,7 +7097,8 @@ export class DomPainter { if (layers.length === 0) { return; } - const meta = textRun.trackedChange ?? layers[0]; + const overlap = resolveInsertDeleteOverlap(layers); + const meta = overlap?.parentInsert ?? textRun.trackedChange ?? layers[0]; layers.forEach((layer) => { const baseClass = TRACK_CHANGE_BASE_CLASS[layer.kind]; @@ -7092,6 +7112,10 @@ export class DomPainter { } }); + 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(','); 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 58f50df191..d1ea4c9c31 100644 --- a/packages/layout-engine/pm-adapter/src/marks/application.ts +++ b/packages/layout-engine/pm-adapter/src/marks/application.ts @@ -508,11 +508,85 @@ export const selectTrackedChangeMeta = ( 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 run.trackedChanges; + return normalizeTrackedChangeLayerList(run.trackedChanges); } - return run.trackedChange ? [run.trackedChange] : []; + return run.trackedChange ? normalizeTrackedChangeLayerList([run.trackedChange]) : []; }; const appendTrackedChangeLayer = (run: TextRun, meta: TrackedChangeMeta): void => { @@ -521,13 +595,15 @@ const appendTrackedChangeLayer = (run: TextRun, meta: TrackedChangeMeta): void = if (!layers.some((layer) => trackedChangeLayerKey(layer) === key)) { layers.push(meta); } - run.trackedChanges = layers; - run.trackedChange = selectTrackedChangeMeta(run.trackedChange, 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 @@ -539,26 +615,10 @@ export const trackedChangesCompatible = (a: TextRun, b: TextRun): boolean => { 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); + return Boolean(bMeta && trackedChangeLayerIdentity(aMeta) === trackedChangeLayerIdentity(bMeta)); }); }; -/** - * Collects and prioritizes tracked change metadata from an array of ProseMirror marks. - * When multiple tracked change marks are present, returns the highest-priority one. - * - * @param marks - Array of ProseMirror marks to process - * @returns The highest-priority 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); -}; - export const collectTrackedChangesFromMarks = (marks?: PMMark[], storyKey?: string): TrackedChangeMeta[] => { if (!marks || !marks.length) return []; const seen = new Set(); @@ -571,7 +631,18 @@ export const collectTrackedChangesFromMarks = (marks?: PMMark[], storyKey?: stri seen.add(key); trackedChanges.push(meta); }); - return trackedChanges; + return normalizeTrackedChangeLayerList(trackedChanges); +}; + +/** + * Collects and prioritizes tracked change metadata from an array of ProseMirror marks. + * When multiple tracked change marks are present, returns the first normalized layer. + * + * @param marks - Array of ProseMirror marks to process + * @returns The primary TrackedChangeMeta, or undefined if none found + */ +export const collectTrackedChangeFromMarks = (marks?: PMMark[], storyKey?: string): TrackedChangeMeta | undefined => { + return collectTrackedChangesFromMarks(marks, storyKey)[0]; }; /** 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/core/Editor.track-changes-dispatch.test.js b/packages/super-editor/src/editors/v1/core/Editor.track-changes-dispatch.test.js index 8aceeb7d04..3789c316c0 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,5 +1,9 @@ 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 { TrackDeleteMarkName, TrackInsertMarkName } from '@extensions/track-changes/constants.js'; import { TrackChangesBasePluginKey } from '@extensions/track-changes/plugins/trackChangesBasePlugin.js'; @@ -10,6 +14,11 @@ 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 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; @@ -250,6 +259,51 @@ describe('Editor dispatch tracked-change meta', () => { ); }); + 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('protects another user tracked insertion from direct delete while local track mode is off', () => { ({ editor } = initTestEditor({ mode: 'text', diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index 09d2ed487a..004635a047 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -99,6 +99,25 @@ 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>; @@ -630,7 +649,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); 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 9e039bf953..4487329bfd 100644 --- a/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts +++ b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts @@ -379,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/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 c2f980ff2e..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,14 +147,14 @@ describe('extract-adapter consumer simulation (SD-2766)', () => { expect(rendered).toContain('Here is a MS Word'); expect(rendered).toContain('sentence'); - // Word-authored replacement halves remain separate source-wrapper entities, - // while each span still resolves to the correct public tracked-change entry. + // 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).not.toBe(delEntity); + expect(insEntity).toBe(delEntity); expect(entitiesById.get(delEntity)?.wordRevisionIds?.delete).toBeTruthy(); - expect(entitiesById.get(insEntity)?.wordRevisionIds?.insert).toBeTruthy(); + expect(entitiesById.get(delEntity)?.wordRevisionIds?.insert).toBeTruthy(); }); it('attaches every tracked change to the blocks it lives in via blockIds', async () => { @@ -210,10 +209,9 @@ 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(); @@ -258,13 +256,11 @@ describe('extract-adapter consumer simulation (SD-2766)', () => { expect(insertEntry).toBeDefined(); const [deleteEntityId, deleteSegments] = deleteEntry!; const [insertEntityId, insertSegments] = insertEntry!; - expect(deleteEntityId).not.toBe(insertEntityId); + expect(deleteEntityId).toBe(insertEntityId); const deleteEntity = result.trackedChanges.find((tc) => tc.entityId === deleteEntityId)!; - const insertEntity = result.trackedChanges.find((tc) => tc.entityId === insertEntityId)!; expect(deleteEntity.wordRevisionIds?.delete).toBeTruthy(); - expect(insertEntity.wordRevisionIds?.insert).toBeTruthy(); - expect(deleteEntity.blockIds).toEqual(insertEntity.blockIds); + expect(deleteEntity.wordRevisionIds?.insert).toBeTruthy(); expect(deleteSegments.filter((s) => s.type === 'delete').map((s) => s.text)).toEqual(['basic ']); expect( 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/helpers/tracked-change-resolver.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.test.ts index a17873b2df..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'); }); }); @@ -209,6 +210,51 @@ describe('groupTrackedChanges', () => { 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([ @@ -308,11 +354,30 @@ describe('buildTrackedChangeCanonicalIdMap', () => { 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')).toBeUndefined(); + expect(map.get('tc-1')).toBe(deleteChange!.id); }); it('returns empty map when no tracked changes exist', () => { 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 e163db05ae..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 @@ -119,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, @@ -169,11 +175,55 @@ function getTrackedChangeAliasCandidates(change: GroupedTrackedChange): string[] 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'], @@ -362,21 +412,25 @@ export function resolveTrackedChange(editor: Editor, id: string): GroupedTracked export function toCanonicalTrackedChangeId(editor: Editor, rawId: string): string | null { const { baseId, side } = splitProjectedTrackedChangeId(rawId); - const grouped = groupTrackedChanges(editor); - const canonical = - grouped.find((item) => item.rawId === baseId || item.commandRawId === baseId || item.id === baseId)?.id ?? null; + 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); - map.set(`${change.id}#inserted`, change.id); - map.set(`${change.id}#deleted`, 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; } 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 a57e7aff90..b28622ffbe 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 @@ -37,6 +37,7 @@ import { trackChangesAcceptAllWrapper, trackChangesAcceptWrapper, trackChangesDecideRangeWrapper, + trackChangesGetWrapper, trackChangesListWrapper, getCachedProjectedTrackedChangeSnapshot, } from './track-changes-wrappers.js'; @@ -47,9 +48,13 @@ function expectTrackChangesDecideReceiptCodeDeclared(code: string): void { expect(COMMAND_CATALOG['trackChanges.decide'].possibleFailureCodes).toContain(code); } -function makeEditor(commands: Record = {}): Editor { +function makeEditor( + commands: Record = {}, + options: Record = { trackedChanges: {} }, +): Editor { return { commands, + options, state: { doc: { textBetween: vi.fn(() => '') } }, } as unknown as Editor; } @@ -395,13 +400,13 @@ describe('track-changes-wrappers revision guard', () => { }); describe('track-changes-wrappers projected id cache', () => { - it('keeps default paired replacements as one aggregate public list item', () => { + 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: 'insert', + type: 'replacement', excerpt: 'oldnew', wordRevisionIds: { insert: '11', delete: '10' }, storyLabel: 'Body', @@ -428,18 +433,159 @@ describe('track-changes-wrappers projected id cache', () => { expect(result.items).toHaveLength(1); expect(result.items[0]).toMatchObject({ id: 'tc-replacement-1', - type: 'insert', - grouping: 'aggregate', + 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('keeps independent replacement sides paired without aggregating them', () => { + 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' }, @@ -487,13 +633,107 @@ describe('track-changes-wrappers projected id cache', () => { 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.grouping, item.pairedWithChangeId])).toEqual([ - ['tc-insert', 'replacement-pair', 'tc-delete'], - ['tc-delete', 'replacement-pair', 'tc-insert'], + 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 = { 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 d3d22cbd4b..3d0558effb 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 @@ -72,6 +72,21 @@ type ProjectedTrackedChangeCacheEntry = { }; 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, @@ -85,9 +100,6 @@ function buildProjectedInfo( ): ProjectedTrackChange { const id = options.id ?? snapshot.address.entityId; const type = options.type ?? snapshot.type; - const changedText = snapshot.excerpt - ? { [type === 'delete' ? 'deletedText' : 'insertedText']: snapshot.excerpt } - : {}; return { info: { address: { @@ -105,7 +117,7 @@ function buildProjectedInfo( authorImage: snapshot.authorImage, date: snapshot.date, excerpt: snapshot.excerpt, - ...(type === 'format' ? {} : changedText), + ...buildChangedTextFields(type, snapshot.excerpt), }, handleKey: `${snapshot.anchorKey}${options.handleSuffix ?? ''}`, snapshot, @@ -113,7 +125,7 @@ function buildProjectedInfo( } function isCombinedReplacementSnapshot(snapshot: TrackedChangeSnapshot): boolean { - return snapshot.hasInsert && snapshot.hasDelete && !snapshot.hasFormat; + return snapshot.hasInsert && snapshot.hasDelete; } function replacementPairKey(snapshot: TrackedChangeSnapshot): string | null { @@ -127,19 +139,59 @@ function replacementPairKey(snapshot: TrackedChangeSnapshot): string | null { return null; } +function projectedSnapshotType(snapshot: TrackedChangeSnapshot): TrackChangeType { + return isCombinedReplacementSnapshot(snapshot) ? 'replacement' : snapshot.type; +} + function snapshotGrouping(snapshot: TrackedChangeSnapshot): TrackChangeInfo['grouping'] { - return isCombinedReplacementSnapshot(snapshot) ? 'aggregate' : 'standalone'; + return isCombinedReplacementSnapshot(snapshot) ? 'replacement-pair' : 'standalone'; } function snapshotToProjected(snapshot: TrackedChangeSnapshot): ProjectedTrackChange { - return buildProjectedInfo(snapshot, { grouping: snapshotGrouping(snapshot), pairedWithChangeId: null }); + return buildProjectedInfo(snapshot, { + type: projectedSnapshotType(snapshot), + grouping: snapshotGrouping(snapshot), + pairedWithChangeId: null, + }); } function snapshotToInfo(snapshot: TrackedChangeSnapshot): TrackChangeInfo { return snapshotToProjected(snapshot).info; } -export function projectSnapshots(snapshots: ReadonlyArray): ProjectedTrackChange[] { +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; @@ -150,29 +202,35 @@ export function projectSnapshots(snapshots: ReadonlyArray byPairKey.set(key, group); } - const pairedById = new Map(); - for (const group of byPairKey.values()) { - const inserts = group.filter((snapshot) => snapshot.type === 'insert'); - const deletes = group.filter((snapshot) => snapshot.type === 'delete'); - if (inserts.length !== 1 || deletes.length !== 1) continue; - pairedById.set(inserts[0].address.entityId, deletes[0].address.entityId); - pairedById.set(deletes[0].address.entityId, inserts[0].address.entityId); + 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 pairedWithChangeId = pairedById.get(snapshot.address.entityId) ?? null; - projected.push( - buildProjectedInfo(snapshot, { - grouping: pairedWithChangeId ? 'replacement-pair' : 'standalone', - pairedWithChangeId, - }), - ); + 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; @@ -263,14 +321,14 @@ export function trackChangesListWrapper(editor: Editor, input?: TrackChangesList rawSnapshots = index.get(scope.story); } - const projected = projectSnapshots(rawSnapshots); + 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. + // correctly guards body, story-scoped, and replacement-aware review flows. const items = paged.items.map((row) => { const info = row.info; @@ -332,7 +390,7 @@ 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 projected = projectSnapshots(snapshots); + const projected = projectSnapshots(snapshots, readReplacementsMode(editor)); const projectedMatch = projected.find((row) => row.info.id === id); if (projectedMatch) return projectedMatch.info; @@ -346,7 +404,10 @@ export function trackChangesGetWrapper(editor: Editor, input: TrackChangesGetInp const excerpt = (resolved.change.excerpt !== undefined ? resolved.change.excerpt : undefined) ?? normalizeExcerpt(resolved.editor.state.doc.textBetween(resolved.change.from, resolved.change.to, ' ', '\ufffc')); - const changedText = excerpt ? { [type === 'delete' ? 'deletedText' : 'insertedText']: excerpt } : {}; + const grouping = + resolved.change.hasInsert && resolved.change.hasDelete && !resolved.change.hasFormat + ? 'replacement-pair' + : undefined; return { address: { @@ -357,6 +418,7 @@ export function trackChangesGetWrapper(editor: Editor, input: TrackChangesGetInp }, id: resolved.change.id, type, + grouping, wordRevisionIds: normalizeWordRevisionIds(resolved.change.wordRevisionIds), overlap: resolved.change.overlap, author: toNonEmptyString(resolved.change.attrs.author), @@ -364,7 +426,7 @@ export function trackChangesGetWrapper(editor: Editor, input: TrackChangesGetInp authorImage: toNonEmptyString(resolved.change.attrs.authorImage), date: toNonEmptyString(resolved.change.attrs.date), excerpt, - ...(type === 'format' ? {} : changedText), + ...buildChangedTextFields(type, excerpt), }; } 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/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/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'); From 000ba262e78d93ea37a5445fa5c391f4929dc14d Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Sun, 24 May 2026 14:39:47 -0700 Subject: [PATCH 23/25] fix: add ui for overlapping delete, other fixes --- apps/docs/document-api/migration.mdx | 6 +- .../reference/_generated-manifest.json | 2 +- apps/docs/document-api/reference/index.mdx | 2 +- .../reference/track-changes/decide.mdx | 40 +++++- apps/docs/document-engine/sdks.mdx | 4 +- apps/mcp/src/generated/catalog.ts | 36 +++++- .../src/contract/operation-definitions.ts | 12 +- packages/document-api/src/contract/schemas.ts | 21 +++- packages/document-api/src/index.test.ts | 23 +++- .../src/track-changes/track-changes.test.ts | 28 +++++ .../src/track-changes/track-changes.ts | 119 +++++++++++------- .../plan-engine/comments-wrappers.test.ts | 43 ++----- .../plan-engine/comments-wrappers.ts | 68 +--------- .../track-changes-wrappers.test.ts | 47 +++++++ .../plan-engine/track-changes-wrappers.ts | 24 ++-- .../extensions/track-changes/track-changes.js | 1 + .../behavior/tests/navigation/extract.spec.ts | 2 +- 17 files changed, 312 insertions(+), 166 deletions(-) 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 6ad45e09d8..28cbaa4786 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -1079,5 +1079,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "1a411ac1bb94bc869de87173008a88a06a95f73dbf3bbc0906ec420af73ee88d" + "sourceHash": "b154213fe6dbcb96de30f11c473aca2946148af73775fbb642b5c750c6a0bc46" } diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index f3a0afcbee..e590891c7c 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -244,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 tracked changes by ID, range, 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 4cc7f7de74..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 tracked changes by ID, range, 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 tracked changes by ID, range, 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(...)` @@ -27,7 +27,7 @@ Returns a Receipt confirming the decision was applied; reports NO_OP if the chan | 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 @@ -134,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": { @@ -141,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": [ diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index 1d2513fd33..1954e2b2e5 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -1007,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 tracked changes by ID, range, 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 @@ -1486,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 tracked changes by ID, range, 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 26ec5cffd8..7b994255c4 100644 --- a/apps/mcp/src/generated/catalog.ts +++ b/apps/mcp/src/generated/catalog.ts @@ -2423,7 +2423,7 @@ export const MCP_TOOL_CATALOG = { { 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:""} 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":"replacement","limit":10}\n 3. {"action":"decide","decision":"accept","target":{"id":""}}\n 4. {"action":"decide","decision":"reject","target":{"scope":"all"}}', + '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: { @@ -2475,12 +2475,46 @@ export const MCP_TOOL_CATALOG = { additionalProperties: false, required: ['id'], }, + { + 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: ['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'], diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index c2bf48f17a..5214f96fa8 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -419,12 +419,20 @@ export const INTENT_GROUP_META: Record = { '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: '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' } }, ], }, @@ -2499,7 +2507,7 @@ export const OPERATION_DEFINITIONS = { }, 'trackChanges.decide': { memberPath: 'trackChanges.decide', - description: 'Accept or reject tracked changes by ID, range, 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 and typed failures for unsupported or denied tracked-change decisions.', requiresDocumentContext: true, diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index 9f902ca0dc..6d4ab5188a 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -5048,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/index.test.ts b/packages/document-api/src/index.test.ts index 3644439682..8e35d8bdd0 100644 --- a/packages/document-api/src/index.test.ts +++ b/packages/document-api/src/index.test.ts @@ -891,11 +891,16 @@ describe('createDocumentApi', () => { }, }); 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); @@ -909,6 +914,7 @@ describe('createDocumentApi', () => { undefined, ); expect(trackAdpt.acceptAll).toHaveBeenCalledWith({}, undefined); + expect(trackAdpt.acceptAll).toHaveBeenCalledWith({ story: footnoteStory }, undefined); expect(trackAdpt.rejectAll).toHaveBeenCalledWith({}, undefined); }); @@ -1050,7 +1056,7 @@ describe('createDocumentApi', () => { expectError( () => api.trackChanges.decide({ decision: 'accept', target: 'tc-1' } as any), 'INVALID_TARGET', - '{ kind: "id" | "range" | "all" }', + '{ id }, { kind: "range", range }, or { scope: "all" }', ); }); @@ -1059,7 +1065,7 @@ describe('createDocumentApi', () => { expectError( () => api.trackChanges.decide({ decision: 'accept', target: null } as any), 'INVALID_TARGET', - '{ kind: "id" | "range" | "all" }', + '{ id }, { kind: "range", range }, or { scope: "all" }', ); }); @@ -1068,7 +1074,7 @@ describe('createDocumentApi', () => { expectError( () => api.trackChanges.decide({ decision: 'accept', target: { foo: 'bar' } } as any), 'INVALID_TARGET', - '{ kind: "id" | "range" | "all" }', + '{ id }, { kind: "range", range }, or { scope: "all" }', ); }); @@ -1077,7 +1083,16 @@ describe('createDocumentApi', () => { expectError( () => api.trackChanges.decide({ decision: 'accept', target: { id: '' } } as any), 'INVALID_TARGET', - '{ kind: "id" | "range" | "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', ); }); }); 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 8e1a65f9d6..12cb75bc73 100644 --- a/packages/document-api/src/track-changes/track-changes.test.ts +++ b/packages/document-api/src/track-changes/track-changes.test.ts @@ -88,4 +88,32 @@ describe('executeTrackChangesDecide validation', () => { 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 e7b74b47fa..d4734b1ce1 100644 --- a/packages/document-api/src/track-changes/track-changes.ts +++ b/packages/document-api/src/track-changes/track-changes.ts @@ -3,6 +3,7 @@ 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; @@ -24,9 +25,23 @@ 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 type TrackChangesRejectAllInput = Record; +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'; +} /** * Range target for partial-range decisions. @@ -47,20 +62,18 @@ export interface TrackChangesRangeInput { // --------------------------------------------------------------------------- /** - * Canonical decide input shape per - * `../labs/tests/requirements/specs/tracked-changes-comments/tracked-changes-spec.md` - * § 9. The legacy `{ id }` and `{ scope: 'all' }` aliases are preserved during - * the migration window so existing headless callers keep working; the executor - * normalizes them into the canonical `{ kind: ... }` form before dispatch. + * 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 = - | { kind: 'id'; id: string; story?: StoryLocator } + | { id: string; story?: StoryLocator } | { kind: 'range'; range: TextTarget; story?: StoryLocator; part?: string } - | { kind: 'all'; story?: StoryLocator | 'all' } - // Legacy aliases — kept for backwards compatibility with the previous - // call shape. Emitted as deprecation diagnostics during normalization. - | { id: string; story?: StoryLocator; kind?: undefined } - | { scope: 'all'; kind?: undefined }; + | { scope: 'all'; story?: StoryLocator | 'all' }; export type ReviewDecideInput = | { decision: 'accept'; target: ReviewDecisionTarget } @@ -75,9 +88,9 @@ 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 @@ -162,29 +175,45 @@ export function executeTrackChangesDecide( if (typeof input.target !== 'object' || input.target == null) { throw new DocumentApiValidationError( 'INVALID_TARGET', - 'trackChanges.decide target must be an object with { kind: "id" | "range" | "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 story = (target as { story?: StoryLocator }).story; const decision = input.decision as 'accept' | 'reject'; + const rawStory = target.story; - // Canonical shape: `{ kind: 'id' | 'range' | 'all' }`. - if (target.kind === 'id') { - 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.kind = "id" requires a non-empty id.', + 'trackChanges.decide range targets must not include id or scope fields.', { 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 (target.kind === 'range') { + 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, @@ -212,28 +241,34 @@ export function executeTrackChangesDecide( return adapter.decideRange({ decision, range, ...(story ? { story } : {}) }, options); } - if (target.kind === 'all') { - if (decision === 'accept') return adapter.acceptAll({} as TrackChangesAcceptAllInput, options); - return adapter.rejectAll({} as TrackChangesRejectAllInput, options); + if (target.scope === 'all' || target.kind === 'all') { + if (decision === 'accept') { + return adapter.acceptAll({ ...(bulkStory ? { story: bulkStory } : {}) }, options); + } + return adapter.rejectAll({ ...(bulkStory ? { story: bulkStory } : {}) }, options); } - // Legacy aliases — `{ id }` / `{ scope: 'all' }`. Preserved for backwards - // compatibility per the closed product decision in `phase0-checkpoint.md`. - const isAll = target.scope === 'all'; - if (!isAll) { - if (typeof target.id !== 'string' || target.id.length === 0) { + if (target.kind === 'id' || target.id !== undefined) { + if (rawStory === 'all') { throw new DocumentApiValidationError( 'INVALID_TARGET', - 'trackChanges.decide target must have { kind: "id" | "range" | "all" } or the legacy { id } / { scope: "all" } shape.', - { field: 'target', value: input.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 (decision === 'accept') { - if (isAll) return adapter.acceptAll({} as TrackChangesAcceptAllInput, options); - return adapter.accept({ id: target.id as string, ...(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/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 3cb7828b68..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 @@ -759,7 +759,7 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { expect(editor.commands!.setTextSelection).toHaveBeenCalledWith({ from: 19, to: 27 }); }); - it('creates a tracked-change-linked comment without a text selection when a deletion target cannot be resolved live', () => { + it('fails closed when a tracked-change target cannot be resolved live', () => { const editor = { ...makeWriteEditor(), emit: vi.fn(), @@ -777,39 +777,18 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { }); const wrapper = createCommentsWrapper(editor); - const receipt = wrapper.add({ - text: 'comment on deletion', - target: { - trackedChangeId: 'tc-del-1#deleted', - } as Parameters[0]['target'], - }); - - expect(receipt.success).toBe(true); + 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.arrayContaining([ - expect.objectContaining({ - commentId: receipt.id, - commentText: 'comment on deletion', - trackedChange: true, - trackedChangeParentId: 'tc-del-1', - trackedChangeType: 'delete', - }), - ]), - ); - expect((editor as { emit: ReturnType }).emit).toHaveBeenCalledWith( - 'commentsUpdate', - expect.objectContaining({ - type: 'add', - activeCommentId: receipt.id, - comment: expect.objectContaining({ - commentId: receipt.id, - trackedChangeParentId: 'tc-del-1', - trackedChangeType: 'delete', - }), - }), - ); + 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', () => { 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 08539ad15d..4e7a47e952 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 @@ -61,7 +61,7 @@ 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, splitProjectedTrackedChangeId } from '../helpers/tracked-change-resolver.js'; +import { resolveTrackedChangeInStory } from '../helpers/tracked-change-resolver.js'; import { BODY_STORY_KEY, buildStoryKey } from '../story-runtime/story-key.js'; // --------------------------------------------------------------------------- @@ -343,58 +343,6 @@ function applyTextSelection(editor: Editor, from: number, to: number): boolean { return false; } -function addDetachedTrackedChangeComment( - editor: Editor, - input: AddCommentInput, - target: Extract, - options?: RevisionGuardOptions, -): CommentsCreateReceipt { - if (options?.expectedRevision) { - checkRevision(editor, options.expectedRevision); - } - - const commentId = uuidv4(); - const now = Date.now(); - const store = getCommentEntityStore(editor); - const user = (editor.options?.user ?? {}) as EditorUserIdentity; - const { baseId, side } = splitProjectedTrackedChangeId(target.trackedChangeId); - const trackedChangeType = side === 'deleted' ? 'delete' : side === 'inserted' ? 'insert' : null; - const trackedChangeStory = target.story ?? ({ kind: 'story', storyType: 'body' } as StoryLocator); - - upsertCommentEntity(store, commentId, { - commentId, - commentText: input.text, - commentJSON: buildCommentJsonFromText(input.text), - parentCommentId: undefined, - createdTime: now, - creatorName: user.name, - creatorEmail: user.email, - creatorImage: user.image, - isDone: false, - isInternal: false, - fileId: editor.options?.documentId, - documentId: editor.options?.documentId, - trackedChange: true, - trackedChangeParentId: baseId, - trackedChangeType, - trackedChangeDisplayType: null, - trackedChangeStory, - trackedChangeStoryKind: trackedChangeStory.kind === 'story' ? trackedChangeStory.storyType : null, - trackedChangeStoryLabel: - trackedChangeStory.kind === 'story' && trackedChangeStory.storyType === 'body' ? 'Body' : null, - trackedChangeAnchorKey: null, - trackedChangeText: trackedChangeType === 'delete' ? '' : null, - deletedText: trackedChangeType === 'delete' ? '' : null, - }); - - const stored = findCommentEntity(store, commentId); - if (stored) { - emitCommentAdd(editor, buildCommentLifecyclePayload(stored), commentId); - } - - return { success: true, id: commentId, inserted: [toCommentAddress(commentId)] }; -} - function resolveCommentIdentity( editor: Editor, commentId: string, @@ -1081,19 +1029,7 @@ function addCommentHandler( }, }; } - let resolvedTarget: CommentTargetResolution; - try { - resolvedTarget = resolveCommentTarget(editor, target); - } catch (error) { - if ( - isTrackedChangeCommentTargetShape(target) && - error instanceof DocumentApiAdapterError && - error.code === 'TARGET_NOT_FOUND' - ) { - return addDetachedTrackedChangeComment(editor, input, target, options); - } - throw error; - } + const resolvedTarget = resolveCommentTarget(editor, target); if (resolvedTarget.ok === false) { return { success: false, failure: resolvedTarget.failure }; } 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 b28622ffbe..16bdbb0981 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 @@ -271,6 +271,53 @@ describe('track-changes-wrappers revision guard', () => { 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(); 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 3d0558effb..6d3822507d 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 @@ -501,17 +501,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) { @@ -569,18 +579,18 @@ 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); } // --------------------------------------------------------------------------- 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 34ec5ee8c5..1c239d3046 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,3 +1,4 @@ +// @ts-check import { Extension } from '@core/Extension.js'; import { TrackDeleteMarkName, TrackInsertMarkName, TrackFormatMarkName } from './constants.js'; import { TrackChangesBasePlugin, TrackChangesBasePluginKey } from './plugins/index.js'; 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 }) => { From 0c3f079d87de72c20006c36d3282845b54266176 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Sun, 24 May 2026 15:50:53 -0700 Subject: [PATCH 24/25] chore: type fixes --- .../__tests__/lib/special-handlers.test.ts | 57 +++++- apps/cli/src/lib/special-handlers.ts | 29 +++ .../src/types/track-changes.types.ts | 9 + .../v1/core/super-converter/SuperConverter.js | 9 + .../v2/importer/docxImporter.js | 40 +++- .../v2/importer/docxImporter.test.js | 120 +++++++++++ .../helpers/comment-entity-store.test.ts | 42 +++- .../helpers/comment-entity-store.ts | 77 ++++++- .../plan-engine/comments-wrappers.ts | 9 +- .../track-changes-wrappers.test.ts | 191 ++++++++++++++++++ .../plan-engine/track-changes-wrappers.ts | 135 +++++++++++++ .../tracked-changes/tracked-change-index.ts | 2 + .../tracked-change-snapshot.ts | 5 + .../track-changes/plugins/index.d.ts | 1 + .../track-changes/review-model/edit-intent.js | 2 +- .../extensions/track-changes/track-changes.js | 53 +++-- .../trackChangesHelpers/types.js | 2 +- .../src/editors/v1/utils/comment-content.ts | 47 ++--- 18 files changed, 770 insertions(+), 60 deletions(-) diff --git a/apps/cli/src/__tests__/lib/special-handlers.test.ts b/apps/cli/src/__tests__/lib/special-handlers.test.ts index fbff2a2e9e..0f0bccfe86 100644 --- a/apps/cli/src/__tests__/lib/special-handlers.test.ts +++ b/apps/cli/src/__tests__/lib/special-handlers.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { POST_INVOKE_HOOKS } from '../../lib/special-handlers'; +import { POST_INVOKE_HOOKS, PRE_INVOKE_HOOKS } from '../../lib/special-handlers'; const rawTrackChangesList = { evaluatedRevision: '0', @@ -86,4 +86,59 @@ describe('special track-changes handlers', () => { 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/lib/special-handlers.ts b/apps/cli/src/lib/special-handlers.ts index e7b3dadb07..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), ]; @@ -212,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 // --------------------------------------------------------------------------- @@ -283,6 +309,9 @@ export const PRE_INVOKE_HOOKS: Partial { + 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; @@ -93,6 +125,10 @@ const detectDocumentOrigin = (docx) => { return 'google-docs'; } + if (looksLikeGoogleDocsMinimalPackage(docx)) { + return 'google-docs'; + } + return 'unknown'; }; @@ -101,7 +137,7 @@ const matchesElementName = (name, localName) => { return name === localName || name.endsWith(`:${localName}`); }; -const readCustomProperty = (docx, propertyName) => { +function readCustomProperty(docx, propertyName) { try { const customXml = docx?.['docProps/custom.xml']; const properties = customXml?.elements?.find((el) => matchesElementName(el?.name, 'Properties')); @@ -113,7 +149,7 @@ const readCustomProperty = (docx, propertyName) => { } catch { return null; } -}; +} const parseTrackedChangeSourceIdMap = (raw) => { if (typeof raw !== 'string' || raw.length === 0) return new Map(); 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/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 61712ea60c..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' }] }], }); }); }); 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 ef380d3e57..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 @@ -11,6 +11,7 @@ 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; @@ -51,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] = []; @@ -63,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; @@ -128,6 +144,59 @@ export function removeCommentEntityTree(store: CommentEntityRecord[], commentId: return removed; } +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 { if (!value) return; 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 4e7a47e952..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 @@ -53,6 +53,7 @@ import { getCommentEntityStore, isCommentResolved, removeCommentEntityTree, + restoreStashedCommentEntityTree, toCommentInfo, upsertCommentEntity, } from '../helpers/comment-entity-store.js'; @@ -631,6 +632,12 @@ function mergeTrackedChangeCommentInfos(editor: Editor, infosById: Map anchor.commentId))); + for (const commentId of anchoredCommentIds) { + restoreStashedCommentEntityTree(editor, commentId); + } + const store = getCommentEntityStore(editor); const infosById = new Map(); @@ -640,7 +647,7 @@ function buildCommentInfos(editor: Editor): TrackedChangeCommentInfo[] { infosById.set(commentId, toCommentInfo({ ...entry, commentId })); } - const canonicalByCommentId = mergeAnchorData(editor, infosById, listCommentAnchorsSafe(editor)); + const canonicalByCommentId = mergeAnchorData(editor, infosById, anchors); mergeTrackedChangeCommentInfos(editor, infosById); for (const [commentId, canonical] of canonicalByCommentId.entries()) { 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 16bdbb0981..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 @@ -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,12 +34,17 @@ vi.mock('../story-runtime/resolve-story-runtime.js', () => ({ resolveStoryRuntime: mocks.resolveStoryRuntime, })); +vi.mock('../helpers/comment-target-resolver.js', () => ({ + resolveCommentAnchorsById: mocks.resolveCommentAnchorsById, +})); + import { trackChangesAcceptAllWrapper, trackChangesAcceptWrapper, trackChangesDecideRangeWrapper, trackChangesGetWrapper, trackChangesListWrapper, + trackChangesRejectWrapper, getCachedProjectedTrackedChangeSnapshot, } from './track-changes-wrappers.js'; @@ -137,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) }); @@ -222,6 +266,153 @@ describe('track-changes-wrappers revision guard', () => { }); }); + 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) }); 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 6d3822507d..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 @@ -31,6 +31,14 @@ 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'; @@ -118,6 +126,8 @@ function buildProjectedInfo( date: snapshot.date, excerpt: snapshot.excerpt, ...buildChangedTextFields(type, snapshot.excerpt), + origin: snapshot.origin, + imported: snapshot.imported, }, handleKey: `${snapshot.anchorKey}${options.handleSuffix ?? ''}`, snapshot, @@ -300,6 +310,122 @@ function decisionFailureReceipt(editor: Editor, fallbackMessage: string, fallbac }; } +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'; @@ -347,6 +473,8 @@ export function trackChangesListWrapper(editor: Editor, input?: TrackChangesList excerpt, insertedText, deletedText, + origin, + imported, } = info; return buildDiscoveryItem(info.id, handle, { address, @@ -362,6 +490,8 @@ export function trackChangesListWrapper(editor: Editor, input?: TrackChangesList excerpt, insertedText, deletedText, + origin, + imported, }); }); @@ -427,6 +557,8 @@ export function trackChangesGetWrapper(editor: Editor, input: TrackChangesGetInp date: toNonEmptyString(resolved.change.attrs.date), excerpt, ...buildChangedTextFields(type, excerpt), + origin: toNonEmptyString(resolved.change.attrs.origin) as TrackChangeInfo['origin'], + imported: Boolean(toNonEmptyString(resolved.change.attrs.sourceId)), }; } @@ -481,6 +613,7 @@ function decideSingle( } getTrackedChangeIndex(hostEditor).invalidate(resolved.story); + applyDecisionCommentEffects(hostEditor, resolved.editor); return { success: true }; } @@ -565,6 +698,7 @@ function decideAll( runtime.commit(editor); } index.invalidate(story); + applyDecisionCommentEffects(editor, runtime.editor); } if (!anyApplied) { @@ -683,5 +817,6 @@ export function trackChangesDecideRangeWrapper( }); } 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/tracked-change-index.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tracked-changes/tracked-change-index.ts index 5bd2bebb72..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 @@ -281,6 +281,8 @@ 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, 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 fb307f6d8a..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,6 +7,7 @@ import type { StoryLocator, TrackedChangeAddress, + TrackChangeProvenanceOrigin, TrackChangeType, TrackChangeOverlapInfo, TrackChangeWordRevisionIds, @@ -34,6 +35,10 @@ 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"). */ 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/edit-intent.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/edit-intent.js index c7b1acff4d..63be895c44 100644 --- 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 @@ -73,7 +73,7 @@ const isFiniteNonNeg = (value) => typeof value === 'number' && Number.isFinite(v * * @param {*} schema * @param {string} text - * @param {Array} [marks] + * @param {readonly import('prosemirror-model').Mark[]} [marks] * @returns {import('prosemirror-model').Slice} */ export const sliceFromText = (schema, text, marks) => { 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 1c239d3046..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 @@ -15,6 +15,26 @@ import { } 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. * Defaults to `'paired'` when unset; anything other than the exact @@ -28,8 +48,10 @@ const readReplacementsMode = (editor) => * emits bubble lifecycle events from the decision receipt. */ const dispatchReviewDecision = ({ editor, state, dispatch, decision, target }) => { - if (editor?.storage?.trackChanges) { - editor.storage.trackChanges.lastDecisionFailure = null; + const trackChangesStorage = getTrackChangesStorage(editor); + if (trackChangesStorage) { + trackChangesStorage.lastDecisionFailure = null; + trackChangesStorage.lastDecisionReceipt = null; } const result = decideTrackedChanges({ state, @@ -38,12 +60,12 @@ const dispatchReviewDecision = ({ editor, state, dispatch, decision, target }) = target, replacements: readReplacementsMode(editor), }); - if (!result.ok) { + 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 (editor?.storage?.trackChanges) { - editor.storage.trackChanges.lastDecisionFailure = { + if (trackChangesStorage) { + trackChangesStorage.lastDecisionFailure = { code: result.code, message: result.message, details: result.details, @@ -51,6 +73,9 @@ const dispatchReviewDecision = ({ editor, state, dispatch, decision, target }) = } 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 @@ -124,7 +149,7 @@ const dispatchReviewDecision = ({ editor, state, dispatch, decision, target }) = * (mark.attrs.splitFromId === originalId). * * @param {{ state: import('prosemirror-state').EditorState, originalId: string }} options - * @returns {Array<{ from: number, to: number, mark: import('prosemirror-model').Mark, node: import('prosemirror-model').Node }>} + * @returns {import('./trackChangesHelpers/types.js').TrackedMarkRange[]} */ const collectRemainingForLogicalId = ({ state, originalId }) => { const all = getTrackChanges(state); @@ -224,6 +249,7 @@ export const TrackChanges = Extension.create({ return { lastCompilerFailure: null, lastDecisionFailure: null, + lastDecisionReceipt: null, }; }, @@ -285,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', @@ -372,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', @@ -726,8 +752,9 @@ const dispatchCompiledInsertTrackedChange = ({ const replacements = readReplacementsMode(editor); const tr = state.tr; const schema = state.schema; - if (editor?.storage?.trackChanges) { - editor.storage.trackChanges.lastCompilerFailure = null; + const trackChangesStorage = getTrackChangesStorage(editor); + if (trackChangesStorage) { + trackChangesStorage.lastCompilerFailure = null; } const activeMarks = state.storedMarks ?? state.doc.resolve(from).marks(); let intent; @@ -776,9 +803,9 @@ const dispatchCompiledInsertTrackedChange = ({ replacements, }); - if (!result.ok) { - if (editor?.storage?.trackChanges) { - editor.storage.trackChanges.lastCompilerFailure = { + if (result.ok === false) { + if (trackChangesStorage) { + trackChangesStorage.lastCompilerFailure = { code: result.code, message: result.message, details: result.details, 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/utils/comment-content.ts b/packages/super-editor/src/editors/v1/utils/comment-content.ts index 73ed8b71e3..aee15f2cb9 100644 --- a/packages/super-editor/src/editors/v1/utils/comment-content.ts +++ b/packages/super-editor/src/editors/v1/utils/comment-content.ts @@ -1,35 +1,18 @@ -/** - * 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. - */ -export function stripHtmlToText(value: string): string { - return value - .replace(/<[^>]+>/g, ' ') - .replace(/ /gi, ' ') - .replace(/\s+/g, ' ') - .trim(); -} - export function buildCommentJsonFromText(text: string): unknown[] { - const normalized = stripHtmlToText(text); + const normalized = text.replace(/\r\n?/g, '\n'); - return [ - { - type: 'paragraph', - content: [ - { - type: 'run', - content: [ - { - type: 'text', - text: normalized, - }, - ], - }, - ], - }, - ]; + return normalized.split('\n').map((paragraphText) => ({ + type: 'paragraph', + content: [ + { + type: 'run', + content: [ + { + type: 'text', + text: paragraphText, + }, + ], + }, + ], + })); } From a97c15a7e8325f423538b5bb8a43e471dbed696f Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Sun, 24 May 2026 18:36:31 -0700 Subject: [PATCH 25/25] chore: soec fixes --- apps/mcp/src/generated/catalog.ts | 8357 +++++++++-------- .../generated/intent-dispatch.generated.ts | 207 +- .../sdk/langs/browser/src/intent-dispatch.ts | 207 +- .../Editor.track-changes-dispatch.test.js | 90 +- .../src/editors/v1/core/Editor.ts | 101 +- 5 files changed, 4859 insertions(+), 4103 deletions(-) diff --git a/apps/mcp/src/generated/catalog.ts b/apps/mcp/src/generated/catalog.ts index 7b994255c4..b40215a3fe 100644 --- a/apps/mcp/src/generated/catalog.ts +++ b/apps/mcp/src/generated/catalog.ts @@ -1,5192 +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', - }, - hanging: { - type: 'integer', + "indents": { + "type": "object", + "properties": { + "left": { + "type": "integer" }, - firstLine: { - type: 'integer', + "hanging": { + "type": "integer" }, + "firstLine": { + "type": "integer" + } }, - additionalProperties: false, + "additionalProperties": false }, - trailingCharacter: { - enum: ['tab', 'space', 'nothing'], + "trailingCharacter": { + "enum": [ + "tab", + "space", + "nothing" + ] }, - markerFont: { - type: 'string', + "markerFont": { + "type": "string" }, - pictureBulletId: { - type: 'integer', + "pictureBulletId": { + "type": "integer" }, - tabStopAt: { - type: ['integer', 'null'], - }, - }, - 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.", + "description": "Only for action 'create'. Omit for other actions." }, - attachTo: { - $ref: '#/$defs/ListItemAddress', - description: "Required for action 'attach'.", + "attachTo": { + "$ref": "#/$defs/ListItemAddress", + "description": "Required for action 'attach'." }, - direction: { - enum: ['withPrevious', 'withNext'], - description: "Required for action 'merge'.", - }, - 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/SelectionTarget" }, { - $ref: '#/$defs/CommentTrackedChangeTarget', - }, + "$ref": "#/$defs/CommentTrackedChangeTarget" + } ], - description: - "Comment target. Accepts a TextAddress, TextTarget, SelectionTarget, or {trackedChangeId, kind?:'trackedChange'} to anchor directly on tracked content.", + "description": "Comment target. Accepts a TextAddress, TextTarget, SelectionTarget, or {trackedChangeId, kind?:'trackedChange'} to anchor directly on tracked content." }, { - oneOf: [ + "oneOf": [ { - $ref: '#/$defs/TextAddress', + "$ref": "#/$defs/TextAddress" }, { - $ref: '#/$defs/TextTarget', + "$ref": "#/$defs/TextTarget" }, { - $ref: '#/$defs/SelectionTarget', + "$ref": "#/$defs/SelectionTarget" }, { - $ref: '#/$defs/CommentTrackedChangeTarget', - }, - ], - }, + "$ref": "#/$defs/CommentTrackedChangeTarget" + } + ] + } ], - 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.", + "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, 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: [ + "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: { - kind: { - const: 'range', - type: 'string', - }, - range: { - $ref: '#/$defs/TextTarget', - }, - story: { - $ref: '#/$defs/StoryLocator', - }, - part: { - type: 'string', - description: 'Optional part discriminator for the range target.', - }, + "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: ['kind', 'range'], + "additionalProperties": false, + "required": [ + "kind", + "range" + ] }, { - type: 'object', - properties: { - scope: { - enum: ['all'], - }, - story: { - oneOf: [ + "type": "object", + "properties": { + "scope": { + "enum": [ + "all" + ] + }, + "story": { + "oneOf": [ { - $ref: '#/$defs/StoryLocator', + "$ref": "#/$defs/StoryLocator" }, { - const: 'all', - type: 'string', - }, + "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.", - }, + "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'], - }, + "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'], - }, - ], - }, - 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', + "type": "object", + "properties": { + "by": { + "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" + } }, - 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', - }, - 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'], + "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/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/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 3789c316c0..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 @@ -14,6 +14,7 @@ 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, @@ -304,7 +305,7 @@ describe('Editor dispatch tracked-change meta', () => { } }); - it('protects another user tracked insertion from direct delete while local track mode is off', () => { + it('mutates another user tracked insertion in place on direct delete while local track mode is off', () => { ({ editor } = initTestEditor({ mode: 'text', content: '

', @@ -318,19 +319,11 @@ describe('Editor dispatch tracked-change meta', () => { deleteText(editor, INSERTED_TAIL); - expect(editor.state.doc.textContent).toContain(INSERTED_TEXT); - expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe(INSERTED_TEXT); - - const childDeletion = markEntries(editor, TrackDeleteMarkName).find(({ text }) => text === INSERTED_TAIL); - expect(childDeletion?.mark.attrs).toEqual( - expect.objectContaining({ - authorEmail: BOB.email, - overlapParentId: FOREIGN_INSERT_ID, - }), - ); + expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe(INSERTED_TEXT_AFTER_DIRECT_DELETE); + expect(markEntries(editor, TrackDeleteMarkName)).toHaveLength(0); }); - it('protects anonymous live tracked insertion from direct delete without a configured editor user', () => { + it('mutates an anonymous live tracked insertion in place on direct delete without a configured editor user', () => { ({ editor } = initTestEditor({ mode: 'text', content: '

', @@ -343,20 +336,11 @@ describe('Editor dispatch tracked-change meta', () => { deleteText(editor, INSERTED_TAIL); - expect(editor.state.doc.textContent).toContain(INSERTED_TEXT); - expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe(INSERTED_TEXT); - - const childDeletion = markEntries(editor, TrackDeleteMarkName).find(({ text }) => text === INSERTED_TAIL); - expect(childDeletion?.mark.attrs).toEqual( - expect.objectContaining({ - author: '', - authorEmail: '', - overlapParentId: FOREIGN_INSERT_ID, - }), - ); + expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe(INSERTED_TEXT_AFTER_DIRECT_DELETE); + expect(markEntries(editor, TrackDeleteMarkName)).toHaveLength(0); }); - it('protects anonymous live tracked insertion from document-api direct delete', () => { + it('mutates an anonymous live tracked insertion in place from document-api direct delete', () => { ({ editor } = initTestEditor({ mode: 'text', content: '

', @@ -367,20 +351,11 @@ describe('Editor dispatch tracked-change meta', () => { const receipt = editor.doc.delete({ ref: getFirstMatchRef(editor, INSERTED_TAIL) }, { changeMode: 'direct' }); expect(receipt.success).toBe(true); - expect(editor.state.doc.textContent).toContain(INSERTED_TEXT); - expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe(INSERTED_TEXT); - - const childDeletion = markEntries(editor, TrackDeleteMarkName).find(({ text }) => text === INSERTED_TAIL); - expect(childDeletion?.mark.attrs).toEqual( - expect.objectContaining({ - author: '', - authorEmail: '', - overlapParentId: FOREIGN_INSERT_ID, - }), - ); + expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe(INSERTED_TEXT_AFTER_DIRECT_DELETE); + expect(markEntries(editor, TrackDeleteMarkName)).toHaveLength(0); }); - it('protects tracked insertion created by document-api insert from document-api direct delete', () => { + it('mutates tracked insertion created by document-api insert from document-api direct delete', () => { ({ editor } = initTestEditor({ mode: 'text', content: '

', @@ -406,31 +381,19 @@ describe('Editor dispatch tracked-change meta', () => { const deleteReceipt = editor.doc.delete({ ref: getFirstMatchRef(editor, 'review') }, { changeMode: 'direct' }); expect(deleteReceipt.success).toBe(true); - expect(editor.state.doc.textContent).toContain('live-review-comment'); - - expect(textForMarkId(editor, TrackInsertMarkName, insertMark?.mark.attrs.id)).toBe('live-review-comment'); + expect(editor.state.doc.textContent).toContain('live--comment'); - const childDeletion = markEntries(editor, TrackDeleteMarkName).find(({ text }) => text === 'review'); - expect(childDeletion?.mark.attrs).toEqual( - expect.objectContaining({ - author: 'CLI', - authorId: 'cli', - authorEmail: '', - overlapParentId: insertMark?.mark.attrs.id, - }), - ); + 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(2); - expect(trackedChanges.items.map((item) => item.raw?.type ?? item.type).sort()).toEqual(['delete', 'insert']); + 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(2); + expect(comments.total).toBe(1); expect(comments.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ trackedChangeType: 'insert', trackedChangeText: 'live-review-comment' }), - expect.objectContaining({ trackedChangeType: 'delete', deletedText: 'review' }), - ]), + expect.arrayContaining([expect.objectContaining({ trackedChangeType: 'insert', trackedChangeText: 'live--comment' })]), ); }); @@ -506,7 +469,7 @@ describe('Editor dispatch tracked-change meta', () => { ); }); - it('emits review comment state for a protected child deletion instead of only truncating the parent', () => { + it('does not emit a child deletion review comment on direct delete inside a tracked insertion', () => { ({ editor } = initTestEditor({ mode: 'text', content: '

', @@ -526,13 +489,16 @@ describe('Editor dispatch tracked-change meta', () => { payload?.trackedChangeType === TrackDeleteMarkName, )?.[1]; - expect(childDeletionEvent).toEqual( - expect.objectContaining({ - deletedText: expect.stringContaining(INSERTED_TAIL), - authorEmail: BOB.email, - }), + 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, + }), + ]), ); - expect(textForMarkId(editor, TrackInsertMarkName, FOREIGN_INSERT_ID)).toBe(INSERTED_TEXT); }); it('still allows direct deletion of untracked plain text while local track mode is off', () => { diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index 004635a047..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 { 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'; @@ -180,6 +180,95 @@ const rangeHasTrackedReviewMark = (doc: PmNode, from: number, to: number): boole 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); @@ -222,6 +311,12 @@ const collapsedInsertionExtendsTrackedReviewMark = ( 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 ( @@ -2912,6 +3007,7 @@ 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); @@ -2919,6 +3015,9 @@ export class Editor extends EventEmitter { 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.'); }