diff --git a/apps/cli/src/cli/operation-hints.ts b/apps/cli/src/cli/operation-hints.ts index 5736a880d8..f8568cc5dd 100644 --- a/apps/cli/src/cli/operation-hints.ts +++ b/apps/cli/src/cli/operation-hints.ts @@ -208,6 +208,11 @@ export const SUCCESS_VERB: Record = { 'images.setPosition': 'set position', 'images.setAnchorOptions': 'set anchor options', 'images.setZOrder': 'set z-order', + + // Diff + 'diff.capture': 'captured snapshot', + 'diff.compare': 'compared documents', + 'diff.apply': 'applied diff', }; // --------------------------------------------------------------------------- @@ -235,7 +240,10 @@ export type OutputFormat = | 'documentInfo' | 'receipt' | 'plain' - | 'void'; + | 'void' + | 'diffSnapshot' + | 'diffPayload' + | 'diffApplyResult'; export const OUTPUT_FORMAT: Record = { get: 'plain', @@ -375,6 +383,11 @@ export const OUTPUT_FORMAT: Record = { 'images.setPosition': 'plain', 'images.setAnchorOptions': 'plain', 'images.setZOrder': 'plain', + + // Diff + 'diff.capture': 'diffSnapshot', + 'diff.compare': 'diffPayload', + 'diff.apply': 'diffApplyResult', }; // --------------------------------------------------------------------------- @@ -537,6 +550,11 @@ export const RESPONSE_ENVELOPE_KEY: Record 'headerFooters.parts.list': 'result', 'headerFooters.parts.create': 'result', 'headerFooters.parts.delete': 'result', + + // Diff + 'diff.capture': 'snapshot', + 'diff.compare': 'diff', + 'diff.apply': 'result', }; // --------------------------------------------------------------------------- @@ -577,6 +595,7 @@ export type OperationFamily = | 'create' | 'blocks' | 'query' + | 'diff' | 'general'; export const OPERATION_FAMILY: Record = { @@ -717,4 +736,9 @@ export const OPERATION_FAMILY: Record = 'images.setPosition': 'images', 'images.setAnchorOptions': 'images', 'images.setZOrder': 'images', + + // Diff + 'diff.capture': 'diff', + 'diff.compare': 'diff', + 'diff.apply': 'diff', }; diff --git a/apps/cli/src/cli/operation-set.ts b/apps/cli/src/cli/operation-set.ts index 30b47b1a2f..eb66738c9e 100644 --- a/apps/cli/src/cli/operation-set.ts +++ b/apps/cli/src/cli/operation-set.ts @@ -105,6 +105,7 @@ const REFERENCE_GROUP_TO_CATEGORY: Record = { toc: 'toc', images: 'images', history: 'history', + diff: 'core', }; function deriveCategoryFromDocApi(docApiId: OperationId): CliCategory { diff --git a/apps/cli/src/lib/document.ts b/apps/cli/src/lib/document.ts index f246b96e9f..b5293010a1 100644 --- a/apps/cli/src/lib/document.ts +++ b/apps/cli/src/lib/document.ts @@ -2,10 +2,9 @@ import { readFile, writeFile } from 'node:fs/promises'; import { createHash } from 'node:crypto'; import { Editor } from 'superdoc/super-editor'; import { BLANK_DOCX_BASE64 } from '@superdoc/super-editor/blank-docx'; -import { getDocumentApiAdapters } from '@superdoc/super-editor/document-api-adapters'; import { markdownToPmDoc } from '@superdoc/super-editor/markdown'; -import { createDocumentApi, type DocumentApi } from '@superdoc/document-api'; +import type { DocumentApi } from '@superdoc/document-api'; import { createCliDomEnvironment } from './dom-environment'; import type { CollaborationProfile } from './collaboration'; import { createCollaborationRuntime } from './collaboration'; @@ -218,9 +217,6 @@ export async function openDocument( } } - const adapters = getDocumentApiAdapters(editor); - const docApi = createDocumentApi(adapters); - Object.defineProperty(editor, 'doc', { value: docApi, configurable: true, writable: true }); const editorWithDoc = editor as EditorWithDoc; return { diff --git a/apps/cli/src/lib/error-mapping.ts b/apps/cli/src/lib/error-mapping.ts index eed01ce903..5226ed4f81 100644 --- a/apps/cli/src/lib/error-mapping.ts +++ b/apps/cli/src/lib/error-mapping.ts @@ -324,6 +324,27 @@ function tryMapPlanEngineError( // Per-family error mappers (dispatch by family) // --------------------------------------------------------------------------- +function mapDiffError(operationId: CliExposedOperationId, error: unknown, code: string | undefined): CliError { + const message = extractErrorMessage(error); + const details = extractErrorDetails(error); + + if (code === 'INVALID_INPUT') { + return new CliError('INVALID_INPUT', message, { operationId, details }); + } + if (code === 'CAPABILITY_UNSUPPORTED') { + return new CliError('CAPABILITY_UNSUPPORTED', message, { operationId, details }); + } + if (code === 'PRECONDITION_FAILED') { + return new CliError('PRECONDITION_FAILED', message, { operationId, details }); + } + if (code === 'CAPABILITY_UNAVAILABLE') { + return new CliError('CAPABILITY_UNAVAILABLE', message, { operationId, details }); + } + + if (error instanceof CliError) return error; + return new CliError('COMMAND_FAILED', message, { operationId, details }); +} + const FAMILY_MAPPERS: Record< OperationFamily, (operationId: CliExposedOperationId, error: unknown, code: string | undefined) => CliError @@ -338,6 +359,7 @@ const FAMILY_MAPPERS: Record< create: mapCreateError, blocks: mapBlocksError, query: mapQueryError, + diff: mapDiffError, general: (operationId, error, code) => { // Plan-engine errors pass through with original code and structured details const planEngineError = tryMapPlanEngineError(operationId, error, code); diff --git a/apps/cli/src/lib/errors.ts b/apps/cli/src/lib/errors.ts index 49d6855c97..f83b48e937 100644 --- a/apps/cli/src/lib/errors.ts +++ b/apps/cli/src/lib/errors.ts @@ -42,7 +42,8 @@ export type CliErrorCode = | 'PAGE_NUMBERS_NOT_MATERIALIZED' | 'CAPABILITY_UNAVAILABLE' | 'INVALID_TARGET' - | 'AMBIGUOUS_TARGET'; + | 'AMBIGUOUS_TARGET' + | 'CAPABILITY_UNSUPPORTED'; /** * Intersection type for errors thrown by document-api adapter operations. diff --git a/apps/cli/src/lib/output-formatters.ts b/apps/cli/src/lib/output-formatters.ts index 31692580d6..da3d7c224f 100644 --- a/apps/cli/src/lib/output-formatters.ts +++ b/apps/cli/src/lib/output-formatters.ts @@ -187,6 +187,46 @@ function formatDocumentInfo(result: unknown, ctx: FormatContext): string { return lines.join('\n'); } +// --------------------------------------------------------------------------- +// Diff formatters +// --------------------------------------------------------------------------- + +function formatDiffSnapshot(result: unknown, ctx: FormatContext): string { + const record = asRecord(result); + if (!record) return `Revision ${ctx.revision}: captured snapshot`; + const fingerprint = hasNonEmptyString(record.fingerprint) ? record.fingerprint : ''; + const coverage = asRecord(record.coverage); + const components = coverage + ? Object.entries(coverage) + .filter(([, v]) => v === true) + .map(([k]) => k) + .join(', ') + : 'body'; + const payloadSize = record.payload ? JSON.stringify(record.payload).length : 0; + return `Revision ${ctx.revision}: captured snapshot\n fingerprint: ${fingerprint}\n coverage: ${components}\n payload size: ${payloadSize} bytes`; +} + +function formatDiffPayload(result: unknown, ctx: FormatContext): string { + const record = asRecord(result); + if (!record) return `Revision ${ctx.revision}: compared documents`; + const summary = asRecord(record.summary); + const changed = asArray(summary?.changedComponents).filter(hasNonEmptyString); + const baseFp = hasNonEmptyString(record.baseFingerprint) ? record.baseFingerprint : ''; + const targetFp = hasNonEmptyString(record.targetFingerprint) ? record.targetFingerprint : ''; + const changedStr = changed.length > 0 ? changed.join(', ') : 'none'; + return `Revision ${ctx.revision}: compared documents\n base: ${baseFp}\n target: ${targetFp}\n changed: ${changedStr}`; +} + +function formatDiffApplyResult(result: unknown, ctx: FormatContext): string { + const record = asRecord(result); + if (!record) return `Revision ${ctx.revision}: applied diff`; + const ops = safeNumber(record.appliedOperations, 0); + const summary = asRecord(record.summary); + const changed = asArray(summary?.changedComponents).filter(hasNonEmptyString); + const changedStr = changed.length > 0 ? changed.join(', ') : 'none'; + return `Revision ${ctx.revision}: applied diff (${ops} operations)\n changed: ${changedStr}`; +} + // --------------------------------------------------------------------------- // Dispatch // --------------------------------------------------------------------------- @@ -200,6 +240,9 @@ const FORMAT_DISPATCH: Partial> = { listResult: (result, ctx) => formatListResult(result, ctx), trackChangeList: (result, ctx) => formatTrackChangeList(result, ctx), documentInfo: (result, ctx) => formatDocumentInfo(result, ctx), + diffSnapshot: (result, ctx) => formatDiffSnapshot(result, ctx), + diffPayload: (result, ctx) => formatDiffPayload(result, ctx), + diffApplyResult: (result, ctx) => formatDiffApplyResult(result, ctx), }; /** diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 0d442c4d47..c9e5cebb58 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -109,7 +109,10 @@ "document-api/available-operations" ] }, - "document-engine/sdks", + { + "group": "SDKs", + "pages": ["document-engine/sdks", "document-engine/sdk-diffing"] + }, "document-engine/cli", { "group": "AI Agents", diff --git a/apps/docs/document-api/available-operations.mdx b/apps/docs/document-api/available-operations.mdx index a4a6c7ed16..413706b39a 100644 --- a/apps/docs/document-api/available-operations.mdx +++ b/apps/docs/document-api/available-operations.mdx @@ -24,6 +24,7 @@ Use the tables below to see what operations are available and where each one is | Core | 13 | 0 | 13 | [Reference](/document-api/reference/core/index) | | Create | 6 | 0 | 6 | [Reference](/document-api/reference/create/index) | | Cross-References | 5 | 0 | 5 | [Reference](/document-api/reference/cross-refs/index) | +| 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) | @@ -161,6 +162,9 @@ Use the tables below to see what operations are available and where each one is | editor.doc.crossRefs.insert(...) | [`crossRefs.insert`](/document-api/reference/cross-refs/insert) | | editor.doc.crossRefs.rebuild(...) | [`crossRefs.rebuild`](/document-api/reference/cross-refs/rebuild) | | editor.doc.crossRefs.remove(...) | [`crossRefs.remove`](/document-api/reference/cross-refs/remove) | +| editor.doc.diff.capture(...) | [`diff.capture`](/document-api/reference/diff/capture) | +| editor.doc.diff.compare(...) | [`diff.compare`](/document-api/reference/diff/compare) | +| editor.doc.diff.apply(...) | [`diff.apply`](/document-api/reference/diff/apply) | | editor.doc.fields.list(...) | [`fields.list`](/document-api/reference/fields/list) | | editor.doc.fields.get(...) | [`fields.get`](/document-api/reference/fields/get) | | editor.doc.fields.insert(...) | [`fields.insert`](/document-api/reference/fields/insert) | diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 1186d7fe49..0da3964084 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -126,6 +126,10 @@ "apps/docs/document-api/reference/cross-refs/rebuild.mdx", "apps/docs/document-api/reference/cross-refs/remove.mdx", "apps/docs/document-api/reference/delete.mdx", + "apps/docs/document-api/reference/diff/apply.mdx", + "apps/docs/document-api/reference/diff/capture.mdx", + "apps/docs/document-api/reference/diff/compare.mdx", + "apps/docs/document-api/reference/diff/index.mdx", "apps/docs/document-api/reference/fields/get.mdx", "apps/docs/document-api/reference/fields/index.mdx", "apps/docs/document-api/reference/fields/insert.mdx", @@ -948,8 +952,15 @@ "operationIds": ["ranges.resolve"], "pagePath": "apps/docs/document-api/reference/ranges/index.mdx", "title": "Ranges" + }, + { + "aliasMemberPaths": [], + "key": "diff", + "operationIds": ["diff.capture", "diff.compare", "diff.apply"], + "pagePath": "apps/docs/document-api/reference/diff/index.mdx", + "title": "Diff" } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "068dea933bf1591112019534c6bae48f811dc8d65c42f6cb94f365548028ea77" + "sourceHash": "bf7e9b493d8ab9e84c2d5875b5aa7fe0e74e8504ec3e258e463af5e529b16e92" } diff --git a/apps/docs/document-api/reference/capabilities/get.mdx b/apps/docs/document-api/reference/capabilities/get.mdx index beef164146..edb3ba65e4 100644 --- a/apps/docs/document-api/reference/capabilities/get.mdx +++ b/apps/docs/document-api/reference/capabilities/get.mdx @@ -842,6 +842,21 @@ _No fields._ | `operations.delete.dryRun` | boolean | yes | | | `operations.delete.reasons` | enum[] | no | | | `operations.delete.tracked` | boolean | yes | | +| `operations.diff.apply` | object | yes | | +| `operations.diff.apply.available` | boolean | yes | | +| `operations.diff.apply.dryRun` | boolean | yes | | +| `operations.diff.apply.reasons` | enum[] | no | | +| `operations.diff.apply.tracked` | boolean | yes | | +| `operations.diff.capture` | object | yes | | +| `operations.diff.capture.available` | boolean | yes | | +| `operations.diff.capture.dryRun` | boolean | yes | | +| `operations.diff.capture.reasons` | enum[] | no | | +| `operations.diff.capture.tracked` | boolean | yes | | +| `operations.diff.compare` | object | yes | | +| `operations.diff.compare.available` | boolean | yes | | +| `operations.diff.compare.dryRun` | boolean | yes | | +| `operations.diff.compare.reasons` | enum[] | no | | +| `operations.diff.compare.tracked` | boolean | yes | | | `operations.fields.get` | object | yes | | | `operations.fields.get.available` | boolean | yes | | | `operations.fields.get.dryRun` | boolean | yes | | @@ -2943,6 +2958,21 @@ _No fields._ "dryRun": true, "tracked": true }, + "diff.apply": { + "available": true, + "dryRun": false, + "tracked": true + }, + "diff.capture": { + "available": true, + "dryRun": false, + "tracked": false + }, + "diff.compare": { + "available": true, + "dryRun": false, + "tracked": false + }, "fields.get": { "available": true, "dryRun": false, @@ -9846,6 +9876,111 @@ _No fields._ ], "type": "object" }, + "diff.apply": { + "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" + }, + "diff.capture": { + "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" + }, + "diff.compare": { + "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" + }, "fields.get": { "additionalProperties": false, "properties": { @@ -18889,7 +19024,10 @@ _No fields._ "authorities.entries.get", "authorities.entries.insert", "authorities.entries.update", - "authorities.entries.remove" + "authorities.entries.remove", + "diff.capture", + "diff.compare", + "diff.apply" ], "type": "object" }, diff --git a/apps/docs/document-api/reference/diff/apply.mdx b/apps/docs/document-api/reference/diff/apply.mdx new file mode 100644 index 0000000000..2821b1c602 --- /dev/null +++ b/apps/docs/document-api/reference/diff/apply.mdx @@ -0,0 +1,615 @@ +--- +title: diff.apply +sidebarTitle: diff.apply +description: Apply a previously computed diff payload to the current document. The document fingerprint must match the diff base fingerprint. Tracked mode governs body content only; styles, numbering, and comments are always applied directly. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Apply a previously computed diff payload to the current document. The document fingerprint must match the diff base fingerprint. Tracked mode governs body content only; styles, numbering, and comments are always applied directly. + +- Operation ID: `diff.apply` +- API member path: `editor.doc.diff.apply(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `yes` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a DiffApplyResult with applied operation count and diagnostics. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `diff` | object(version="sd-diff-payload/v1") | yes | | +| `diff.baseFingerprint` | string | yes | | +| `diff.coverage` | object | yes | | +| `diff.coverage.body` | `true` | yes | Constant: `true` | +| `diff.coverage.comments` | boolean | yes | | +| `diff.coverage.headerFooters` | `false` | yes | Constant: `false` | +| `diff.coverage.numbering` | boolean | yes | | +| `diff.coverage.styles` | boolean | yes | | +| `diff.engine` | enum | yes | `"super-editor"` | +| `diff.payload` | object | yes | | +| `diff.summary` | object | yes | | +| `diff.summary.body` | object | yes | | +| `diff.summary.body.hasChanges` | boolean | yes | | +| `diff.summary.changedComponents` | enum[] | yes | | +| `diff.summary.comments` | object | yes | | +| `diff.summary.comments.hasChanges` | boolean | yes | | +| `diff.summary.hasChanges` | boolean | yes | | +| `diff.summary.numbering` | object | yes | | +| `diff.summary.numbering.hasChanges` | boolean | yes | | +| `diff.summary.styles` | object | yes | | +| `diff.summary.styles.hasChanges` | boolean | yes | | +| `diff.targetFingerprint` | string | yes | | +| `diff.version` | `"sd-diff-payload/v1"` | yes | Constant: `"sd-diff-payload/v1"` | + +### Example request + +```json +{ + "diff": { + "baseFingerprint": "example", + "coverage": { + "body": true, + "comments": true, + "headerFooters": false, + "numbering": true, + "styles": true + }, + "engine": "super-editor", + "payload": {}, + "summary": { + "body": { + "hasChanges": true + }, + "changedComponents": [ + "body" + ], + "comments": { + "hasChanges": true + }, + "hasChanges": true, + "numbering": { + "hasChanges": true + }, + "styles": { + "hasChanges": true + } + }, + "targetFingerprint": "example", + "version": "sd-diff-payload/v1" + } +} +``` + +## Output fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `appliedOperations` | integer | yes | | +| `baseFingerprint` | string | yes | | +| `coverage` | object | yes | | +| `coverage.body` | `true` | yes | Constant: `true` | +| `coverage.comments` | boolean | yes | | +| `coverage.headerFooters` | `false` | yes | Constant: `false` | +| `coverage.numbering` | boolean | yes | | +| `coverage.styles` | boolean | yes | | +| `diagnostics` | string[] | yes | | +| `summary` | object | yes | | +| `summary.body` | object | yes | | +| `summary.body.hasChanges` | boolean | yes | | +| `summary.changedComponents` | enum[] | yes | | +| `summary.comments` | object | yes | | +| `summary.comments.hasChanges` | boolean | yes | | +| `summary.hasChanges` | boolean | yes | | +| `summary.numbering` | object | yes | | +| `summary.numbering.hasChanges` | boolean | yes | | +| `summary.styles` | object | yes | | +| `summary.styles.hasChanges` | boolean | yes | | +| `targetFingerprint` | string | yes | | + +### Example response + +```json +{ + "appliedOperations": 1, + "baseFingerprint": "example", + "coverage": { + "body": true, + "comments": true, + "headerFooters": false, + "numbering": true, + "styles": true + }, + "diagnostics": [ + "example" + ], + "summary": { + "body": { + "hasChanges": true + }, + "changedComponents": [ + "body" + ], + "comments": { + "hasChanges": true + }, + "hasChanges": true, + "numbering": { + "hasChanges": true + }, + "styles": { + "hasChanges": true + } + }, + "targetFingerprint": "example" +} +``` + +## Pre-apply throws + +- `INVALID_INPUT` +- `CAPABILITY_UNSUPPORTED` +- `PRECONDITION_FAILED` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- None + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "diff": { + "additionalProperties": false, + "properties": { + "baseFingerprint": { + "type": "string" + }, + "coverage": { + "additionalProperties": false, + "properties": { + "body": { + "const": true, + "type": "boolean" + }, + "comments": { + "type": "boolean" + }, + "headerFooters": { + "const": false, + "type": "boolean" + }, + "numbering": { + "type": "boolean" + }, + "styles": { + "type": "boolean" + } + }, + "required": [ + "body", + "comments", + "styles", + "numbering", + "headerFooters" + ], + "type": "object" + }, + "engine": { + "enum": [ + "super-editor" + ], + "type": "string" + }, + "payload": { + "description": "Opaque engine-owned diff data.", + "type": "object" + }, + "summary": { + "additionalProperties": false, + "properties": { + "body": { + "additionalProperties": false, + "properties": { + "hasChanges": { + "type": "boolean" + } + }, + "required": [ + "hasChanges" + ], + "type": "object" + }, + "changedComponents": { + "items": { + "enum": [ + "body", + "comments", + "styles", + "numbering" + ], + "type": "string" + }, + "type": "array" + }, + "comments": { + "additionalProperties": false, + "properties": { + "hasChanges": { + "type": "boolean" + } + }, + "required": [ + "hasChanges" + ], + "type": "object" + }, + "hasChanges": { + "type": "boolean" + }, + "numbering": { + "additionalProperties": false, + "properties": { + "hasChanges": { + "type": "boolean" + } + }, + "required": [ + "hasChanges" + ], + "type": "object" + }, + "styles": { + "additionalProperties": false, + "properties": { + "hasChanges": { + "type": "boolean" + } + }, + "required": [ + "hasChanges" + ], + "type": "object" + } + }, + "required": [ + "hasChanges", + "changedComponents", + "body", + "comments", + "styles", + "numbering" + ], + "type": "object" + }, + "targetFingerprint": { + "type": "string" + }, + "version": { + "const": "sd-diff-payload/v1", + "type": "string" + } + }, + "required": [ + "version", + "engine", + "baseFingerprint", + "targetFingerprint", + "coverage", + "summary", + "payload" + ], + "type": "object" + } + }, + "required": [ + "diff" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "appliedOperations": { + "type": "integer" + }, + "baseFingerprint": { + "type": "string" + }, + "coverage": { + "additionalProperties": false, + "properties": { + "body": { + "const": true, + "type": "boolean" + }, + "comments": { + "type": "boolean" + }, + "headerFooters": { + "const": false, + "type": "boolean" + }, + "numbering": { + "type": "boolean" + }, + "styles": { + "type": "boolean" + } + }, + "required": [ + "body", + "comments", + "styles", + "numbering", + "headerFooters" + ], + "type": "object" + }, + "diagnostics": { + "items": { + "type": "string" + }, + "type": "array" + }, + "summary": { + "additionalProperties": false, + "properties": { + "body": { + "additionalProperties": false, + "properties": { + "hasChanges": { + "type": "boolean" + } + }, + "required": [ + "hasChanges" + ], + "type": "object" + }, + "changedComponents": { + "items": { + "enum": [ + "body", + "comments", + "styles", + "numbering" + ], + "type": "string" + }, + "type": "array" + }, + "comments": { + "additionalProperties": false, + "properties": { + "hasChanges": { + "type": "boolean" + } + }, + "required": [ + "hasChanges" + ], + "type": "object" + }, + "hasChanges": { + "type": "boolean" + }, + "numbering": { + "additionalProperties": false, + "properties": { + "hasChanges": { + "type": "boolean" + } + }, + "required": [ + "hasChanges" + ], + "type": "object" + }, + "styles": { + "additionalProperties": false, + "properties": { + "hasChanges": { + "type": "boolean" + } + }, + "required": [ + "hasChanges" + ], + "type": "object" + } + }, + "required": [ + "hasChanges", + "changedComponents", + "body", + "comments", + "styles", + "numbering" + ], + "type": "object" + }, + "targetFingerprint": { + "type": "string" + } + }, + "required": [ + "appliedOperations", + "baseFingerprint", + "targetFingerprint", + "coverage", + "summary", + "diagnostics" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "appliedOperations": { + "type": "integer" + }, + "baseFingerprint": { + "type": "string" + }, + "coverage": { + "additionalProperties": false, + "properties": { + "body": { + "const": true, + "type": "boolean" + }, + "comments": { + "type": "boolean" + }, + "headerFooters": { + "const": false, + "type": "boolean" + }, + "numbering": { + "type": "boolean" + }, + "styles": { + "type": "boolean" + } + }, + "required": [ + "body", + "comments", + "styles", + "numbering", + "headerFooters" + ], + "type": "object" + }, + "diagnostics": { + "items": { + "type": "string" + }, + "type": "array" + }, + "summary": { + "additionalProperties": false, + "properties": { + "body": { + "additionalProperties": false, + "properties": { + "hasChanges": { + "type": "boolean" + } + }, + "required": [ + "hasChanges" + ], + "type": "object" + }, + "changedComponents": { + "items": { + "enum": [ + "body", + "comments", + "styles", + "numbering" + ], + "type": "string" + }, + "type": "array" + }, + "comments": { + "additionalProperties": false, + "properties": { + "hasChanges": { + "type": "boolean" + } + }, + "required": [ + "hasChanges" + ], + "type": "object" + }, + "hasChanges": { + "type": "boolean" + }, + "numbering": { + "additionalProperties": false, + "properties": { + "hasChanges": { + "type": "boolean" + } + }, + "required": [ + "hasChanges" + ], + "type": "object" + }, + "styles": { + "additionalProperties": false, + "properties": { + "hasChanges": { + "type": "boolean" + } + }, + "required": [ + "hasChanges" + ], + "type": "object" + } + }, + "required": [ + "hasChanges", + "changedComponents", + "body", + "comments", + "styles", + "numbering" + ], + "type": "object" + }, + "targetFingerprint": { + "type": "string" + } + }, + "required": [ + "appliedOperations", + "baseFingerprint", + "targetFingerprint", + "coverage", + "summary", + "diagnostics" + ], + "type": "object" +} +``` + + + +```json +{ + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/diff/capture.mdx b/apps/docs/document-api/reference/diff/capture.mdx new file mode 100644 index 0000000000..49b24ffb04 --- /dev/null +++ b/apps/docs/document-api/reference/diff/capture.mdx @@ -0,0 +1,153 @@ +--- +title: diff.capture +sidebarTitle: diff.capture +description: "Capture the current document's diffable state as a versioned snapshot. v1 covers body, comments, styles, and numbering. Header/footer content is not included." +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Capture the current document's diffable state as a versioned snapshot. v1 covers body, comments, styles, and numbering. Header/footer content is not included. + +- Operation ID: `diff.capture` +- API member path: `editor.doc.diff.capture(...)` +- Mutates document: `no` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a DiffSnapshot with a fingerprint and opaque payload. + +## Input fields + +_No fields._ + +### Example request + +```json +{} +``` + +## Output fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `coverage` | object | yes | | +| `coverage.body` | `true` | yes | Constant: `true` | +| `coverage.comments` | boolean | yes | | +| `coverage.headerFooters` | `false` | yes | Constant: `false` | +| `coverage.numbering` | boolean | yes | | +| `coverage.styles` | boolean | yes | | +| `engine` | enum | yes | `"super-editor"` | +| `fingerprint` | string | yes | | +| `payload` | object | yes | | +| `version` | `"sd-diff-snapshot/v1"` | yes | Constant: `"sd-diff-snapshot/v1"` | + +### Example response + +```json +{ + "coverage": { + "body": true, + "comments": true, + "headerFooters": false, + "numbering": true, + "styles": true + }, + "engine": "super-editor", + "fingerprint": "example", + "payload": {}, + "version": "sd-diff-snapshot/v1" +} +``` + +## Pre-apply throws + +- None + +## Non-applied failure codes + +- None + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": {}, + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "coverage": { + "additionalProperties": false, + "properties": { + "body": { + "const": true, + "type": "boolean" + }, + "comments": { + "type": "boolean" + }, + "headerFooters": { + "const": false, + "type": "boolean" + }, + "numbering": { + "type": "boolean" + }, + "styles": { + "type": "boolean" + } + }, + "required": [ + "body", + "comments", + "styles", + "numbering", + "headerFooters" + ], + "type": "object" + }, + "engine": { + "enum": [ + "super-editor" + ], + "type": "string" + }, + "fingerprint": { + "type": "string" + }, + "payload": { + "description": "Opaque engine-owned snapshot data.", + "type": "object" + }, + "version": { + "const": "sd-diff-snapshot/v1", + "type": "string" + } + }, + "required": [ + "version", + "engine", + "fingerprint", + "coverage", + "payload" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/diff/compare.mdx b/apps/docs/document-api/reference/diff/compare.mdx new file mode 100644 index 0000000000..4edc0dd049 --- /dev/null +++ b/apps/docs/document-api/reference/diff/compare.mdx @@ -0,0 +1,357 @@ +--- +title: diff.compare +sidebarTitle: diff.compare +description: Compare the current document (base) against a previously captured target snapshot. Returns a versioned diff payload describing the changes from base to target. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Compare the current document (base) against a previously captured target snapshot. Returns a versioned diff payload describing the changes from base to target. + +- Operation ID: `diff.compare` +- API member path: `editor.doc.diff.compare(...)` +- Mutates document: `no` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a DiffPayload with a summary and opaque payload. + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `targetSnapshot` | object(version="sd-diff-snapshot/v1") | yes | | +| `targetSnapshot.coverage` | object | yes | | +| `targetSnapshot.coverage.body` | `true` | yes | Constant: `true` | +| `targetSnapshot.coverage.comments` | boolean | yes | | +| `targetSnapshot.coverage.headerFooters` | `false` | yes | Constant: `false` | +| `targetSnapshot.coverage.numbering` | boolean | yes | | +| `targetSnapshot.coverage.styles` | boolean | yes | | +| `targetSnapshot.engine` | enum | yes | `"super-editor"` | +| `targetSnapshot.fingerprint` | string | yes | | +| `targetSnapshot.payload` | object | yes | | +| `targetSnapshot.version` | `"sd-diff-snapshot/v1"` | yes | Constant: `"sd-diff-snapshot/v1"` | + +### Example request + +```json +{ + "targetSnapshot": { + "coverage": { + "body": true, + "comments": true, + "headerFooters": false, + "numbering": true, + "styles": true + }, + "engine": "super-editor", + "fingerprint": "example", + "payload": {}, + "version": "sd-diff-snapshot/v1" + } +} +``` + +## Output fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `baseFingerprint` | string | yes | | +| `coverage` | object | yes | | +| `coverage.body` | `true` | yes | Constant: `true` | +| `coverage.comments` | boolean | yes | | +| `coverage.headerFooters` | `false` | yes | Constant: `false` | +| `coverage.numbering` | boolean | yes | | +| `coverage.styles` | boolean | yes | | +| `engine` | enum | yes | `"super-editor"` | +| `payload` | object | yes | | +| `summary` | object | yes | | +| `summary.body` | object | yes | | +| `summary.body.hasChanges` | boolean | yes | | +| `summary.changedComponents` | enum[] | yes | | +| `summary.comments` | object | yes | | +| `summary.comments.hasChanges` | boolean | yes | | +| `summary.hasChanges` | boolean | yes | | +| `summary.numbering` | object | yes | | +| `summary.numbering.hasChanges` | boolean | yes | | +| `summary.styles` | object | yes | | +| `summary.styles.hasChanges` | boolean | yes | | +| `targetFingerprint` | string | yes | | +| `version` | `"sd-diff-payload/v1"` | yes | Constant: `"sd-diff-payload/v1"` | + +### Example response + +```json +{ + "baseFingerprint": "example", + "coverage": { + "body": true, + "comments": true, + "headerFooters": false, + "numbering": true, + "styles": true + }, + "engine": "super-editor", + "payload": {}, + "summary": { + "body": { + "hasChanges": true + }, + "changedComponents": [ + "body" + ], + "comments": { + "hasChanges": true + }, + "hasChanges": true, + "numbering": { + "hasChanges": true + }, + "styles": { + "hasChanges": true + } + }, + "targetFingerprint": "example", + "version": "sd-diff-payload/v1" +} +``` + +## Pre-apply throws + +- `INVALID_INPUT` +- `CAPABILITY_UNSUPPORTED` + +## Non-applied failure codes + +- None + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "targetSnapshot": { + "additionalProperties": false, + "properties": { + "coverage": { + "additionalProperties": false, + "properties": { + "body": { + "const": true, + "type": "boolean" + }, + "comments": { + "type": "boolean" + }, + "headerFooters": { + "const": false, + "type": "boolean" + }, + "numbering": { + "type": "boolean" + }, + "styles": { + "type": "boolean" + } + }, + "required": [ + "body", + "comments", + "styles", + "numbering", + "headerFooters" + ], + "type": "object" + }, + "engine": { + "enum": [ + "super-editor" + ], + "type": "string" + }, + "fingerprint": { + "type": "string" + }, + "payload": { + "description": "Opaque engine-owned snapshot data.", + "type": "object" + }, + "version": { + "const": "sd-diff-snapshot/v1", + "type": "string" + } + }, + "required": [ + "version", + "engine", + "fingerprint", + "coverage", + "payload" + ], + "type": "object" + } + }, + "required": [ + "targetSnapshot" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "baseFingerprint": { + "type": "string" + }, + "coverage": { + "additionalProperties": false, + "properties": { + "body": { + "const": true, + "type": "boolean" + }, + "comments": { + "type": "boolean" + }, + "headerFooters": { + "const": false, + "type": "boolean" + }, + "numbering": { + "type": "boolean" + }, + "styles": { + "type": "boolean" + } + }, + "required": [ + "body", + "comments", + "styles", + "numbering", + "headerFooters" + ], + "type": "object" + }, + "engine": { + "enum": [ + "super-editor" + ], + "type": "string" + }, + "payload": { + "description": "Opaque engine-owned diff data.", + "type": "object" + }, + "summary": { + "additionalProperties": false, + "properties": { + "body": { + "additionalProperties": false, + "properties": { + "hasChanges": { + "type": "boolean" + } + }, + "required": [ + "hasChanges" + ], + "type": "object" + }, + "changedComponents": { + "items": { + "enum": [ + "body", + "comments", + "styles", + "numbering" + ], + "type": "string" + }, + "type": "array" + }, + "comments": { + "additionalProperties": false, + "properties": { + "hasChanges": { + "type": "boolean" + } + }, + "required": [ + "hasChanges" + ], + "type": "object" + }, + "hasChanges": { + "type": "boolean" + }, + "numbering": { + "additionalProperties": false, + "properties": { + "hasChanges": { + "type": "boolean" + } + }, + "required": [ + "hasChanges" + ], + "type": "object" + }, + "styles": { + "additionalProperties": false, + "properties": { + "hasChanges": { + "type": "boolean" + } + }, + "required": [ + "hasChanges" + ], + "type": "object" + } + }, + "required": [ + "hasChanges", + "changedComponents", + "body", + "comments", + "styles", + "numbering" + ], + "type": "object" + }, + "targetFingerprint": { + "type": "string" + }, + "version": { + "const": "sd-diff-payload/v1", + "type": "string" + } + }, + "required": [ + "version", + "engine", + "baseFingerprint", + "targetFingerprint", + "coverage", + "summary", + "payload" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/diff/index.mdx b/apps/docs/document-api/reference/diff/index.mdx new file mode 100644 index 0000000000..e39a51bf40 --- /dev/null +++ b/apps/docs/document-api/reference/diff/index.mdx @@ -0,0 +1,20 @@ +--- +title: Diff operations +sidebarTitle: Diff +description: Diff operation reference from the canonical Document API contract. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +[Back to full reference](../index) + +Snapshot-based document comparison and replay. + +| Operation | Member path | Mutates | Idempotency | Tracked | Dry run | +| --- | --- | --- | --- | --- | --- | +| diff.capture | `diff.capture` | No | `idempotent` | No | No | +| diff.compare | `diff.compare` | No | `idempotent` | No | No | +| diff.apply | `diff.apply` | Yes | `conditional` | Yes | No | + diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index 447f5e75c7..df2c5c3a16 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -50,6 +50,7 @@ Document API is currently alpha and subject to breaking changes. | Citations | 15 | 0 | 15 | [Open](/document-api/reference/citations/index) | | Table of Authorities | 11 | 0 | 11 | [Open](/document-api/reference/authorities/index) | | Ranges | 1 | 0 | 1 | [Open](/document-api/reference/ranges/index) | +| Diff | 3 | 0 | 3 | [Open](/document-api/reference/diff/index) | ## Available operations @@ -567,3 +568,11 @@ The tables below are grouped by namespace. | Operation | API member path | Description | | --- | --- | --- | | ranges.resolve | editor.doc.ranges.resolve(...) | Resolve two explicit anchors into a contiguous document range. Returns a transparent SelectionTarget, a mutation-ready ref, and preview metadata. Stateless and deterministic. | + +#### Diff + +| Operation | API member path | Description | +| --- | --- | --- | +| diff.capture | editor.doc.diff.capture(...) | Capture the current document's diffable state as a versioned snapshot. v1 covers body, comments, styles, and numbering. Header/footer content is not included. | +| diff.compare | editor.doc.diff.compare(...) | Compare the current document (base) against a previously captured target snapshot. Returns a versioned diff payload describing the changes from base to target. | +| diff.apply | editor.doc.diff.apply(...) | Apply a previously computed diff payload to the current document. The document fingerprint must match the diff base fingerprint. Tracked mode governs body content only; styles, numbering, and comments are always applied directly. | diff --git a/apps/docs/document-engine/sdk-diffing.mdx b/apps/docs/document-engine/sdk-diffing.mdx new file mode 100644 index 0000000000..41dc1b7fcc --- /dev/null +++ b/apps/docs/document-engine/sdk-diffing.mdx @@ -0,0 +1,157 @@ +--- +title: SDK Diffing +sidebarTitle: Diffing +description: Generate tracked-change redlines from two documents with the Node.js and Python SDKs +keywords: "sdk diffing, tracked changes, redline, compare documents, node sdk, python sdk" +--- + +SuperDoc SDKs expose snapshot-based diffing through `doc.diff.capture`, `doc.diff.compare`, and `doc.diff.apply`. + +This page covers the main file-to-file workflow: + +- You already have a base document, `Doc1` +- A new document, `Doc2`, arrives later +- You want to produce `Doc3`, which starts from `Doc1` and contains all `Doc2` changes as tracked changes + +## Recommended workflow + +Use two sessions: + +1. Open `Doc1` as the base session +2. Open `Doc2` as a temporary target session +3. Capture a diff snapshot from the target session +4. Compare the base session against that snapshot +5. Apply the diff back onto the base session with `changeMode: 'tracked'` +6. Save the base session to a new output path as `Doc3` + + +Tracked diff apply requires a user identity. Set `user` on the SDK client so tracked changes are attributed correctly. + + +If your app already has `Doc1` open, keep using that session as the base session and only open the uploaded document as the temporary target session. + +## Node.js + +```typescript +import { SuperDocClient } from '@superdoc-dev/sdk'; + +const client = new SuperDocClient({ + user: { name: 'Review Bot', email: 'bot@example.com' }, +}); + +await client.doc.open({ + sessionId: 'base', + doc: './Doc1.docx', +}); + +await client.doc.open({ + sessionId: 'target', + doc: './Doc2.docx', +}); + +const targetSnapshot = await client.doc.diff.capture({ + sessionId: 'target', +}); + +await client.doc.close({ + sessionId: 'target', +}); + +const diff = await client.doc.diff.compare({ + sessionId: 'base', + targetSnapshot, +}); + +await client.doc.diff.apply({ + sessionId: 'base', + diff, + changeMode: 'tracked', +}); + +await client.doc.save({ + sessionId: 'base', + out: './Doc3.docx', + force: true, +}); + +await client.doc.close({ + sessionId: 'base', +}); +``` + +## Python + +```python +import asyncio + +from superdoc import AsyncSuperDocClient + + +async def main(): + async with AsyncSuperDocClient( + user={"name": "Review Bot", "email": "bot@example.com"} + ) as client: + await client.doc.open({ + "sessionId": "base", + "doc": "./Doc1.docx", + }) + + await client.doc.open({ + "sessionId": "target", + "doc": "./Doc2.docx", + }) + + target_snapshot = await client.doc.diff.capture({ + "sessionId": "target", + }) + + await client.doc.close({ + "sessionId": "target", + }) + + diff = await client.doc.diff.compare({ + "sessionId": "base", + "targetSnapshot": target_snapshot, + }) + + await client.doc.diff.apply({ + "sessionId": "base", + "diff": diff, + "changeMode": "tracked", + }) + + await client.doc.save({ + "sessionId": "base", + "out": "./Doc3.docx", + "force": True, + }) + + await client.doc.close({ + "sessionId": "base", + }) + + +asyncio.run(main()) +``` + +## What this produces + +- `Doc3` is based on `Doc1`, not `Doc2` +- Body edits from `Doc2` are replayed onto `Doc1` as tracked changes +- Comments, styles, and numbering changes are replayed directly +- The output stays as a reviewable redline until someone accepts or rejects the tracked changes + +## Current v1 limits + +- Header and footer content is not diffed in v1 +- The diff payload is opaque and intended for replay, not semantic inspection +- `diff.apply` checks that the current base document fingerprint still matches the diff's `baseFingerprint` + +If the base document changes after `diff.compare` and before `diff.apply`, re-run the compare step against the current base document state. + +## See also + +- [SDKs Overview](/document-engine/sdks) +- [diff.capture reference](/document-api/reference/diff/capture) +- [diff.compare reference](/document-api/reference/diff/compare) +- [diff.apply reference](/document-api/reference/diff/apply) diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index 65ea0d3cf2..17bb3c7d75 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -1,6 +1,6 @@ --- title: SDKs -sidebarTitle: SDKs +sidebarTitle: Overview description: Node.js and Python SDKs for programmatic document editing keywords: "sdk, node, python, document automation, headless docx, superdoc sdk" --- @@ -173,6 +173,12 @@ const { createSuperDocClient } = require('@superdoc-dev/sdk'); Set `defaultChangeMode: 'tracked'` (Node) or `default_change_mode='tracked'` (Python) to make mutations use tracked changes by default. If you pass `changeMode` on a specific call, that explicit value overrides the default. The Python SDK also exposes synchronous `SuperDocClient` with the same `doc.*` operations when you prefer non-async code paths. +## Need file-to-file diffing? + +For workflows where you already have `Doc1` and need to compare an uploaded `Doc2`, see [SDK Diffing](/document-engine/sdk-diffing). + +That guide covers the recommended `diff.capture -> diff.compare -> diff.apply` flow and how to save the result as a third `.docx` with tracked changes. + ## User identity By default the SDK attributes edits to a generic "CLI" user. Set `user` on the client to identify your automation in comments, tracked changes, and collaboration presence: @@ -521,6 +527,9 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.authorities.entries.insert` | `authorities entries insert` | Insert a new TA authority entry field at a target location. | | `doc.authorities.entries.update` | `authorities entries update` | Update the properties of an existing TA authority entry. | | `doc.authorities.entries.remove` | `authorities entries remove` | Remove a TA authority entry field from the document. | +| `doc.diff.capture` | `diff capture` | Capture the current document's diffable state as a versioned snapshot. v1 covers body, comments, styles, and numbering. Header/footer content is not included. | +| `doc.diff.compare` | `diff compare` | Compare the current document (base) against a previously captured target snapshot. Returns a versioned diff payload describing the changes from base to target. | +| `doc.diff.apply` | `diff apply` | Apply a previously computed diff payload to the current document. The document fingerprint must match the diff base fingerprint. Tracked mode governs body content only; styles, numbering, and comments are always applied directly. | #### Format @@ -956,6 +965,9 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.authorities.entries.insert` | `authorities entries insert` | Insert a new TA authority entry field at a target location. | | `doc.authorities.entries.update` | `authorities entries update` | Update the properties of an existing TA authority entry. | | `doc.authorities.entries.remove` | `authorities entries remove` | Remove a TA authority entry field from the document. | +| `doc.diff.capture` | `diff capture` | Capture the current document's diffable state as a versioned snapshot. v1 covers body, comments, styles, and numbering. Header/footer content is not included. | +| `doc.diff.compare` | `diff compare` | Compare the current document (base) against a previously captured target snapshot. Returns a versioned diff payload describing the changes from base to target. | +| `doc.diff.apply` | `diff apply` | Apply a previously computed diff payload to the current document. The document fingerprint must match the diff base fingerprint. Tracked mode governs body content only; styles, numbering, and comments are always applied directly. | #### Format diff --git a/packages/document-api/src/contract/contract.test.ts b/packages/document-api/src/contract/contract.test.ts index f2388f8a06..605c8412f8 100644 --- a/packages/document-api/src/contract/contract.test.ts +++ b/packages/document-api/src/contract/contract.test.ts @@ -244,6 +244,7 @@ describe('document-api contract catalog', () => { 'citations', 'authorities', 'ranges', + 'diff', ]; for (const id of OPERATION_IDS) { expect(validGroups, `${id} has invalid referenceGroup`).toContain(OPERATION_DEFINITIONS[id].referenceGroup); @@ -316,7 +317,8 @@ describe('document-api contract catalog', () => { id.startsWith('headerFooters.') || id === 'styles.apply' || id === 'tables.setDefaultStyle' || - id === 'tables.clearDefaultStyle', + id === 'tables.clearDefaultStyle' || + id === 'diff.apply', `unexpected historyUnsafe: ${id}`, ).toBe(true); } diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index 87e9819f89..b873e9598d 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -62,7 +62,8 @@ export type ReferenceGroupKey = | 'fields' | 'citations' | 'authorities' - | 'ranges'; + | 'ranges' + | 'diff'; // --------------------------------------------------------------------------- // Entry shape @@ -5268,6 +5269,60 @@ export const OPERATION_DEFINITIONS = { referenceDocPath: 'authorities/entries-remove.mdx', referenceGroup: 'authorities', }, + + // --------------------------------------------------------------------------- + // diff.* + // --------------------------------------------------------------------------- + + 'diff.capture': { + memberPath: 'diff.capture', + description: + "Capture the current document's diffable state as a versioned snapshot. " + + 'v1 covers body, comments, styles, and numbering. Header/footer content is not included.', + expectedResult: 'Returns a DiffSnapshot with a fingerprint and opaque payload.', + requiresDocumentContext: true, + metadata: readOperation({ + idempotency: 'idempotent', + }), + referenceDocPath: 'diff/capture.mdx', + referenceGroup: 'diff', + skipAsATool: true, + }, + 'diff.compare': { + memberPath: 'diff.compare', + description: + 'Compare the current document (base) against a previously captured target snapshot. ' + + 'Returns a versioned diff payload describing the changes from base to target.', + expectedResult: 'Returns a DiffPayload with a summary and opaque payload.', + requiresDocumentContext: true, + metadata: readOperation({ + idempotency: 'idempotent', + throws: ['INVALID_INPUT', 'CAPABILITY_UNSUPPORTED'], + }), + referenceDocPath: 'diff/compare.mdx', + referenceGroup: 'diff', + skipAsATool: true, + }, + 'diff.apply': { + memberPath: 'diff.apply', + description: + 'Apply a previously computed diff payload to the current document. ' + + 'The document fingerprint must match the diff base fingerprint. ' + + 'Tracked mode governs body content only; styles, numbering, and comments are always applied directly.', + expectedResult: 'Returns a DiffApplyResult with applied operation count and diagnostics.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: false, + supportsTrackedMode: true, + possibleFailureCodes: NONE_FAILURES, + throws: ['INVALID_INPUT', 'CAPABILITY_UNSUPPORTED', 'PRECONDITION_FAILED', 'CAPABILITY_UNAVAILABLE'], + historyUnsafe: true, + }), + referenceDocPath: 'diff/apply.mdx', + referenceGroup: 'diff', + skipAsATool: true, + }, } as const satisfies Record; // --------------------------------------------------------------------------- diff --git a/packages/document-api/src/contract/operation-registry.ts b/packages/document-api/src/contract/operation-registry.ts index 48bb9a31b4..96ac9de5c5 100644 --- a/packages/document-api/src/contract/operation-registry.ts +++ b/packages/document-api/src/contract/operation-registry.ts @@ -54,6 +54,14 @@ import type { TrackChangesListInput, TrackChangesGetInput, ReviewDecideInput } f import type { TrackChangeInfo, TrackChangesListResult } from '../types/track-changes.types.js'; import type { DocumentApiCapabilities } from '../capabilities/capabilities.js'; import type { HistoryState, HistoryActionResult } from '../history/history.types.js'; +import type { + DiffSnapshot, + DiffPayload, + DiffApplyResult, + DiffCompareInput, + DiffApplyInput, + DiffApplyOptions, +} from '../diff/diff.types.js'; import type { ListsListQuery, ListsListResult, @@ -1413,6 +1421,11 @@ export interface OperationRegistry extends FormatInlineAliasOperationRegistry { options: MutationOptions; output: AuthorityEntryMutationResult; }; + + // --- diff.* --- + 'diff.capture': { input: undefined; options: never; output: DiffSnapshot }; + 'diff.compare': { input: DiffCompareInput; options: never; output: DiffPayload }; + 'diff.apply': { input: DiffApplyInput; options: DiffApplyOptions; output: DiffApplyResult }; } // --- Bidirectional completeness checks --- diff --git a/packages/document-api/src/contract/reference-doc-map.ts b/packages/document-api/src/contract/reference-doc-map.ts index 4f219efd91..c7b7f35aa8 100644 --- a/packages/document-api/src/contract/reference-doc-map.ts +++ b/packages/document-api/src/contract/reference-doc-map.ts @@ -171,6 +171,11 @@ const GROUP_METADATA: Record = { get: { input: objectSchema({ @@ -6249,6 +6310,22 @@ const operationSchemas: Record = { input: objectSchema({ target: authorityEntryAddressSchema }, ['target']), ...authorityEntryMutation, }, + + // --- diff.* --- + 'diff.capture': { + input: objectSchema({}), + output: diffSnapshotSchema, + }, + 'diff.compare': { + input: objectSchema({ targetSnapshot: diffSnapshotSchema }, ['targetSnapshot']), + output: diffPayloadSchema, + }, + 'diff.apply': { + input: objectSchema({ diff: diffPayloadSchema }, ['diff']), + output: diffApplyResultSchema, + success: diffApplyResultSchema, + failure: { type: 'object' }, + }, }; /** diff --git a/packages/document-api/src/diff/diff.ts b/packages/document-api/src/diff/diff.ts new file mode 100644 index 0000000000..26a23488a2 --- /dev/null +++ b/packages/document-api/src/diff/diff.ts @@ -0,0 +1,127 @@ +/** + * Diff namespace — engine-agnostic public API and adapter contract. + * + * Validates wrapper-level shape, then delegates to the engine adapter. + * Deep payload validation lives in the engine adapter / shared diff service. + */ + +import { DocumentApiValidationError } from '../errors.js'; +import type { + DiffSnapshot, + DiffPayload, + DiffApplyResult, + DiffCompareInput, + DiffApplyInput, + DiffApplyOptions, +} from './diff.types.js'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const SNAPSHOT_VERSION = 'sd-diff-snapshot/v1'; +const PAYLOAD_VERSION = 'sd-diff-payload/v1'; + +// --------------------------------------------------------------------------- +// Adapter interface — implemented by each engine +// --------------------------------------------------------------------------- + +export interface DiffAdapter { + capture(): DiffSnapshot; + compare(input: DiffCompareInput): DiffPayload; + apply(input: DiffApplyInput, options?: DiffApplyOptions): DiffApplyResult; +} + +// --------------------------------------------------------------------------- +// Public API shape on DocumentApi +// --------------------------------------------------------------------------- + +export interface DiffApi { + capture(): DiffSnapshot; + compare(input: DiffCompareInput): DiffPayload; + apply(input: DiffApplyInput, options?: DiffApplyOptions): DiffApplyResult; +} + +// --------------------------------------------------------------------------- +// Validation helpers +// --------------------------------------------------------------------------- + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function validateSnapshotWrapper(snapshot: unknown): asserts snapshot is DiffSnapshot { + if (!isRecord(snapshot)) { + throw new DocumentApiValidationError('INVALID_INPUT', 'targetSnapshot must be a DiffSnapshot object.'); + } + if (snapshot.version !== SNAPSHOT_VERSION) { + throw new DocumentApiValidationError( + 'CAPABILITY_UNSUPPORTED', + `Unsupported snapshot version "${String(snapshot.version)}". Expected "${SNAPSHOT_VERSION}".`, + ); + } + if (typeof snapshot.engine !== 'string') { + throw new DocumentApiValidationError('INVALID_INPUT', 'targetSnapshot.engine must be a string.'); + } + if (typeof snapshot.fingerprint !== 'string') { + throw new DocumentApiValidationError('INVALID_INPUT', 'targetSnapshot.fingerprint must be a string.'); + } + if (!isRecord(snapshot.coverage)) { + throw new DocumentApiValidationError('INVALID_INPUT', 'targetSnapshot.coverage must be an object.'); + } + if (!isRecord(snapshot.payload)) { + throw new DocumentApiValidationError('INVALID_INPUT', 'targetSnapshot.payload must be an object.'); + } +} + +function validateDiffPayloadWrapper(diff: unknown): asserts diff is DiffPayload { + if (!isRecord(diff)) { + throw new DocumentApiValidationError('INVALID_INPUT', 'diff must be a DiffPayload object.'); + } + if (diff.version !== PAYLOAD_VERSION) { + throw new DocumentApiValidationError( + 'CAPABILITY_UNSUPPORTED', + `Unsupported diff version "${String(diff.version)}". Expected "${PAYLOAD_VERSION}".`, + ); + } + if (typeof diff.engine !== 'string') { + throw new DocumentApiValidationError('INVALID_INPUT', 'diff.engine must be a string.'); + } + if (typeof diff.baseFingerprint !== 'string') { + throw new DocumentApiValidationError('INVALID_INPUT', 'diff.baseFingerprint must be a string.'); + } + if (typeof diff.targetFingerprint !== 'string') { + throw new DocumentApiValidationError('INVALID_INPUT', 'diff.targetFingerprint must be a string.'); + } + if (!isRecord(diff.coverage)) { + throw new DocumentApiValidationError('INVALID_INPUT', 'diff.coverage must be an object.'); + } + if (!isRecord(diff.summary)) { + throw new DocumentApiValidationError('INVALID_INPUT', 'diff.summary must be an object.'); + } + if (!isRecord(diff.payload)) { + throw new DocumentApiValidationError('INVALID_INPUT', 'diff.payload must be an object.'); + } +} + +// --------------------------------------------------------------------------- +// Execute functions — bridge public API to adapter +// --------------------------------------------------------------------------- + +export function executeDiffCapture(adapter: DiffAdapter): DiffSnapshot { + return adapter.capture(); +} + +export function executeDiffCompare(adapter: DiffAdapter, input: DiffCompareInput): DiffPayload { + validateSnapshotWrapper(input?.targetSnapshot); + return adapter.compare(input); +} + +export function executeDiffApply( + adapter: DiffAdapter, + input: DiffApplyInput, + options?: DiffApplyOptions, +): DiffApplyResult { + validateDiffPayloadWrapper(input?.diff); + return adapter.apply(input, options); +} diff --git a/packages/document-api/src/diff/diff.types.ts b/packages/document-api/src/diff/diff.types.ts new file mode 100644 index 0000000000..af0f7dc02f --- /dev/null +++ b/packages/document-api/src/diff/diff.types.ts @@ -0,0 +1,103 @@ +/** + * Public types for the snapshot-based document diff contract. + * + * These types are engine-agnostic wrappers around opaque engine payloads. + * The `payload` field in both DiffSnapshot and DiffPayload is engine-owned + * and must not be inspected by consumers. + */ + +// --------------------------------------------------------------------------- +// Engine identification +// --------------------------------------------------------------------------- + +/** Identifier for the engine adapter that produced the opaque payload. */ +export type DiffEngineId = 'super-editor'; + +// --------------------------------------------------------------------------- +// Coverage +// --------------------------------------------------------------------------- + +/** Declares which document components are included in a snapshot or diff. */ +export interface DiffCoverage { + body: true; + comments: boolean; + styles: boolean; + numbering: boolean; + headerFooters: false; +} + +// --------------------------------------------------------------------------- +// Snapshot +// --------------------------------------------------------------------------- + +/** Versioned, fingerprinted snapshot of a document's diffable state. */ +export interface DiffSnapshot { + version: 'sd-diff-snapshot/v1'; + engine: DiffEngineId; + fingerprint: string; + coverage: DiffCoverage; + /** Opaque engine-owned snapshot data. Do not inspect or modify. */ + payload: Record; +} + +// --------------------------------------------------------------------------- +// Diff result types +// --------------------------------------------------------------------------- + +/** Coarse change summary for a diff payload. */ +export interface DiffSummary { + hasChanges: boolean; + changedComponents: Array<'body' | 'comments' | 'styles' | 'numbering'>; + body: { hasChanges: boolean }; + comments: { hasChanges: boolean }; + styles: { hasChanges: boolean }; + numbering: { hasChanges: boolean }; +} + +/** Versioned diff payload describing changes from a base to a target document. */ +export interface DiffPayload { + version: 'sd-diff-payload/v1'; + engine: DiffEngineId; + baseFingerprint: string; + targetFingerprint: string; + coverage: DiffCoverage; + summary: DiffSummary; + /** Opaque engine-owned diff data. Do not inspect or modify. */ + payload: Record; +} + +/** Result metadata returned after applying a diff. */ +export interface DiffApplyResult { + appliedOperations: number; + baseFingerprint: string; + targetFingerprint: string; + coverage: DiffCoverage; + summary: DiffSummary; + diagnostics: string[]; +} + +// --------------------------------------------------------------------------- +// Operation inputs +// --------------------------------------------------------------------------- + +/** Input for `diff.compare`. */ +export interface DiffCompareInput { + targetSnapshot: DiffSnapshot; +} + +/** Input for `diff.apply`. */ +export interface DiffApplyInput { + diff: DiffPayload; +} + +// --------------------------------------------------------------------------- +// Change mode (re-exported for convenience) +// --------------------------------------------------------------------------- + +/** How body content changes are applied. */ +export type DiffChangeMode = 'direct' | 'tracked'; + +/** Options for `diff.apply`. */ +export interface DiffApplyOptions { + changeMode?: DiffChangeMode; +} diff --git a/packages/document-api/src/diff/index.ts b/packages/document-api/src/diff/index.ts new file mode 100644 index 0000000000..f8d7a5bc46 --- /dev/null +++ b/packages/document-api/src/diff/index.ts @@ -0,0 +1,14 @@ +export type { DiffAdapter, DiffApi } from './diff.js'; +export { executeDiffCapture, executeDiffCompare, executeDiffApply } from './diff.js'; +export type { + DiffSnapshot, + DiffPayload, + DiffApplyResult, + DiffSummary, + DiffCoverage, + DiffEngineId, + DiffCompareInput, + DiffApplyInput, + DiffApplyOptions, + DiffChangeMode, +} from './diff.types.js'; diff --git a/packages/document-api/src/errors.ts b/packages/document-api/src/errors.ts index 7d6d172cf6..930c28712f 100644 --- a/packages/document-api/src/errors.ts +++ b/packages/document-api/src/errors.ts @@ -35,6 +35,8 @@ const LEGACY_TO_SD_CODE: Record = { INVALID_PLACEMENT: 'INVALID_PLACEMENT', REVISION_MISMATCH: 'REVISION_MISMATCH', INTERNAL_ERROR: 'INTERNAL_ERROR', + CAPABILITY_UNSUPPORTED: 'CAPABILITY_UNSUPPORTED', + PRECONDITION_FAILED: 'INVALID_PAYLOAD', }; /** diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index 6cb6a2e691..7b6b44312a 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -9,6 +9,8 @@ export * from './contract/index.js'; export * from './capabilities/capabilities.js'; export * from './inline-semantics/index.js'; export type { HistoryAdapter, HistoryApi } from './history/history.js'; +export type { DiffAdapter, DiffApi } from './diff/diff.js'; +export * from './diff/diff.types.js'; export type { SelectionMutationAdapter, SelectionMutationRequest } from './selection-mutation.js'; export type { RangeAnchor, @@ -284,6 +286,16 @@ import { buildDispatchTable } from './invoke/invoke.js'; import type { HistoryAdapter, HistoryApi } from './history/history.js'; import type { HistoryState, HistoryActionResult } from './history/history.types.js'; import { executeHistoryGet, executeHistoryUndo, executeHistoryRedo } from './history/history.js'; +import type { DiffAdapter, DiffApi } from './diff/diff.js'; +import { executeDiffCapture, executeDiffCompare, executeDiffApply } from './diff/diff.js'; +import type { + DiffSnapshot, + DiffPayload, + DiffApplyResult, + DiffCompareInput, + DiffApplyInput, + DiffApplyOptions, +} from './diff/diff.types.js'; import { executeTableOperation } from './tables/tables.js'; import type { ParagraphsAdapter, @@ -1538,6 +1550,10 @@ export interface DocumentApi { * Mutation plan engine — preview and apply atomic mutation plans. */ mutations: MutationsApi; + /** + * Snapshot-based document comparison and replay. + */ + diff: DiffApi; /** * History operations (undo/redo) scoped to the active editor instance. * Session-scoped — reflects the runtime undo/redo stack, not persistent state. @@ -1603,6 +1619,7 @@ export interface DocumentApiAdapters { ranges: RangesAdapter; query: QueryAdapter; mutations: MutationsAdapter; + diff: DiffAdapter; history: HistoryAdapter; } @@ -2808,6 +2825,17 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { return adapters.mutations.apply(input); }, }, + diff: { + capture(): DiffSnapshot { + return executeDiffCapture(adapters.diff); + }, + compare(input: DiffCompareInput): DiffPayload { + return executeDiffCompare(adapters.diff, input); + }, + apply(input: DiffApplyInput, options?: DiffApplyOptions): DiffApplyResult { + return executeDiffApply(adapters.diff, input, options); + }, + }, history: { get(): HistoryState { return executeHistoryGet(adapters.history); diff --git a/packages/document-api/src/invoke/invoke.ts b/packages/document-api/src/invoke/invoke.ts index 1908ac0e40..d83d4ad772 100644 --- a/packages/document-api/src/invoke/invoke.ts +++ b/packages/document-api/src/invoke/invoke.ts @@ -490,5 +490,10 @@ export function buildDispatchTable(api: DocumentApi): TypedDispatchTable { 'authorities.entries.insert': (input, options) => api.authorities.entries.insert(input, options), 'authorities.entries.update': (input, options) => api.authorities.entries.update(input, options), 'authorities.entries.remove': (input, options) => api.authorities.entries.remove(input, options), + + // --- diff.* --- + 'diff.capture': () => api.diff.capture(), + 'diff.compare': (input) => api.diff.compare(input), + 'diff.apply': (input, options) => api.diff.apply(input, options), }; } diff --git a/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts b/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts index 59224db7a3..979b6d33bc 100644 --- a/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts +++ b/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts @@ -1588,7 +1588,11 @@ const STUB_TABLE_OPS: ReadonlySet = new Set([] as OperationId[]); * pattern. mutations.apply returns PlanReceipt (always success: true) or throws. */ const PLAN_ENGINE_META_OPS: ReadonlySet = new Set(['mutations.apply'] as OperationId[]); -const NON_RECEIPT_MUTATION_OPS: ReadonlySet = new Set(['history.undo', 'history.redo'] as OperationId[]); +const NON_RECEIPT_MUTATION_OPS: ReadonlySet = new Set([ + 'history.undo', + 'history.redo', + 'diff.apply', +] as OperationId[]); /** * Content-control operations whose handlers always return `true` because they diff --git a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts index 374929b817..11196fa17a 100644 --- a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts +++ b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts @@ -162,6 +162,7 @@ import { tablesClearDefaultStyleAdapter, } from './tables-adapter.js'; import { createHistoryAdapter } from './history-adapter.js'; +import { createDiffAdapter } from './diff-adapter.js'; import { tocListWrapper, tocGetWrapper, @@ -677,6 +678,7 @@ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters preview: (input) => previewPlan(editor, input), apply: (input) => executePlan(editor, input), }, + diff: createDiffAdapter(editor), history: createHistoryAdapter(editor), }; } diff --git a/packages/super-editor/src/document-api-adapters/diff-adapter.ts b/packages/super-editor/src/document-api-adapters/diff-adapter.ts new file mode 100644 index 0000000000..67e1bd39dd --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/diff-adapter.ts @@ -0,0 +1,64 @@ +/** + * Document API adapter for the diff namespace. + * + * Maps and delegates to the shared diff service in + * `extensions/diffing/service/`. Contains no diff logic of its own. + */ + +import type { + DiffAdapter, + DiffSnapshot, + DiffPayload, + DiffApplyResult, + DiffCompareInput, + DiffApplyInput, + DiffApplyOptions, +} from '@superdoc/document-api'; +import type { Editor } from '../core/Editor.js'; +import { + captureSnapshot, + compareToSnapshot, + applyDiffPayload, + DiffServiceError, +} from '../extensions/diffing/service/index'; +import { DocumentApiAdapterError } from './errors.js'; + +/** + * Creates a DiffAdapter bound to the given editor instance. + */ +export function createDiffAdapter(editor: Editor): DiffAdapter { + return { + capture(): DiffSnapshot { + return wrapServiceCall(() => captureSnapshot(editor)); + }, + + compare(input: DiffCompareInput): DiffPayload { + return wrapServiceCall(() => compareToSnapshot(editor, input.targetSnapshot)); + }, + + apply(input: DiffApplyInput, options?: DiffApplyOptions): DiffApplyResult { + const { result, tr } = wrapServiceCall(() => applyDiffPayload(editor, input.diff, options)); + + if (tr.docChanged) { + editor.dispatch(tr); + } + + editor.emit('commentsUpdate', { type: 'replayCompleted' }); + return result; + }, + }; +} + +/** + * Translates DiffServiceError codes into DocumentApiAdapterError codes. + */ +function wrapServiceCall(fn: () => T): T { + try { + return fn(); + } catch (error) { + if (error instanceof DiffServiceError) { + throw new DocumentApiAdapterError(error.code, error.message); + } + throw error; + } +} diff --git a/packages/super-editor/src/document-api-adapters/errors.ts b/packages/super-editor/src/document-api-adapters/errors.ts index 7cca1fca8d..13d3b7cd5f 100644 --- a/packages/super-editor/src/document-api-adapters/errors.ts +++ b/packages/super-editor/src/document-api-adapters/errors.ts @@ -10,6 +10,8 @@ export type DocumentApiAdapterErrorCode = | 'INVALID_NESTING' | 'INVALID_PLACEMENT' | 'INTERNAL_ERROR' + | 'PRECONDITION_FAILED' + | 'CAPABILITY_UNSUPPORTED' // SDM/1 structural codes | 'ADDRESS_STALE' | 'DUPLICATE_ID' @@ -63,6 +65,8 @@ const ADAPTER_TO_SD_CODE: Record = { INVALID_NESTING: 'INVALID_NESTING', INVALID_PLACEMENT: 'INVALID_PLACEMENT', INTERNAL_ERROR: 'INTERNAL_ERROR', + PRECONDITION_FAILED: 'INVALID_PAYLOAD', + CAPABILITY_UNSUPPORTED: 'CAPABILITY_UNSUPPORTED', ADDRESS_STALE: 'ADDRESS_STALE', DUPLICATE_ID: 'DUPLICATE_ID', INVALID_CONTEXT: 'INVALID_CONTEXT', diff --git a/packages/super-editor/src/extensions/diffing/algorithm/comment-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/comment-diffing.ts index 6a793d3481..f66242d07c 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/comment-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/comment-diffing.ts @@ -90,14 +90,22 @@ export type CommentModifiedDiff = CommentDiffBase<'modified'> & { export type CommentDiff = CommentAddedDiff | CommentDeletedDiff | CommentModifiedDiff; /** - * Comment attributes ignored during metadata diffing. + * Comment attributes ignored during metadata diffing and snapshot canonicalization. * * `trackedChangeParentId` is runtime-coupled to tracked-change mark ids, which - * may be regenerated between imports of the same DOCX. Treating it as a stable - * diffable attribute causes false-positive comment modifications that can break - * thread linkage on replay. + * may be regenerated between imports of the same DOCX. `documentId`, `fileId`, + * and `selection` are non-semantic ownership/runtime fields that must be + * stripped before fingerprinting comment state. */ -const COMMENT_ATTRS_DIFF_IGNORED_KEYS = ['textJson', 'elements', 'commentId', 'trackedChangeParentId']; +export const COMMENT_ATTRS_DIFF_IGNORED_KEYS = [ + 'textJson', + 'elements', + 'commentId', + 'trackedChangeParentId', + 'documentId', + 'fileId', + 'selection', +]; /** * Builds normalized tokens for diffing comment content. diff --git a/packages/super-editor/src/extensions/diffing/replay/replay-comments.test.js b/packages/super-editor/src/extensions/diffing/replay/replay-comments.test.js index dfad8ae8b3..51da501e45 100644 --- a/packages/super-editor/src/extensions/diffing/replay/replay-comments.test.js +++ b/packages/super-editor/src/extensions/diffing/replay/replay-comments.test.js @@ -50,6 +50,96 @@ const testModifiesComment = () => { expect(comments).toEqual([{ commentId: 'c-1', commentText: 'Updated comment', isDone: true }]); }; +/** + * Verifies added comment payloads are cloned before replay stores them. + * @returns {void} + */ +const testClonesAddedCommentPayloads = () => { + const comments = []; + const diff = { + action: 'added', + nodeType: 'comment', + commentId: 'c-1', + commentJSON: { + commentId: 'c-1', + commentText: 'New comment', + textJson: { + type: 'paragraph', + content: [{ type: 'text', text: 'Nested text' }], + }, + }, + text: 'New comment', + }; + + replayComments({ comments, commentDiffs: [diff] }); + + expect(comments[0]).not.toBe(diff.commentJSON); + expect(comments[0].textJson).not.toBe(diff.commentJSON.textJson); + + diff.commentJSON.commentText = 'Mutated'; + diff.commentJSON.textJson.content[0].text = 'Mutated nested'; + + expect(comments).toEqual([ + { + commentId: 'c-1', + commentText: 'New comment', + textJson: { + type: 'paragraph', + content: [{ type: 'text', text: 'Nested text' }], + }, + }, + ]); +}; + +/** + * Verifies modified comment payloads are cloned before replay stores them. + * @returns {void} + */ +const testClonesModifiedCommentPayloads = () => { + const comments = [{ commentId: 'c-1', commentText: 'Old comment' }]; + const diff = { + action: 'modified', + nodeType: 'comment', + commentId: 'c-1', + oldCommentJSON: { commentId: 'c-1', commentText: 'Old comment' }, + newCommentJSON: { + commentId: 'c-1', + commentText: 'Updated comment', + elements: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Updated nested' }], + }, + ], + }, + oldText: 'Old comment', + newText: 'Updated comment', + contentDiff: [], + attrsDiff: null, + }; + + replayComments({ comments, commentDiffs: [diff] }); + + expect(comments[0]).not.toBe(diff.newCommentJSON); + expect(comments[0].elements).not.toBe(diff.newCommentJSON.elements); + + diff.newCommentJSON.commentText = 'Mutated'; + diff.newCommentJSON.elements[0].content[0].text = 'Mutated nested'; + + expect(comments).toEqual([ + { + commentId: 'c-1', + commentText: 'Updated comment', + elements: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Updated nested' }], + }, + ], + }, + ]); +}; + /** * Verifies deleted comment diffs remove by resolved comment id. * @returns {void} @@ -278,10 +368,11 @@ const testIncludesDocumentIdentityFromEditor = () => { }; /** - * Verifies replay keeps existing payload file/document ownership fields. + * Verifies replay always rebinds ownership to the active editor, even when + * the source payload has its own documentId/fileId values. * @returns {void} */ -const testPreservesExistingDocumentIdentity = () => { +const testRebindsDocumentIdentityToActiveEditor = () => { const comments = []; const editor = { emit: vi.fn(), @@ -309,8 +400,8 @@ const testPreservesExistingDocumentIdentity = () => { type: 'add', comment: expect.objectContaining({ commentId: 'external-owned', - documentId: 'doc-9', - fileId: 'doc-9', + documentId: 'doc-2', + fileId: 'doc-2', }), }), ); @@ -323,13 +414,15 @@ const testPreservesExistingDocumentIdentity = () => { const runReplayCommentsSuite = () => { it('adds comments from added diffs', testAddsComment); it('replaces comments from modified diffs', testModifiesComment); + it('clones added comment payloads before storing them', testClonesAddedCommentPayloads); + it('clones modified comment payloads before storing them', testClonesModifiedCommentPayloads); it('deletes comments by resolved id', testDeletesCommentByResolvedId); it('skips missing modified comments with warnings', testSkipsMissingModifiedComment); it('aggregates results across multiple diffs', testAggregatesMultipleDiffs); it('emits commentsUpdate events for replayed diffs', testEmitsCommentsUpdateEvents); it('derives comment text from elements for replayed additions', testDerivesCommentTextFromElements); it('includes replay comment ownership metadata from editor document', testIncludesDocumentIdentityFromEditor); - it('preserves replay comment ownership metadata when already present', testPreservesExistingDocumentIdentity); + it('rebinds replay comment ownership to the active editor document', testRebindsDocumentIdentityToActiveEditor); }; describe('replayComments', runReplayCommentsSuite); diff --git a/packages/super-editor/src/extensions/diffing/replay/replay-comments.ts b/packages/super-editor/src/extensions/diffing/replay/replay-comments.ts index ecba4145ca..0ff70a706c 100644 --- a/packages/super-editor/src/extensions/diffing/replay/replay-comments.ts +++ b/packages/super-editor/src/extensions/diffing/replay/replay-comments.ts @@ -23,6 +23,21 @@ type ReplayEditor = { }; }; +/** + * Creates an editor-owned copy of a comment payload before replay stores or emits it. + * + * Comment payloads come from opaque diff input, so replay must not retain the + * caller's object references. + * + * @param comment Source comment payload. + * @returns Deep-cloned comment payload. + */ +function cloneCommentPayload( + comment: import('../algorithm/comment-diffing').CommentInput, +): import('../algorithm/comment-diffing').CommentInput { + return structuredClone(comment); +} + /** * Builds a replay event payload with stable id and document ownership metadata. * @@ -40,25 +55,20 @@ function buildReplayCommentEventPayload({ commentId: string; editor?: ReplayEditor; }): import('../algorithm/comment-diffing').CommentInput { - const payload = { - ...comment, - }; + const payload = cloneCommentPayload(comment); if (!payload.commentId) { payload.commentId = commentId; } const editorDocumentId = editor?.options?.documentId != null ? String(editor.options.documentId) : null; - const payloadDocumentId = payload.documentId != null ? String(payload.documentId) : null; - const payloadFileId = payload.fileId != null ? String(payload.fileId) : null; - const documentId = payloadDocumentId ?? editorDocumentId; - const fileId = payloadFileId ?? documentId ?? editorDocumentId; - if (!payload.documentId && documentId) { - payload.documentId = documentId; - } - if (!payload.fileId && fileId) { - payload.fileId = fileId; + // Always rebind ownership to the active editor's document scope. + // Source-editor values must not leak into the replay target — the active + // editor is the authoritative owner of replayed comments. + if (editorDocumentId) { + payload.documentId = editorDocumentId; + payload.fileId = editorDocumentId; } return payload; @@ -114,14 +124,15 @@ function replayCommentDiff({ return result; } - comments.push(diff.commentJSON); + const storedComment = cloneCommentPayload(diff.commentJSON); + comments.push(storedComment); result.applied += 1; const payload = buildReplayCommentEventPayload({ - comment: diff.commentJSON, + comment: storedComment, commentId: diff.commentId, editor, }); - const resolvedText = resolveCommentTextPayload({ comment: diff.commentJSON, fallbackText: diff.text }); + const resolvedText = resolveCommentTextPayload({ comment: storedComment, fallbackText: diff.text }); if (!payload.commentText && resolvedText) { payload.commentText = resolvedText; } @@ -160,14 +171,15 @@ function replayCommentDiff({ return result; } - comments.splice(existingIndex, 1, diff.newCommentJSON); + const storedComment = cloneCommentPayload(diff.newCommentJSON); + comments.splice(existingIndex, 1, storedComment); result.applied += 1; const payload = buildReplayCommentEventPayload({ - comment: diff.newCommentJSON, + comment: storedComment, commentId: diff.commentId, editor, }); - const resolvedText = resolveCommentTextPayload({ comment: diff.newCommentJSON, fallbackText: diff.newText }); + const resolvedText = resolveCommentTextPayload({ comment: storedComment, fallbackText: diff.newText }); if (!payload.commentText && resolvedText) { payload.commentText = resolvedText; } diff --git a/packages/super-editor/src/extensions/diffing/replayDiffs.test.js b/packages/super-editor/src/extensions/diffing/replayDiffs.test.js index da49c016b3..8064eba23a 100644 --- a/packages/super-editor/src/extensions/diffing/replayDiffs.test.js +++ b/packages/super-editor/src/extensions/diffing/replayDiffs.test.js @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { Editor } from '@core/Editor.js'; +import { BLANK_DOCX_BASE64 } from '@core/blank-docx.js'; import { getStarterExtensions } from '@extensions/index.js'; import { getTrackChanges } from '@extensions/track-changes/trackChangesHelpers/getTrackChanges.js'; import { getTestDataAsBuffer } from '@tests/export/export-helpers/export-helpers.js'; @@ -433,6 +434,36 @@ describe('replayDiffs tracked-change ids', () => { await expectTrackedReplayMarksHaveIds('diff_before8.docx', 'diff_after8.docx'); }); }); +describe('replayDiffs tracked append regression', () => { + it('tracks appended text in a simple paragraph diff', async () => { + const user = { name: 'Test User', email: 'test@example.com' }; + const openBlankDocx = async (text) => { + const editor = await Editor.open(Buffer.from(BLANK_DOCX_BASE64, 'base64'), { + isHeadless: true, + extensions: getStarterExtensions(), + user, + }); + editor.dispatch(editor.state.tr.insertText(text, 1)); + return editor; + }; + const beforeEditor = await openBlankDocx('Section 1. Payment is due within thirty days.'); + const afterEditor = await openBlankDocx( + 'Section 1. Payment is due within thirty days. Renewal requires written approval.', + ); + + try { + const diff = beforeEditor.commands.compareDocuments(afterEditor.state.doc, afterEditor.converter?.comments ?? []); + const success = beforeEditor.commands.replayDifferences(diff, { applyTrackedChanges: true }); + + expect(success).toBe(true); + expect(beforeEditor.state.doc.textContent).toBe(afterEditor.state.doc.textContent); + expect(getTrackChanges(beforeEditor.state).length).toBeGreaterThan(0); + } finally { + beforeEditor.destroy?.(); + afterEditor.destroy?.(); + } + }); +}); describe('replayDiffs table style', () => { it('replays table style changes when tracked replay is enabled', async () => { await expectReplayPreservesTableStyle('diff_before16.docx', 'diff_after16.docx', true); diff --git a/packages/super-editor/src/extensions/diffing/service/canonicalize.ts b/packages/super-editor/src/extensions/diffing/service/canonicalize.ts new file mode 100644 index 0000000000..454229e415 --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/service/canonicalize.ts @@ -0,0 +1,97 @@ +/** + * Canonical representation of diffable document state. + * + * All fingerprinting and snapshot capture flows through this module to ensure + * a single source of truth for what "diffable state" means. + */ + +import type { Node as PMNode } from 'prosemirror-model'; +import type { NumberingProperties, StylesDocumentProperties } from '@superdoc/style-engine/ooxml'; +import type { CommentInput } from '../algorithm/comment-diffing'; +import { COMMENT_ATTRS_DIFF_IGNORED_KEYS } from '../algorithm/comment-diffing'; + +/** The canonical diffable state of one document. */ +export interface CanonicalDiffableState { + body: Record; + comments: Record[]; + styles: Record | null; + numbering: Record | null; +} + +/** + * Keys stripped from the canonical comment representation. + * + * The diffing algorithm strips `textJson` and `elements` from *attribute* + * comparison because body content is compared structurally via + * `tokenizeCommentText`. The fingerprint, however, must still cover body + * content — otherwise an external snapshot can tamper with comment bodies + * without changing the fingerprint. + * + * We therefore re-use the diff ignored-key list but explicitly keep + * `textJson`, `elements`, and `commentId` in the canonical output. + */ +const CANONICAL_COMMENT_IGNORED_KEYS = COMMENT_ATTRS_DIFF_IGNORED_KEYS.filter( + (key) => key !== 'textJson' && key !== 'elements' && key !== 'commentId', +); + +/** + * Strips non-semantic ownership fields from a comment for canonical + * representation while preserving body-content fields (`textJson`, + * `elements`) and identity fields (`commentId`) so they are covered by + * the fingerprint. + */ +function canonicalizeComment(comment: CommentInput): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(comment)) { + if (!CANONICAL_COMMENT_IGNORED_KEYS.includes(key)) { + result[key] = value; + } + } + return result; +} + +/** + * Builds the canonical diffable state from raw editor data. + * + * This is the single source of truth for what parts of a document are + * considered during fingerprinting and diffing. + */ +export function buildCanonicalDiffableState( + doc: PMNode, + comments: CommentInput[], + styles: StylesDocumentProperties | null | undefined, + numbering: NumberingProperties | null | undefined, +): CanonicalDiffableState { + return { + body: doc.toJSON() as Record, + comments: comments.map(canonicalizeComment), + styles: styles ? (styles as unknown as Record) : null, + numbering: numbering ? (numbering as unknown as Record) : null, + }; +} + +/** + * Recursively sorts object keys for stable serialization. + * Arrays are preserved in order; only object key ordering is normalized. + */ +function sortKeysDeep(value: unknown): unknown { + if (value === null || value === undefined) return value; + if (Array.isArray(value)) return value.map(sortKeysDeep); + if (typeof value === 'object') { + const sorted: Record = {}; + for (const key of Object.keys(value as Record).sort()) { + sorted[key] = sortKeysDeep((value as Record)[key]); + } + return sorted; + } + return value; +} + +/** + * Produces a stable JSON string from a canonical diffable state. + * Key ordering is recursively normalized so that equivalent states + * always produce identical strings regardless of insertion order. + */ +export function stableStringify(state: CanonicalDiffableState): string { + return JSON.stringify(sortKeysDeep(state)); +} diff --git a/packages/super-editor/src/extensions/diffing/service/coverage.ts b/packages/super-editor/src/extensions/diffing/service/coverage.ts new file mode 100644 index 0000000000..a0deedf4de --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/service/coverage.ts @@ -0,0 +1,30 @@ +/** + * Coverage metadata for the diff engine. + * + * v1 always covers body, comments, styles, and numbering. + * Header/footer diffing is not supported in v1. + */ + +import type { DiffCoverage } from '@superdoc/document-api'; + +/** Default v1 coverage — all supported components enabled. */ +export const V1_COVERAGE: DiffCoverage = Object.freeze({ + body: true, + comments: true, + styles: true, + numbering: true, + headerFooters: false, +}); + +/** + * Returns true when two coverage objects are structurally equal. + */ +export function coverageEquals(a: DiffCoverage, b: DiffCoverage): boolean { + return ( + a.body === b.body && + a.comments === b.comments && + a.styles === b.styles && + a.numbering === b.numbering && + a.headerFooters === b.headerFooters + ); +} diff --git a/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts b/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts new file mode 100644 index 0000000000..eaeeb1853d --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest'; + +import { Editor } from '@core/Editor.js'; +import { BLANK_DOCX_BASE64 } from '@core/blank-docx.js'; +import { getStarterExtensions } from '@extensions/index.js'; +import { getTrackChanges } from '@extensions/track-changes/trackChangesHelpers/getTrackChanges.js'; +import type { CommentInput } from '../algorithm/comment-diffing.ts'; +import { applyDiffPayload, captureSnapshot, compareToSnapshot } from './index.ts'; + +const TEST_USER = { name: 'Test User', email: 'test@example.com' }; + +type MutableCommentPayload = { + commentText: string; + textJson: { + content: Array<{ text: string }>; + }; +}; + +type ModifiedCommentDiffPayload = { + action: string; + oldCommentJSON: MutableCommentPayload; + newCommentJSON: MutableCommentPayload; +}; + +function buildCommentTextJson(text: string): Record { + return { + type: 'paragraph', + content: [{ type: 'text', text }], + }; +} + +function setEditorComments(editor: Editor, comments: CommentInput[]): void { + if (!editor.converter) { + throw new Error('Expected editor converter to be initialized.'); + } + editor.converter.comments = comments; +} + +async function openBlankDocxWithText(text: string): Promise { + const editor = await Editor.open(Buffer.from(BLANK_DOCX_BASE64, 'base64'), { + isHeadless: true, + extensions: getStarterExtensions(), + user: TEST_USER, + }); + editor.dispatch(editor.state.tr.insertText(text, 1)); + return editor; +} + +describe('diff-service tracked apply', () => { + it('applies appended text as tracked changes', async () => { + const baseEditor = await openBlankDocxWithText('Section 1. Payment is due within thirty days.'); + const targetEditor = await openBlankDocxWithText( + 'Section 1. Payment is due within thirty days. Renewal requires written approval.', + ); + + try { + const snapshot = captureSnapshot(targetEditor); + const diff = compareToSnapshot(baseEditor, snapshot); + const { tr } = applyDiffPayload(baseEditor, diff, { changeMode: 'tracked' }); + + baseEditor.dispatch(tr); + + expect(baseEditor.state.doc.textContent).toBe(targetEditor.state.doc.textContent); + expect(getTrackChanges(baseEditor.state).length).toBeGreaterThan(0); + } finally { + baseEditor.destroy?.(); + targetEditor.destroy?.(); + } + }); + + it('rejects snapshots whose comment identity was tampered after capture', async () => { + const baseEditor = await openBlankDocxWithText('Base document.'); + const targetEditor = await openBlankDocxWithText('Base document.'); + + try { + setEditorComments(targetEditor, [ + { + commentId: 'c-1', + commentText: 'Identity comment', + textJson: buildCommentTextJson('Identity comment'), + }, + ]); + + const snapshot = captureSnapshot(targetEditor); + const snapshotComments = snapshot.payload.comments as Array>; + snapshotComments[0]!.commentId = 'c-2'; + + expect(() => compareToSnapshot(baseEditor, snapshot)).toThrowError( + /fingerprint does not match re-derived value/i, + ); + } finally { + baseEditor.destroy?.(); + targetEditor.destroy?.(); + } + }); + + it('returns comment diffs detached from base comments and target snapshot payloads', async () => { + const baseEditor = await openBlankDocxWithText('Base document.'); + const targetEditor = await openBlankDocxWithText('Base document.'); + + try { + setEditorComments(baseEditor, [ + { + commentId: 'c-1', + commentText: 'Old comment', + textJson: buildCommentTextJson('Old nested'), + }, + ]); + setEditorComments(targetEditor, [ + { + commentId: 'c-1', + commentText: 'New comment', + textJson: buildCommentTextJson('New nested'), + }, + ]); + + const snapshot = captureSnapshot(targetEditor); + const diff = compareToSnapshot(baseEditor, snapshot); + const commentDiffs = (diff.payload.commentDiffs ?? []) as ModifiedCommentDiffPayload[]; + + expect(commentDiffs).toHaveLength(1); + expect(commentDiffs[0]?.action).toBe('modified'); + + const modifiedDiff = commentDiffs[0]!; + modifiedDiff.oldCommentJSON.commentText = 'Tampered old'; + modifiedDiff.oldCommentJSON.textJson.content[0].text = 'Tampered old nested'; + modifiedDiff.newCommentJSON.commentText = 'Tampered new'; + modifiedDiff.newCommentJSON.textJson.content[0].text = 'Tampered new nested'; + + expect(baseEditor.converter?.comments?.[0]).toMatchObject({ + commentId: 'c-1', + commentText: 'Old comment', + textJson: buildCommentTextJson('Old nested'), + }); + expect((snapshot.payload.comments as Array>)[0]).toMatchObject({ + commentId: 'c-1', + commentText: 'New comment', + textJson: buildCommentTextJson('New nested'), + }); + } finally { + baseEditor.destroy?.(); + targetEditor.destroy?.(); + } + }); +}); diff --git a/packages/super-editor/src/extensions/diffing/service/diff-service.ts b/packages/super-editor/src/extensions/diffing/service/diff-service.ts new file mode 100644 index 0000000000..1eaa973d1f --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/service/diff-service.ts @@ -0,0 +1,639 @@ +/** + * Shared diff service — the single source of truth for snapshot capture, + * comparison, and replay. + * + * Both the existing editor commands (diffing.js) and the Document API adapter + * (diff-adapter.ts) delegate to this module. No diff logic should be + * duplicated outside of this service. + */ + +import type { Node as PMNode, Schema } from 'prosemirror-model'; +import type { Transaction } from 'prosemirror-state'; +import type { NumberingProperties, StylesDocumentProperties } from '@superdoc/style-engine/ooxml'; +import type { DiffSnapshot, DiffPayload, DiffApplyResult, DiffCoverage } from '@superdoc/document-api'; +import type { CommentInput } from '../algorithm/comment-diffing'; +import type { DiffResult } from '../computeDiff'; +import { computeDiff } from '../computeDiff'; +import { replayDiffs, type ReplayDiffsResult } from '../replayDiffs'; +import { buildCanonicalDiffableState } from './canonicalize'; +import { computeFingerprint } from './fingerprint'; +import { buildDiffSummary } from './summary'; +import { V1_COVERAGE, coverageEquals } from './coverage'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const SNAPSHOT_VERSION = 'sd-diff-snapshot/v1' as const; +const PAYLOAD_VERSION = 'sd-diff-payload/v1' as const; +const ENGINE_ID = 'super-editor' as const; + +// --------------------------------------------------------------------------- +// Editor shape (minimal interface to avoid tight coupling) +// --------------------------------------------------------------------------- + +export interface DiffServiceEditor { + state: { doc: PMNode; schema: Schema; tr: Transaction }; + converter?: { + comments?: CommentInput[]; + translatedLinkedStyles?: StylesDocumentProperties | null; + translatedNumbering?: NumberingProperties | null; + } | null; + emit?: (event: string, payload: unknown) => void; + options?: { + documentId?: string | null; + user?: unknown; + }; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function getEditorComments(editor: DiffServiceEditor): CommentInput[] { + return Array.isArray(editor.converter?.comments) ? editor.converter!.comments! : []; +} + +function getEditorStyles(editor: DiffServiceEditor): StylesDocumentProperties | null { + return editor.converter?.translatedLinkedStyles ?? null; +} + +function getEditorNumbering(editor: DiffServiceEditor): NumberingProperties | null { + return editor.converter?.translatedNumbering ?? null; +} + +// --------------------------------------------------------------------------- +// Capture +// --------------------------------------------------------------------------- + +/** + * Captures the current editor's diffable state as a versioned snapshot. + * + * The payload stores **raw** document data (full comments with identity and + * body fields intact) so that `compareToSnapshot` can feed them into + * `computeDiff` → `diffComments` which needs `commentId`, `textJson`, and + * `elements`. Canonicalization is used only for fingerprinting. + */ +export function captureSnapshot(editor: DiffServiceEditor): DiffSnapshot { + const doc = editor.state.doc; + const comments = getEditorComments(editor); + const styles = getEditorStyles(editor); + const numbering = getEditorNumbering(editor); + + const canonical = buildCanonicalDiffableState(doc, comments, styles, numbering); + const fingerprint = computeFingerprint(canonical); + + return { + version: SNAPSHOT_VERSION, + engine: ENGINE_ID, + fingerprint, + coverage: { ...V1_COVERAGE }, + // Deep-clone every slot so the snapshot is immutable. doc.toJSON() + // already returns a fresh tree; the rest are live references that would + // drift if the editor keeps mutating after capture. + payload: structuredClone({ + doc: doc.toJSON() as Record, + comments: comments as unknown as Record[], + styles: styles as unknown as Record | null, + numbering: numbering as unknown as Record | null, + }), + }; +} + +// --------------------------------------------------------------------------- +// Compare +// --------------------------------------------------------------------------- + +/** + * Compares the current editor (base) against a target snapshot. + * Returns a versioned diff payload. + */ +export function compareToSnapshot(editor: DiffServiceEditor, targetSnapshot: DiffSnapshot): DiffPayload { + validateEngine(targetSnapshot.engine); + validateSnapshotVersion(targetSnapshot.version); + + const targetCoverage = targetSnapshot.coverage; + validateCoverageMatch(V1_COVERAGE, targetCoverage); + + // Structurally validate payload slots before use — the payload is opaque + // and may have been deserialized from external JSON. + validateSnapshotPayload(targetSnapshot.payload); + + const targetComments = (targetSnapshot.payload.comments ?? []) as CommentInput[]; + const targetStyles = targetSnapshot.payload.styles as StylesDocumentProperties | null; + const targetNumbering = targetSnapshot.payload.numbering as NumberingProperties | null; + const targetDoc = parseDocPayload(editor.state.schema, targetSnapshot.payload.doc); + + // Re-derive target fingerprint from payload to guard against tampered wrappers. + // Wrap in try-catch so malformed nested data (e.g. comment body nodes that + // pass structural validation but fail during canonicalization) surfaces as + // INVALID_INPUT rather than a raw TypeError. + let reDerivedFingerprint: string; + try { + const targetCanonical = buildCanonicalDiffableState(targetDoc, targetComments, targetStyles, targetNumbering); + reDerivedFingerprint = computeFingerprint(targetCanonical); + } catch (err) { + if (err instanceof DiffServiceError) throw err; + throw new DiffServiceError( + 'INVALID_INPUT', + `Snapshot payload contains malformed data that failed during canonicalization: ${err instanceof Error ? err.message : String(err)}`, + ); + } + if (reDerivedFingerprint !== targetSnapshot.fingerprint) { + throw new DiffServiceError( + 'INVALID_INPUT', + `Target snapshot fingerprint does not match re-derived value. The snapshot may have been tampered with.`, + ); + } + + // Compute base fingerprint + const baseDoc = editor.state.doc; + const baseComments = getEditorComments(editor); + const baseStyles = getEditorStyles(editor); + const baseNumbering = getEditorNumbering(editor); + const baseCanonical = buildCanonicalDiffableState(baseDoc, baseComments, baseStyles, baseNumbering); + const baseFingerprint = computeFingerprint(baseCanonical); + + // Compute raw diff. Wrap in try-catch so malformed nested comment bodies + // (e.g. textJson that passes structural validation but fails inside + // schema.nodeFromJSON during tokenizeCommentText) surface as INVALID_INPUT. + let rawDiff: DiffResult; + try { + rawDiff = computeDiff( + baseDoc, + targetDoc, + editor.state.schema, + baseComments, + targetComments, + baseStyles, + targetStyles, + baseNumbering, + targetNumbering, + ); + } catch (err) { + if (err instanceof DiffServiceError) throw err; + throw new DiffServiceError( + 'INVALID_INPUT', + `Snapshot payload contains data that failed during diff computation: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + const summary = buildDiffSummary(rawDiff); + const payload = structuredClone({ + docDiffs: rawDiff.docDiffs as unknown as Record[], + commentDiffs: rawDiff.commentDiffs as unknown as Record[], + stylesDiff: rawDiff.stylesDiff as unknown as Record | null, + numberingDiff: rawDiff.numberingDiff as unknown as Record | null, + }) as Record; + + return { + version: PAYLOAD_VERSION, + engine: ENGINE_ID, + baseFingerprint, + targetFingerprint: targetSnapshot.fingerprint, + coverage: { ...V1_COVERAGE }, + summary, + // Detach the payload from editor-owned objects before returning it across + // the API boundary. Comment diffs can otherwise retain live comment refs. + payload, + }; +} + +// --------------------------------------------------------------------------- +// Apply +// --------------------------------------------------------------------------- + +export interface ApplyOptions { + changeMode?: 'direct' | 'tracked'; +} + +/** Returned by applyDiffPayload — includes the transaction for the caller to dispatch. */ +export interface ApplyDiffResult { + result: DiffApplyResult; + tr: Transaction; +} + +/** + * Applies a previously computed diff payload against the current editor. + * + * Returns both the public result and the PM transaction. The caller (adapter + * or editor command) is responsible for dispatching the transaction. + */ +export function applyDiffPayload( + editor: DiffServiceEditor, + diffPayload: DiffPayload, + options?: ApplyOptions, +): ApplyDiffResult { + validateEngine(diffPayload.engine); + validatePayloadVersion(diffPayload.version); + + // Verify base fingerprint matches current document + const baseDoc = editor.state.doc; + const baseComments = getEditorComments(editor); + const baseStyles = getEditorStyles(editor); + const baseNumbering = getEditorNumbering(editor); + const baseCanonical = buildCanonicalDiffableState(baseDoc, baseComments, baseStyles, baseNumbering); + const currentFingerprint = computeFingerprint(baseCanonical); + + if (currentFingerprint !== diffPayload.baseFingerprint) { + throw new DiffServiceError( + 'PRECONDITION_FAILED', + `Document fingerprint mismatch. Expected "${diffPayload.baseFingerprint}", got "${currentFingerprint}". ` + + `The document may have changed since the diff was computed. Re-run diff.compare against the current state.`, + ); + } + + // Reconstruct internal DiffResult from opaque payload with structural validation + const rawDiff = parseDiffPayloadContents(diffPayload.payload); + + // Read the existing comments array without mutating the real editor. + // If the converter exists but has no comments store, we use an empty + // array for staging; the real store is only created on commit. + const comments = editor.converter ? (Array.isArray(editor.converter.comments) ? editor.converter.comments : []) : []; + + const trackedRequested = options?.changeMode === 'tracked'; + const trackedAvailable = Boolean(editor.options?.user); + + // Reject explicitly when tracked mode is requested but unavailable. + // Other tracked-capable mutations follow the same gate; silently + // degrading to direct apply would break the "Doc1 + Doc2 → Doc3 with + // tracked changes" workflow. + if (trackedRequested && !trackedAvailable) { + throw new DiffServiceError( + 'CAPABILITY_UNAVAILABLE', + 'Tracked change mode was requested but is not available. ' + + 'A user identity must be configured on the editor to enable tracked changes.', + ); + } + + const tr = editor.state.tr; + + // Replay against a staging editor so the real editor is never mutated + // unless every operation succeeds. replayDiffs mutates comments, + // converter state (styles, numbering, convertedXml, documentModified), + // and emits UI events as side-effects outside the PM transaction. + // A staging wrapper isolates all of that: events are buffered, converter + // data is cloned, and the real editor is only touched on commit. + const { staging, stagedComments, commit } = createStagingEditor(editor, comments); + + const replayResult: ReplayDiffsResult = replayDiffs({ + tr, + diff: rawDiff, + schema: editor.state.schema, + comments: stagedComments, + editor: staging as unknown as Parameters[0]['editor'], + }); + + tr.setMeta('inputType', 'programmatic'); + + if (trackedRequested) { + tr.setMeta('forceTrackChanges', true); + } else { + tr.setMeta('skipTrackChanges', true); + } + + // Reject if any operations were skipped — staging editor absorbed all + // side-effects so the real editor remains untouched. + if (replayResult.skippedDiffs > 0) { + throw new DiffServiceError( + 'INTERNAL_ERROR', + `Diff apply failed: ${replayResult.skippedDiffs} operations skipped. ` + + `Warnings: ${replayResult.warnings.join('; ')}`, + ); + } + + // All operations succeeded — commit staged state to real editor. + commit(); + + // Re-derive summary from the actual diff data rather than trusting the + // caller-supplied wrapper, which could be tampered. + const verifiedSummary = buildDiffSummary(rawDiff); + + return { + result: { + appliedOperations: replayResult.appliedDiffs, + baseFingerprint: diffPayload.baseFingerprint, + targetFingerprint: diffPayload.targetFingerprint, + coverage: { ...V1_COVERAGE }, + summary: verifiedSummary, + diagnostics: replayResult.warnings, + }, + tr, + }; +} + +// --------------------------------------------------------------------------- +// Staging editor for atomic apply +// --------------------------------------------------------------------------- + +/** + * Converter properties that replay mutates as side-effects. Only these + * are deep-cloned for staging; everything else is passed through as a + * read-only reference so non-cloneable live objects (mockWindow, + * mockDocument, sub-editor entries, etc.) never hit structuredClone. + */ +const STAGED_CONVERTER_KEYS = [ + 'translatedLinkedStyles', + 'translatedNumbering', + 'convertedXml', + 'numbering', + // promoteToGuid() mutates these on the converter during style/numbering + // replay; they must be committed back since Editor.dispatch() only calls + // promoteToGuid for body-changing transactions (tr.docChanged). + 'documentGuid', + 'documentUniqueIdentifier', +] as const; + +/** + * Creates a staging wrapper around the real editor so `replayDiffs` can run + * without mutating any live state. + * + * - Events are buffered in a pending list instead of being emitted. + * - Only the specific converter fields that replay mutates are deep-cloned; + * all other properties (including non-cloneable live objects) are shared + * read-only with the real converter via prototype delegation. + * - The comments array is independently cloned. + * + * Call `commit()` after a successful replay to copy staged state back to the + * real editor and flush buffered events. On failure, simply discard the + * staging wrapper — the real editor is untouched. + */ +function createStagingEditor( + editor: DiffServiceEditor, + comments: CommentInput[], +): { staging: DiffServiceEditor; stagedComments: CommentInput[]; commit: () => void } { + const pendingEvents: Array<[string, unknown]> = []; + const stagedComments = comments.map((c) => ({ ...c })); + + // Build a staging converter that inherits non-mutable properties from + // the real converter via Object.create, then deep-clones only the + // fields that replay is known to mutate. + let stagedConverter: DiffServiceEditor['converter'] = null; + if (editor.converter) { + const raw = editor.converter as Record; + const cloned = Object.create(raw) as Record; + + for (const key of STAGED_CONVERTER_KEYS) { + const val = raw[key]; + if (val !== null && val !== undefined && typeof val === 'object') { + cloned[key] = structuredClone(val); + } + } + + // Replay also sets documentModified (primitive) — seed from current value + cloned.documentModified = raw.documentModified; + // Point cloned converter's comments at the staged array + cloned.comments = stagedComments; + + stagedConverter = cloned as DiffServiceEditor['converter']; + } + + const staging: DiffServiceEditor = { + state: editor.state, + emit: (event: string, payload: unknown) => { + pendingEvents.push([event, payload]); + }, + options: editor.options, + converter: stagedConverter, + }; + + function commit() { + // Copy staged mutable fields back to the real converter + if (editor.converter && stagedConverter) { + const realRaw = editor.converter as Record; + const stagedRaw = stagedConverter as Record; + + for (const key of STAGED_CONVERTER_KEYS) { + realRaw[key] = stagedRaw[key]; + } + realRaw.documentModified = stagedRaw.documentModified; + } + + // Apply comment mutations to the real array. Deep-clone each entry + // so the editor owns its comment objects outright — without this, + // commentJSON / newCommentJSON references from the caller's diff + // payload would leak into editor.converter.comments, allowing + // post-return mutation of editor state. If the real editor had no + // comments store, create one now (deferred from applyDiffPayload to + // avoid mutating the real editor before staging succeeds). + const ownedComments = stagedComments.map((c) => structuredClone(c)); + if (editor.converter) { + if (Array.isArray(editor.converter.comments)) { + editor.converter.comments.length = 0; + editor.converter.comments.push(...ownedComments); + } else { + editor.converter.comments = ownedComments; + } + } + + // Flush buffered events to the real editor + for (const [event, payload] of pendingEvents) { + editor.emit?.(event, payload); + } + } + + return { staging, stagedComments, commit }; +} + +// --------------------------------------------------------------------------- +// Payload parsing and validation +// --------------------------------------------------------------------------- + +/** + * Parses and validates a doc payload from a snapshot, turning raw JSON into + * a ProseMirror node. Wraps `schema.nodeFromJSON` so malformed payloads + * produce a documented `INVALID_INPUT` error instead of a raw engine exception. + */ +function parseDocPayload(schema: Schema, doc: unknown): PMNode { + if (doc === null || doc === undefined || typeof doc !== 'object' || Array.isArray(doc)) { + throw new DiffServiceError('INVALID_INPUT', 'Snapshot payload.doc must be a valid document object.'); + } + try { + return schema.nodeFromJSON(doc); + } catch (err) { + throw new DiffServiceError( + 'INVALID_INPUT', + `Snapshot payload.doc is not a valid ProseMirror document: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} + +/** + * Parses and validates the opaque payload of a DiffPayload, returning a + * typed DiffResult. Ensures the required array/object slots exist and are + * the expected types so the replay layer never receives structurally invalid data. + */ +function parseDiffPayloadContents(payload: Record): DiffResult { + const docDiffs = payload.docDiffs; + const commentDiffs = payload.commentDiffs; + const stylesDiff = payload.stylesDiff; + const numberingDiff = payload.numberingDiff; + + if (!Array.isArray(docDiffs)) { + throw new DiffServiceError('INVALID_INPUT', 'Diff payload.docDiffs must be an array.'); + } + if (!Array.isArray(commentDiffs)) { + throw new DiffServiceError('INVALID_INPUT', 'Diff payload.commentDiffs must be an array.'); + } + for (let i = 0; i < commentDiffs.length; i++) { + validateCommentDiffEntry(commentDiffs[i], i); + } + if ( + stylesDiff !== null && + stylesDiff !== undefined && + (typeof stylesDiff !== 'object' || Array.isArray(stylesDiff)) + ) { + throw new DiffServiceError('INVALID_INPUT', 'Diff payload.stylesDiff must be a plain object or null.'); + } + if ( + numberingDiff !== null && + numberingDiff !== undefined && + (typeof numberingDiff !== 'object' || Array.isArray(numberingDiff)) + ) { + throw new DiffServiceError('INVALID_INPUT', 'Diff payload.numberingDiff must be a plain object or null.'); + } + + // Deep-clone commentDiffs so replay never holds references to caller-owned + // objects. Without this, commentJSON/newCommentJSON pushed into + // editor.converter.comments would be the same object references from the + // input payload, allowing post-return mutation of editor state. + return { + docDiffs: docDiffs as DiffResult['docDiffs'], + commentDiffs: structuredClone(commentDiffs) as DiffResult['commentDiffs'], + stylesDiff: (stylesDiff ?? null) as DiffResult['stylesDiff'], + numberingDiff: (numberingDiff ?? null) as DiffResult['numberingDiff'], + }; +} + +// --------------------------------------------------------------------------- +// Validation helpers +// --------------------------------------------------------------------------- + +function isNonNullObject(v: unknown): v is Record { + return v !== null && v !== undefined && typeof v === 'object' && !Array.isArray(v); +} + +function validateCommentDiffEntry(entry: unknown, index: number): void { + if (!isNonNullObject(entry)) { + throw new DiffServiceError('INVALID_INPUT', `Diff payload.commentDiffs[${index}] must be a non-null object.`); + } + const { action, commentId, nodeType } = entry as Record; + if (nodeType !== 'comment') { + throw new DiffServiceError('INVALID_INPUT', `Diff payload.commentDiffs[${index}].nodeType must be 'comment'.`); + } + if (typeof commentId !== 'string') { + throw new DiffServiceError('INVALID_INPUT', `Diff payload.commentDiffs[${index}].commentId must be a string.`); + } + if (action === 'added') { + if (!isNonNullObject((entry as Record).commentJSON)) { + throw new DiffServiceError( + 'INVALID_INPUT', + `Diff payload.commentDiffs[${index}].commentJSON must be a non-null object for 'added' entries.`, + ); + } + } else if (action === 'deleted') { + if (!isNonNullObject((entry as Record).commentJSON)) { + throw new DiffServiceError( + 'INVALID_INPUT', + `Diff payload.commentDiffs[${index}].commentJSON must be a non-null object for 'deleted' entries.`, + ); + } + } else if (action === 'modified') { + if (!isNonNullObject((entry as Record).newCommentJSON)) { + throw new DiffServiceError( + 'INVALID_INPUT', + `Diff payload.commentDiffs[${index}].newCommentJSON must be a non-null object for 'modified' entries.`, + ); + } + } else { + throw new DiffServiceError( + 'INVALID_INPUT', + `Diff payload.commentDiffs[${index}].action must be 'added', 'deleted', or 'modified'.`, + ); + } +} + +function validateEngine(engine: string): void { + if (engine !== ENGINE_ID) { + throw new DiffServiceError( + 'INVALID_INPUT', + `Unsupported engine "${engine}". This adapter only supports "${ENGINE_ID}".`, + ); + } +} + +function validateSnapshotVersion(version: string): void { + if (version !== SNAPSHOT_VERSION) { + throw new DiffServiceError( + 'CAPABILITY_UNSUPPORTED', + `Unsupported snapshot version "${version}". Expected "${SNAPSHOT_VERSION}".`, + ); + } +} + +function validatePayloadVersion(version: string): void { + if (version !== PAYLOAD_VERSION) { + throw new DiffServiceError( + 'CAPABILITY_UNSUPPORTED', + `Unsupported diff version "${version}". Expected "${PAYLOAD_VERSION}".`, + ); + } +} + +function validateSnapshotPayload(payload: Record): void { + if (payload.comments !== null && payload.comments !== undefined) { + if (!Array.isArray(payload.comments)) { + throw new DiffServiceError('INVALID_INPUT', 'Snapshot payload.comments must be an array or null.'); + } + for (let i = 0; i < payload.comments.length; i++) { + const entry = payload.comments[i]; + if (entry === null || entry === undefined || typeof entry !== 'object' || Array.isArray(entry)) { + throw new DiffServiceError('INVALID_INPUT', `Snapshot payload.comments[${i}] must be a non-null object.`); + } + } + } + if ( + payload.styles !== null && + payload.styles !== undefined && + (typeof payload.styles !== 'object' || Array.isArray(payload.styles)) + ) { + throw new DiffServiceError('INVALID_INPUT', 'Snapshot payload.styles must be a plain object or null.'); + } + if ( + payload.numbering !== null && + payload.numbering !== undefined && + (typeof payload.numbering !== 'object' || Array.isArray(payload.numbering)) + ) { + throw new DiffServiceError('INVALID_INPUT', 'Snapshot payload.numbering must be a plain object or null.'); + } +} + +function validateCoverageMatch(base: DiffCoverage, target: DiffCoverage): void { + if (!coverageEquals(base, target)) { + throw new DiffServiceError( + 'INVALID_INPUT', + `Coverage mismatch between base and target. Both must use the same coverage configuration.`, + ); + } +} + +// --------------------------------------------------------------------------- +// Error type +// --------------------------------------------------------------------------- + +export type DiffServiceErrorCode = + | 'INVALID_INPUT' + | 'CAPABILITY_UNSUPPORTED' + | 'PRECONDITION_FAILED' + | 'CAPABILITY_UNAVAILABLE' + | 'INTERNAL_ERROR'; + +export class DiffServiceError extends Error { + code: DiffServiceErrorCode; + + constructor(code: DiffServiceErrorCode, message: string) { + super(message); + this.name = 'DiffServiceError'; + this.code = code; + } +} diff --git a/packages/super-editor/src/extensions/diffing/service/fingerprint.test.ts b/packages/super-editor/src/extensions/diffing/service/fingerprint.test.ts new file mode 100644 index 0000000000..f223ad25a6 --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/service/fingerprint.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; + +import type { CanonicalDiffableState } from './canonicalize'; +import { computeFingerprint } from './fingerprint'; + +describe('computeFingerprint', () => { + it('matches the expected SHA-256 for a stable canonical state', () => { + const state: CanonicalDiffableState = { + body: { type: 'doc' }, + comments: [], + styles: null, + numbering: null, + }; + + expect(computeFingerprint(state)).toBe('66a5174811bcb593a6927a09fa130a40705a453407a6fc7777d9d3bcede7892e'); + }); + + it('changes when comment body content changes', () => { + const baseState: CanonicalDiffableState = { + body: { type: 'doc' }, + comments: [{ commentId: 'c1', textJson: { type: 'doc', content: [{ type: 'text', text: 'A' }] } }], + styles: null, + numbering: null, + }; + const changedState: CanonicalDiffableState = { + body: { type: 'doc' }, + comments: [{ commentId: 'c1', textJson: { type: 'doc', content: [{ type: 'text', text: 'B' }] } }], + styles: null, + numbering: null, + }; + + expect(computeFingerprint(baseState)).not.toBe(computeFingerprint(changedState)); + }); + + it('changes when comment identity changes', () => { + const baseState: CanonicalDiffableState = { + body: { type: 'doc' }, + comments: [{ commentId: 'c1', textJson: { type: 'doc', content: [{ type: 'text', text: 'Same' }] } }], + styles: null, + numbering: null, + }; + const changedState: CanonicalDiffableState = { + body: { type: 'doc' }, + comments: [{ commentId: 'c2', textJson: { type: 'doc', content: [{ type: 'text', text: 'Same' }] } }], + styles: null, + numbering: null, + }; + + expect(computeFingerprint(baseState)).not.toBe(computeFingerprint(changedState)); + }); +}); diff --git a/packages/super-editor/src/extensions/diffing/service/fingerprint.ts b/packages/super-editor/src/extensions/diffing/service/fingerprint.ts new file mode 100644 index 0000000000..cf61d0a00f --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/service/fingerprint.ts @@ -0,0 +1,93 @@ +/** + * Fingerprint computation for diffable document state. + * + * Uses a runtime-neutral SHA-256 implementation over canonicalized + + * stable-stringified state so the diff service works in both browser and Node + * bundles. + * One function, one hash — used by capture, compare, and apply. + */ + +import { type CanonicalDiffableState, stableStringify } from './canonicalize'; + +const INITIAL_HASH = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]; + +const ROUND_CONSTANTS = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, + 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, + 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, + 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, + 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, + 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, + 0xc67178f2, +]; + +function rightRotate(value: number, amount: number): number { + return (value >>> amount) | (value << (32 - amount)); +} + +function sha256Hex(input: string): string { + const bytes = new TextEncoder().encode(input); + const bitLength = BigInt(bytes.length) * 8n; + const paddedLength = Math.ceil((bytes.length + 1 + 8) / 64) * 64; + const padded = new Uint8Array(paddedLength); + padded.set(bytes); + padded[bytes.length] = 0x80; + + const paddedView = new DataView(padded.buffer); + paddedView.setUint32(paddedLength - 8, Number((bitLength >> 32n) & 0xffffffffn)); + paddedView.setUint32(paddedLength - 4, Number(bitLength & 0xffffffffn)); + + const hash = [...INITIAL_HASH]; + const words = new Uint32Array(64); + + for (let offset = 0; offset < paddedLength; offset += 64) { + for (let index = 0; index < 16; index += 1) { + words[index] = paddedView.getUint32(offset + index * 4); + } + + for (let index = 16; index < 64; index += 1) { + const s0 = rightRotate(words[index - 15], 7) ^ rightRotate(words[index - 15], 18) ^ (words[index - 15] >>> 3); + const s1 = rightRotate(words[index - 2], 17) ^ rightRotate(words[index - 2], 19) ^ (words[index - 2] >>> 10); + words[index] = (words[index - 16] + s0 + words[index - 7] + s1) >>> 0; + } + + let [a, b, c, d, e, f, g, h] = hash; + + for (let index = 0; index < 64; index += 1) { + const s1 = rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25); + const ch = (e & f) ^ (~e & g); + const temp1 = (h + s1 + ch + ROUND_CONSTANTS[index] + words[index]) >>> 0; + const s0 = rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22); + const maj = (a & b) ^ (a & c) ^ (b & c); + const temp2 = (s0 + maj) >>> 0; + + h = g; + g = f; + f = e; + e = (d + temp1) >>> 0; + d = c; + c = b; + b = a; + a = (temp1 + temp2) >>> 0; + } + + hash[0] = (hash[0] + a) >>> 0; + hash[1] = (hash[1] + b) >>> 0; + hash[2] = (hash[2] + c) >>> 0; + hash[3] = (hash[3] + d) >>> 0; + hash[4] = (hash[4] + e) >>> 0; + hash[5] = (hash[5] + f) >>> 0; + hash[6] = (hash[6] + g) >>> 0; + hash[7] = (hash[7] + h) >>> 0; + } + + return hash.map((value) => value.toString(16).padStart(8, '0')).join(''); +} + +/** + * Computes a SHA-256 fingerprint of the canonical diffable state. + */ +export function computeFingerprint(state: CanonicalDiffableState): string { + return sha256Hex(stableStringify(state)); +} diff --git a/packages/super-editor/src/extensions/diffing/service/index.ts b/packages/super-editor/src/extensions/diffing/service/index.ts new file mode 100644 index 0000000000..aad2d5eb5a --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/service/index.ts @@ -0,0 +1,14 @@ +export { + captureSnapshot, + compareToSnapshot, + applyDiffPayload, + DiffServiceError, + type DiffServiceEditor, + type DiffServiceErrorCode, + type ApplyOptions, + type ApplyDiffResult, +} from './diff-service'; +export { buildCanonicalDiffableState, stableStringify, type CanonicalDiffableState } from './canonicalize'; +export { computeFingerprint } from './fingerprint'; +export { buildDiffSummary } from './summary'; +export { V1_COVERAGE, coverageEquals } from './coverage'; diff --git a/packages/super-editor/src/extensions/diffing/service/summary.ts b/packages/super-editor/src/extensions/diffing/service/summary.ts new file mode 100644 index 0000000000..de939d6e91 --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/service/summary.ts @@ -0,0 +1,33 @@ +/** + * Diff summary generation. + * + * Produces a stable public DiffSummary from an internal DiffResult. + */ + +import type { DiffResult } from '../computeDiff'; +import type { DiffSummary } from '@superdoc/document-api'; + +/** + * Builds a coarse-grained summary from raw diff results. + */ +export function buildDiffSummary(diff: DiffResult): DiffSummary { + const bodyHasChanges = diff.docDiffs.length > 0; + const commentsHasChanges = diff.commentDiffs.length > 0; + const stylesHasChanges = diff.stylesDiff !== null; + const numberingHasChanges = diff.numberingDiff !== null; + + const changedComponents: DiffSummary['changedComponents'] = []; + if (bodyHasChanges) changedComponents.push('body'); + if (commentsHasChanges) changedComponents.push('comments'); + if (stylesHasChanges) changedComponents.push('styles'); + if (numberingHasChanges) changedComponents.push('numbering'); + + return { + hasChanges: changedComponents.length > 0, + changedComponents, + body: { hasChanges: bodyHasChanges }, + comments: { hasChanges: commentsHasChanges }, + styles: { hasChanges: stylesHasChanges }, + numbering: { hasChanges: numberingHasChanges }, + }; +} diff --git a/tests/doc-api-stories/package.json b/tests/doc-api-stories/package.json index 4bd0605568..76ef4fcd6c 100644 --- a/tests/doc-api-stories/package.json +++ b/tests/doc-api-stories/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "pretest": "pnpm --silent --dir ../.. corpus:pull && rm -rf results", - "test": "pnpm --silent --prefix ../../packages/sdk run generate && pnpm --silent --prefix ../../apps/cli run build && pnpm --silent --prefix ../../packages/sdk/langs/node run build && vitest run --config ./vitest.config.ts" + "test": "pnpm --silent --prefix ../../packages/sdk run generate && pnpm --silent --prefix ../../packages/superdoc run build:es && pnpm --silent --prefix ../../apps/cli run build && pnpm --silent --prefix ../../packages/sdk/langs/node run build && vitest run --config ./vitest.config.ts" }, "dependencies": { "@superdoc-dev/sdk": "file:../../packages/sdk/langs/node" diff --git a/tests/doc-api-stories/tests/diff/tracked-redline-roundtrip.ts b/tests/doc-api-stories/tests/diff/tracked-redline-roundtrip.ts new file mode 100644 index 0000000000..0e344d55d6 --- /dev/null +++ b/tests/doc-api-stories/tests/diff/tracked-redline-roundtrip.ts @@ -0,0 +1,118 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; +import { unwrap, useStoryHarness } from '../harness'; + +const execFileAsync = promisify(execFile); +const ZIP_MAX_BUFFER_BYTES = 10 * 1024 * 1024; +const TEST_USER = { name: 'Review Bot', email: 'bot@example.com' }; + +function sid(label: string): string { + return `${label}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; +} + +function unwrapNamed(payload: unknown, key?: string): T { + if (key && payload && typeof payload === 'object' && key in payload) { + return (payload as Record)[key] as T; + } + return unwrap(payload); +} + +async function readDocxPart(docPath: string, partPath: string): Promise { + const { stdout } = await execFileAsync('unzip', ['-p', docPath, partPath], { + maxBuffer: ZIP_MAX_BUFFER_BYTES, + }); + return stdout; +} + +describe('document-api story: diff tracked redline roundtrip', () => { + const { client, outPath } = useStoryHarness('diff/tracked-redline-roundtrip', { + preserveResults: true, + clientOptions: { + user: TEST_USER, + }, + }); + + async function listTrackedChanges(sessionId: string, type?: 'insert' | 'delete' | 'format') { + return unwrap(await client.doc.trackChanges.list(type ? { sessionId, type } : { sessionId })); + } + + it('compares two docs and saves a third doc with tracked changes', async () => { + const baseSessionId = sid('diff-base'); + const targetSessionId = sid('diff-target'); + const reopenedSessionId = sid('diff-reopen'); + + const baseText = 'Section 1. Payment is due within thirty days.'; + const targetParagraph = 'Renewal requires written approval.'; + + await client.doc.open({ + sessionId: baseSessionId, + contentOverride: baseText, + overrideType: 'text', + }); + await client.doc.open({ + sessionId: targetSessionId, + contentOverride: `${baseText}\n${targetParagraph}`, + overrideType: 'text', + }); + + const targetSnapshot = unwrapNamed(await client.doc.diff.capture({ sessionId: targetSessionId }), 'snapshot'); + expect(targetSnapshot.version).toBe('sd-diff-snapshot/v1'); + expect(targetSnapshot.engine).toBe('super-editor'); + + await client.doc.close({ sessionId: targetSessionId, discard: true }); + + const diff = unwrapNamed( + await client.doc.diff.compare({ + sessionId: baseSessionId, + targetSnapshot, + }), + 'diff', + ); + expect(diff.version).toBe('sd-diff-payload/v1'); + expect(diff.engine).toBe('super-editor'); + expect(diff.summary.hasChanges).toBe(true); + expect(diff.summary.body.hasChanges).toBe(true); + expect(diff.summary.changedComponents).toContain('body'); + + const applyResult = unwrapNamed( + await client.doc.diff.apply({ + sessionId: baseSessionId, + diff, + changeMode: 'tracked', + }), + 'result', + ); + expect(applyResult.appliedOperations).toBeGreaterThan(0); + expect(applyResult.summary.hasChanges).toBe(true); + expect(applyResult.summary.body.hasChanges).toBe(true); + + const trackedAfterApply = await listTrackedChanges(baseSessionId); + const insertionsAfterApply = await listTrackedChanges(baseSessionId, 'insert'); + expect(trackedAfterApply.total).toBeGreaterThan(0); + expect(insertionsAfterApply.total).toBeGreaterThan(0); + + const outputPath = outPath('tracked-redline.docx'); + await client.doc.save({ + sessionId: baseSessionId, + out: outputPath, + force: true, + }); + + const documentXml = await readDocxPart(outputPath, 'word/document.xml'); + expect(documentXml).toContain(targetParagraph); + expect(documentXml).toMatch(/ CLI_DIST_BIN, - () => CLI_SRC_BIN, - ); + const cliBin = + cliBinMode === 'source' + ? CLI_SRC_BIN + : cliBinMode === 'dist' + ? CLI_DIST_BIN + : await access(CLI_DIST_BIN).then( + () => CLI_DIST_BIN, + () => CLI_SRC_BIN, + ); const stateDir = path.join(resultsDir, '.superdoc-cli-state'); const client = createSuperDocClient({ + requestTimeoutMs: 30_000, + startupTimeoutMs: 30_000, + shutdownTimeoutMs: 30_000, + ...clientOptions, env: { + ...clientOptions.env, SUPERDOC_CLI_BIN: cliBin, SUPERDOC_CLI_STATE_DIR: stateDir, }, - requestTimeoutMs: 30_000, - startupTimeoutMs: 30_000, - shutdownTimeoutMs: 30_000, }); await client.connect();