From 8e35805eda9a385ed5e2d57ad10ac4b997a28faa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tadeu=20Tupinamb=C3=A1?= Date: Mon, 3 Aug 2026 12:39:41 -0300 Subject: [PATCH 1/5] fix(v1): preserve locked content controls when wrapping (#1260) Ported-From-Source-Repo: superdoc/orbit Ported-From-Source-Commit: 44594f13e0adcda5eb108b0c3e68dd885be8d9ee Ported-Public-Prefix: superdoc/public --- .../contract-conformance.test.ts | 7 +- .../content-controls-wrappers.test.ts | 30 +++++- .../plan-engine/content-controls-wrappers.ts | 23 ++++- ...ntrol-wrap-persistence.integration.test.ts | 93 +++++++++++++++++++ .../structured-content-lock-plugin.js | 34 ++++++- .../structured-content-lock-plugin.test.js | 69 +++++++++++++- 6 files changed, 244 insertions(+), 12 deletions(-) diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts index 3cb578b430..0269012688 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts @@ -2992,9 +2992,12 @@ function makeSdtEditor(overrideAttrs: Record = {}, textContent steps: [{ type: 'replaceStep' }], }; - const dispatch = vi.fn(); + let editor: Editor; + const dispatch = vi.fn(() => { + (editor.state as { doc: ProseMirrorNode }).doc = createNode('doc', [sdtNode], { isBlock: false }); + }); - const editor = { + editor = { state: { doc, tr, diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.test.ts index a9fadb6833..ec0440b145 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.test.ts @@ -201,9 +201,12 @@ function makeSdtEditor(overrideAttrs: Record = {}, sdtChildren? steps: [{ type: 'replaceStep' }], }; - const dispatch = vi.fn(); + let editor: Editor; + const dispatch = vi.fn(() => { + (editor.state as { doc: ProseMirrorNode }).doc = createNode('doc', [sdtNode], { isBlock: false }); + }); - const editor = { + editor = { state: { doc, tr, @@ -299,9 +302,12 @@ function makeInlineSdtEditor(overrideAttrs: Record = {}, sdtChi steps: [{ type: 'replaceStep' }], }; - const dispatch = vi.fn(); + let editor: Editor; + const dispatch = vi.fn(() => { + (editor.state as { doc: ProseMirrorNode }).doc = createNode('doc', [paragraph], { isBlock: false }); + }); - return { + editor = { state: { doc, tr, @@ -356,6 +362,8 @@ function makeInlineSdtEditor(overrideAttrs: Record = {}, sdtChi insertStructuredContentInline: vi.fn(() => true), }, } as unknown as Editor; + + return editor; } /** @@ -465,6 +473,20 @@ describe('contentControls.wrap', () => { } }); + it('reports no effect when dispatch does not apply the wrapping transaction', () => { + const editor = makeSdtEditor(); + const dispatch = editor.view!.dispatch as ReturnType; + dispatch.mockImplementation(() => undefined); + const adapter = createContentControlsAdapter(editor); + + const result = adapter.wrap({ target: SDT_TARGET, kind: 'block' }, { changeMode: 'direct' }); + + expect(result).toEqual({ + success: false, + failure: { code: 'NO_OP', message: 'The mutation reported success without changing the document.' }, + }); + }); + it('creates the wrapper node via schema.nodes.structuredContentBlock.create', () => { const editor = makeSdtEditor(); const adapter = createContentControlsAdapter(editor); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.ts index 365609fe6f..c31d13557a 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.ts @@ -14,6 +14,7 @@ import { Fragment, type Node as ProseMirrorNode, type Schema } from 'prosemirror-model'; import { TextSelection } from 'prosemirror-state'; +import { STRUCTURED_CONTENT_WRAPPER_PRESERVING_META } from '../../extensions/structured-content/structured-content-lock-plugin.js'; import type { Editor } from '../../core/Editor.js'; import type { ProseMirrorJSON } from '../../core/types/EditorTypes.js'; import type { @@ -226,6 +227,22 @@ function executeSdtMutation( return buildMutationSuccess(target, updatedRef); } +function executeSdtWrappingMutation( + editor: Editor, + target: ContentControlTarget, + options: MutationOptions | undefined, + handler: () => boolean | ContentControlTarget, +): ContentControlMutationResult { + const documentBefore = editor.state.doc; + const result = executeSdtMutation(editor, target, options, handler); + + if (!options?.dryRun && result.success && editor.state.doc === documentBefore) { + return buildMutationFailure('NO_OP', 'The mutation reported success without changing the document.'); + } + + return result; +} + /** * Dispatch a transaction in both UI-attached and headless adapter contexts. * Stories and CLI calls can run without a mounted editor view, so fall back to @@ -502,7 +519,7 @@ function wrapWrapper( const id = generateSdtId(); const wrapperTarget: ContentControlTarget = { kind: input.kind, nodeType: 'sdt', nodeId: id }; - return executeSdtMutation(editor, input.target, options, () => { + return executeSdtWrappingMutation(editor, input.target, options, () => { const resolved = resolveSdtByTarget(editor.state.doc, input.target); const nodeTypeName = input.kind === 'block' ? SDT_BLOCK_NAME : 'structuredContent'; const nodeType = editor.schema.nodes[nodeTypeName]; @@ -524,6 +541,7 @@ function wrapWrapper( ); const { tr } = editor.state; tr.replaceWith(resolved.pos, resolved.pos + resolved.node.nodeSize, wrapperNode); + tr.setMeta(STRUCTURED_CONTENT_WRAPPER_PRESERVING_META, true); dispatchTransaction(editor, tr); return wrapperTarget; }); @@ -1779,7 +1797,7 @@ function groupWrapWrapper( resolveSdtByTarget(editor.state.doc, input.target); const target = input.target; - return executeSdtMutation(editor, target, options, () => { + return executeSdtWrappingMutation(editor, target, options, () => { const resolved = resolveSdtByTarget(editor.state.doc, input.target); const groupNodeType = editor.schema.nodes[SDT_BLOCK_NAME]; if (!groupNodeType) return false; @@ -1789,6 +1807,7 @@ function groupWrapWrapper( const { tr } = editor.state; tr.replaceWith(resolved.pos, resolved.pos + resolved.node.nodeSize, groupNode); + tr.setMeta(STRUCTURED_CONTENT_WRAPPER_PRESERVING_META, true); dispatchTransaction(editor, tr); return { kind: 'block' as const, nodeType: 'sdt' as const, nodeId: groupId }; }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/sd-3617-content-control-wrap-persistence.integration.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/sd-3617-content-control-wrap-persistence.integration.test.ts index 3a00e843cb..45678df98a 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/sd-3617-content-control-wrap-persistence.integration.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/sd-3617-content-control-wrap-persistence.integration.test.ts @@ -90,4 +90,97 @@ describe('SD-3617 nested content-control wrap persistence', () => { editor.destroy(); } }); + + it('contentControls.wrap preserves and reparents an sdtLocked nested child after save and reopen', async () => { + const source = await loadTestDataForEditorTests('sdt-nested-block.docx'); + const editor = openEditor(source); + let reopened: Editor | undefined; + try { + const originalParent = editor.doc.contentControls.selectByTag({ tag: 'outer-block' }).items[0]!; + const child = editor.doc.contentControls.selectByTag({ tag: 'inner-block' }).items[0]!; + editor.doc.contentControls.setLockMode({ target: child.target, lockMode: 'sdtLocked' }); + + const result = editor.doc.contentControls.wrap({ + target: child.target, + kind: 'block', + tag: 'locked-child-parent', + alias: 'Locked child parent', + }); + + expect(result.success).toBe(true); + if (!result.success || !result.updatedRef) { + throw new Error('Expected contentControls.wrap to return the persisted parent reference'); + } + const parent = editor.doc.contentControls.get({ target: result.updatedRef }); + expect(parent.properties.tag).toBe('locked-child-parent'); + expect(editor.doc.contentControls.get({ target: child.target }).lockMode).toBe('sdtLocked'); + expect(editor.doc.contentControls.getParent({ target: child.target })?.id).toBe(parent.id); + expect(editor.doc.contentControls.getParent({ target: parent.target })?.id).toBe(originalParent.id); + expect(editor.doc.contentControls.listChildren({ target: parent.target }).items.map(({ id }) => id)).toEqual([ + child.id, + ]); + reopened = await reopenEditor(editor); + + const reopenedChild = reopened.doc.contentControls.get({ target: child.target }); + const reopenedParent = reopened.doc.contentControls.get({ target: result.updatedRef }); + const reopenedOriginalParent = reopened.doc.contentControls.get({ target: originalParent.target }); + + expect(reopenedChild.lockMode).toBe('sdtLocked'); + expect(reopenedParent.properties.tag).toBe('locked-child-parent'); + expect(reopened.doc.contentControls.getParent({ target: reopenedChild.target })?.id).toBe(reopenedParent.id); + expect(reopened.doc.contentControls.getParent({ target: reopenedParent.target })?.id).toBe( + reopenedOriginalParent.id, + ); + expect( + reopened.doc.contentControls.listChildren({ target: reopenedParent.target }).items.map(({ id }) => id), + ).toEqual([reopenedChild.id]); + } finally { + reopened?.destroy(); + editor.destroy(); + } + }); + + it('contentControls.group.wrap preserves and reparents an sdtLocked nested child after save and reopen', async () => { + const source = await loadTestDataForEditorTests('sdt-nested-block.docx'); + const editor = openEditor(source); + let reopened: Editor | undefined; + try { + const originalParent = editor.doc.contentControls.selectByTag({ tag: 'outer-block' }).items[0]!; + const child = editor.doc.contentControls.selectByTag({ tag: 'inner-block' }).items[0]!; + editor.doc.contentControls.setLockMode({ target: child.target, lockMode: 'sdtLocked' }); + + const result = editor.doc.contentControls.group.wrap({ target: child.target }); + + expect(result.success).toBe(true); + if (!result.success || !result.updatedRef) { + throw new Error('Expected contentControls.group.wrap to return the persisted parent reference'); + } + const parent = editor.doc.contentControls.get({ target: result.updatedRef }); + expect(parent.controlType).toBe('group'); + expect(editor.doc.contentControls.get({ target: child.target }).lockMode).toBe('sdtLocked'); + expect(editor.doc.contentControls.getParent({ target: child.target })?.id).toBe(parent.id); + expect(editor.doc.contentControls.getParent({ target: parent.target })?.id).toBe(originalParent.id); + expect(editor.doc.contentControls.listChildren({ target: parent.target }).items.map(({ id }) => id)).toEqual([ + child.id, + ]); + reopened = await reopenEditor(editor); + + const reopenedChild = reopened.doc.contentControls.get({ target: child.target }); + const reopenedParent = reopened.doc.contentControls.get({ target: result.updatedRef }); + const reopenedOriginalParent = reopened.doc.contentControls.get({ target: originalParent.target }); + + expect(reopenedChild.lockMode).toBe('sdtLocked'); + expect(reopenedParent.controlType).toBe('group'); + expect(reopened.doc.contentControls.getParent({ target: reopenedChild.target })?.id).toBe(reopenedParent.id); + expect(reopened.doc.contentControls.getParent({ target: reopenedParent.target })?.id).toBe( + reopenedOriginalParent.id, + ); + expect( + reopened.doc.contentControls.listChildren({ target: reopenedParent.target }).items.map(({ id }) => id), + ).toEqual([reopenedChild.id]); + } finally { + reopened?.destroy(); + editor.destroy(); + } + }); }); diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js index 5f7d265ed3..7d06b28fb1 100644 --- a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js @@ -7,6 +7,7 @@ import { import { BLOCK_NODE_METADATA_UPDATE_META } from '../block-node/block-node.js'; export const STRUCTURED_CONTENT_LOCK_KEY = new PluginKey('structuredContentLock'); +export const STRUCTURED_CONTENT_WRAPPER_PRESERVING_META = 'structuredContentWrapperPreserving'; /** * Lock enforcement plugin for StructuredContent nodes. @@ -41,11 +42,34 @@ function collectSDTNodes(doc) { return sdtNodes; } +function collectPreservedSDTNodes(previousSdtNodes, nextDoc) { + const nextNodesById = new Map(); + for (const nextSdt of collectSDTNodes(nextDoc)) { + const id = nextSdt.node.attrs.id; + if (id == null) continue; + const key = String(id); + const matches = nextNodesById.get(key) ?? []; + matches.push(nextSdt.node); + nextNodesById.set(key, matches); + } + + const preserved = new Set(); + for (const previousSdt of previousSdtNodes) { + const id = previousSdt.node.attrs.id; + if (id == null) continue; + const matches = nextNodesById.get(String(id)); + if (matches?.length === 1 && matches[0].eq(previousSdt.node)) { + preserved.add(previousSdt); + } + } + return preserved; +} + /** * Check if a range [from, to] would violate any lock rules * Returns { blocked: boolean, reason?: string } */ -function checkLockViolation(sdtNodes, from, to) { +function checkLockViolation(sdtNodes, from, to, preservedSdtNodes) { for (const sdt of sdtNodes) { const overlaps = from < sdt.end && to > sdt.pos; if (!overlaps) continue; @@ -63,7 +87,7 @@ function checkLockViolation(sdtNodes, from, to) { const isSdtLocked = sdt.lockMode === 'sdtLocked' || sdt.lockMode === 'sdtContentLocked'; const isContentLocked = sdt.lockMode === 'contentLocked' || sdt.lockMode === 'sdtContentLocked'; - if (isSdtLocked && wouldDamageWrapper) { + if (isSdtLocked && wouldDamageWrapper && !preservedSdtNodes?.has(sdt)) { return { blocked: true, reason: `Cannot delete SDT wrapper (${sdt.lockMode})` }; } @@ -314,6 +338,10 @@ export function createStructuredContentLockPlugin() { return true; } + const preservedSdtNodes = tr.getMeta?.(STRUCTURED_CONTENT_WRAPPER_PRESERVING_META) + ? collectPreservedSDTNodes(sdtNodes, tr.doc) + : undefined; + for (const step of tr.steps) { // Skip steps without from/to (AttrStep, AddNodeMarkStep, RemoveNodeMarkStep) — // these change metadata, not content, so they can't violate lock rules. @@ -321,7 +349,7 @@ export function createStructuredContentLockPlugin() { continue; } - const result = checkLockViolation(sdtNodes, step.from, step.to); + const result = checkLockViolation(sdtNodes, step.from, step.to, preservedSdtNodes); if (result.blocked) { return false; diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js index f5759ca8d6..b8b93b47e2 100644 --- a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js @@ -8,7 +8,10 @@ import { findFirstContentCursorPosInNode, findLastContentCursorPosInNode, } from '@core/commands/helpers/textPositions.js'; -import { STRUCTURED_CONTENT_LOCK_KEY } from './structured-content-lock-plugin.js'; +import { + STRUCTURED_CONTENT_LOCK_KEY, + STRUCTURED_CONTENT_WRAPPER_PRESERVING_META, +} from './structured-content-lock-plugin.js'; /** * Test suite for StructuredContentLockPlugin @@ -135,6 +138,70 @@ describe('StructuredContentLockPlugin', () => { }); }); + describe('wrapper-preserving structural transactions', () => { + it.each(['sdtLocked', 'sdtContentLocked'])( + 'allows a marked transaction that reparents an unchanged %s block SDT', + (lockMode) => { + const doc = createDocWithSDT(lockMode, 'structuredContentBlock'); + const state = applyDocToEditor(doc); + const sdtInfo = findSDTNode(state.doc, 'structuredContentBlock'); + const parent = schema.nodes.structuredContentBlock.create( + { id: 'parent-456', lockMode: 'unlocked' }, + sdtInfo.node, + ); + + const tr = state.tr + .replaceWith(sdtInfo.pos, sdtInfo.end, parent) + .setMeta(STRUCTURED_CONTENT_WRAPPER_PRESERVING_META, true); + const nextState = state.apply(tr); + + expect(nextState.doc.childCount).toBe(1); + expect(nextState.doc.firstChild?.attrs.id).toBe('parent-456'); + expect(nextState.doc.firstChild?.firstChild?.attrs.id).toBe('test-123'); + expect(nextState.doc.firstChild?.firstChild?.attrs.lockMode).toBe(lockMode); + expect(nextState.doc.firstChild?.firstChild?.textContent).toBe('Test content'); + }, + ); + + it('still blocks a marked transaction that changes the locked SDT', () => { + const doc = createDocWithSDT('sdtLocked', 'structuredContentBlock'); + const state = applyDocToEditor(doc); + const sdtInfo = findSDTNode(state.doc, 'structuredContentBlock'); + const changedParagraph = schema.nodes.paragraph.create(null, schema.text('Changed content')); + const changedSdt = sdtInfo.node.type.create(sdtInfo.node.attrs, changedParagraph); + const parent = schema.nodes.structuredContentBlock.create({ id: 'parent-456', lockMode: 'unlocked' }, changedSdt); + + const tr = state.tr + .replaceWith(sdtInfo.pos, sdtInfo.end, parent) + .setMeta(STRUCTURED_CONTENT_WRAPPER_PRESERVING_META, true); + const nextState = state.apply(tr); + + expect(nextState.doc.eq(state.doc)).toBe(true); + }); + + it('allows wrapping an unlocked ancestor that preserves its locked descendant', () => { + const lockedChild = schema.nodes.structuredContentBlock.create( + { id: 'locked-child', lockMode: 'sdtLocked' }, + schema.nodes.paragraph.create(null, schema.text('Locked child content')), + ); + const target = schema.nodes.structuredContentBlock.create( + { id: 'unlocked-target', lockMode: 'unlocked' }, + lockedChild, + ); + const state = applyDocToEditor(schema.nodes.doc.create(null, target)); + const parent = schema.nodes.structuredContentBlock.create({ id: 'parent-456', lockMode: 'unlocked' }, target); + + const tr = state.tr + .replaceWith(0, target.nodeSize, parent) + .setMeta(STRUCTURED_CONTENT_WRAPPER_PRESERVING_META, true); + const nextState = state.apply(tr); + + expect(nextState.doc.firstChild?.attrs.id).toBe('parent-456'); + expect(nextState.doc.firstChild?.firstChild?.attrs.id).toBe('unlocked-target'); + expect(nextState.doc.firstChild?.firstChild?.firstChild?.attrs.id).toBe('locked-child'); + }); + }); + describe('content modification (contentLocked behavior)', () => { const contentModificationCases = [ // [lockMode, nodeType, shouldBlock, description] From 49cca8ebd13b9b4d9ed0521a12ca8c735a83d9dc Mon Sep 17 00:00:00 2001 From: Gabriel Chittolina <163901514+chittolinag@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:27:28 -0300 Subject: [PATCH 2/5] fix: release stable/legacy on react/sdk/cli (#1269) * fix: release stable/legacy on react/sdk/cli * fix: sdk guard test --------- Co-authored-by: Gabriel Chittolina Ported-From-Source-Repo: superdoc/orbit Ported-From-Source-Commit: 818165d35f26423f82fd0e62adf5ab39699605a7 Ported-Public-Prefix: superdoc/public --- apps/cli/.releaserc.cjs | 17 +++++++++++++++-- packages/react/.releaserc.cjs | 17 +++++++++++++++-- packages/sdk/.releaserc.cjs | 17 +++++++++++++++-- .../scripts/__tests__/release-order.test.mjs | 18 +++++++++++++----- scripts/release-local-stable.mjs | 12 ++++++++++++ 5 files changed, 70 insertions(+), 11 deletions(-) diff --git a/apps/cli/.releaserc.cjs b/apps/cli/.releaserc.cjs index 08525d89be..c1d7f46536 100644 --- a/apps/cli/.releaserc.cjs +++ b/apps/cli/.releaserc.cjs @@ -28,9 +28,22 @@ require('../../scripts/semantic-release/patch-commit-filter.cjs')(RELEASE_PATHS) const branch = process.env.GITHUB_REF_NAME || process.env.CI_COMMIT_BRANCH; +// Tag ownership: `@superdoc-dev/cli` on npm is published by two release lines. V2 owns +// the default channels — `latest` for stable, `next` for previews — and V1 is +// maintenance-only under `legacy`. V1 must never claim `latest` or `next`: both +// lines publish the same package name, so a V1 release that claimed a V2 channel +// would silently take it over (last write wins). That is what happened here — +// V1 kept moving `next` while V2's releases were left untagged. +// +// `main` is deliberately absent. It was the only branch that could produce a +// `next` release, so removing it is what stops V1 claiming the channel. +// +// Matches packages/superdoc, which established this split. const branches = [ - { name: 'stable', channel: 'latest' }, - { name: 'main', prerelease: 'next', channel: 'next' }, + { + name: 'stable', + channel: 'legacy', // V1 maintenance line; V2 owns `latest` + }, ]; const isPrerelease = branches.some((b) => typeof b === 'object' && b.name === branch && b.prerelease); diff --git a/packages/react/.releaserc.cjs b/packages/react/.releaserc.cjs index 6525327eb8..b466c281f7 100644 --- a/packages/react/.releaserc.cjs +++ b/packages/react/.releaserc.cjs @@ -28,9 +28,22 @@ require('../../scripts/semantic-release/patch-commit-filter.cjs')(RELEASE_PATHS) const branch = process.env.GITHUB_REF_NAME || process.env.CI_COMMIT_BRANCH; +// Tag ownership: `@superdoc-dev/react` on npm is published by two release lines. V2 owns +// the default channels — `latest` for stable, `next` for previews — and V1 is +// maintenance-only under `legacy`. V1 must never claim `latest` or `next`: both +// lines publish the same package name, so a V1 release that claimed a V2 channel +// would silently take it over (last write wins). That is what happened here — +// V1 kept moving `next` while V2's releases were left untagged. +// +// `main` is deliberately absent. It was the only branch that could produce a +// `next` release, so removing it is what stops V1 claiming the channel. +// +// Matches packages/superdoc, which established this split. const branches = [ - { name: 'stable', channel: 'latest' }, - { name: 'main', prerelease: 'next', channel: 'next' }, + { + name: 'stable', + channel: 'legacy', // V1 maintenance line; V2 owns `latest` + }, ]; const isPrerelease = branches.some((b) => typeof b === 'object' && b.name === branch && b.prerelease); diff --git a/packages/sdk/.releaserc.cjs b/packages/sdk/.releaserc.cjs index 968c28446a..c586c57069 100644 --- a/packages/sdk/.releaserc.cjs +++ b/packages/sdk/.releaserc.cjs @@ -28,9 +28,22 @@ require('../../scripts/semantic-release/patch-commit-filter.cjs')(RELEASE_PATHS) const branch = process.env.GITHUB_REF_NAME || process.env.CI_COMMIT_BRANCH; const isCiRelease = Boolean(process.env.CI); +// Tag ownership: `@superdoc-dev/sdk` on npm is published by two release lines. V2 owns +// the default channels — `latest` for stable, `next` for previews — and V1 is +// maintenance-only under `legacy`. V1 must never claim `latest` or `next`: both +// lines publish the same package name, so a V1 release that claimed a V2 channel +// would silently take it over (last write wins). That is what happened here — +// V1 kept moving `next` while V2's releases were left untagged. +// +// `main` is deliberately absent. It was the only branch that could produce a +// `next` release, so removing it is what stops V1 claiming the channel. +// +// Matches packages/superdoc, which established this split. const branches = [ - { name: 'stable', channel: 'latest' }, - { name: 'main', prerelease: 'next', channel: 'next' }, + { + name: 'stable', + channel: 'legacy', // V1 maintenance line; V2 owns `latest` + }, ]; const isPrerelease = branches.some((b) => typeof b === 'object' && b.name === branch && b.prerelease); diff --git a/packages/sdk/scripts/__tests__/release-order.test.mjs b/packages/sdk/scripts/__tests__/release-order.test.mjs index 416c3c9ef5..7e66fe9de3 100644 --- a/packages/sdk/scripts/__tests__/release-order.test.mjs +++ b/packages/sdk/scripts/__tests__/release-order.test.mjs @@ -182,15 +182,23 @@ test('sdk semantic-release prepareCmd builds Node SDK before validate', async () ); }); -test('sdk semantic-release matches CLI channel model (next/next on main, latest on stable)', async () => { +// `@superdoc-dev/sdk` is published to one npm name by two release lines. V2 owns +// `latest` and `next`; V1 is maintenance-only under `legacy`. This guard used to +// assert the opposite - `latest` on stable and a `next` prerelease from `main` - +// which is the configuration that let a V1 release silently take a V2 channel. +test('sdk semantic-release leaves V2 channels alone (legacy on stable, no next)', async () => { const content = await readRepoFile('packages/sdk/.releaserc.cjs'); assert.ok( - content.includes("{ name: 'stable', channel: 'latest' }"), - 'packages/sdk/.releaserc.cjs: stable release branch must remain configured', + /name: 'stable',\s*\n\s*channel: 'legacy'/u.test(content), + 'packages/sdk/.releaserc.cjs: stable must release on legacy, never latest', ); assert.ok( - content.includes("{ name: 'main', prerelease: 'next', channel: 'next' }"), - 'packages/sdk/.releaserc.cjs: main branch must release next versions on next channel', + !content.includes("channel: 'next'") && !content.includes("prerelease: 'next'"), + 'packages/sdk/.releaserc.cjs: V1 must not claim the next channel owned by V2', + ); + assert.ok( + !/name: 'main'/u.test(content), + 'packages/sdk/.releaserc.cjs: main was the only branch that could produce a next release', ); assert.ok( content.includes('const isCiRelease = Boolean(process.env.CI);'), diff --git a/scripts/release-local-stable.mjs b/scripts/release-local-stable.mjs index ec863827dd..f9620803f1 100644 --- a/scripts/release-local-stable.mjs +++ b/scripts/release-local-stable.mjs @@ -906,6 +906,10 @@ const packages = [ tagPrefix: 'cli-v', tagPattern: 'cli-v*', npmPackages: CLI_NPM_PACKAGES, + // V1 is the maintenance line for this package; V2 owns `latest` and `next`. + // Mirrors the `legacy` channel in its .releaserc.cjs so a recovered publish + // lands on the same tag the release itself would use. + stableDistTag: 'legacy', resumePublish: resumeCliPublish, }, { @@ -915,6 +919,10 @@ const packages = [ tagPrefix: 'sdk-v', tagPattern: 'sdk-v*', npmPackages: SDK_NODE_NPM_PACKAGES, + // V1 is the maintenance line for this package; V2 owns `latest` and `next`. + // Mirrors the `legacy` channel in its .releaserc.cjs so a recovered publish + // lands on the same tag the release itself would use. + stableDistTag: 'legacy', resumePublish: resumeSdkPublish, ...(SDK_PYPI_ENABLED ? { @@ -961,6 +969,10 @@ const packages = [ tagPrefix: 'react-v', tagPattern: 'react-v*', npmPackages: ['@superdoc-dev/react'], + // V1 is the maintenance line for this package; V2 owns `latest` and `next`. + // Mirrors the `legacy` channel in its .releaserc.cjs so a recovered publish + // lands on the same tag the release itself would use. + stableDistTag: 'legacy', resumePublish: resumeReactPublish, }, { From 5b1af905f942ec862aef43a6ef45c92d828347fd Mon Sep 17 00:00:00 2001 From: Artem Nistuley <101666502+artem-harbour@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:33:47 +0300 Subject: [PATCH 3/5] fix: preserve tracked deletion around cross-reference fields on import/export (#1253) Co-authored-by: Artem Nistuley Ported-From-Source-Repo: superdoc/orbit Ported-From-Source-Commit: c9c5a957cdd903aabe7d3bb166e192f975a7913e Ported-Public-Prefix: superdoc/public --- .../preProcessNodesForFldChar.js | 9 +- .../preProcessNodesForFldChar.test.js | 37 ++++++++ .../crossReference-translator.js | 14 +++ .../crossReference-translator.test.js | 29 ++++++ .../v3/handlers/w/del/del-translator.js | 30 ++++--- .../v3/handlers/w/del/del-translator.test.js | 36 ++++++++ .../w/r/helpers/track-change-helpers.js | 36 +++++++- .../tracked-delete-crossreference-field.docx | Bin 0 -> 37270 bytes ...racked-delete-crossreference-field.test.js | 85 ++++++++++++++++++ 9 files changed, 258 insertions(+), 18 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/tests/data/behavior-fixtures/tracked-delete-crossreference-field.docx create mode 100644 packages/super-editor/src/editors/v1/tests/import-export/tracked-delete-crossreference-field.test.js diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js index 7b168c1314..311f8a8fb7 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.js @@ -139,7 +139,7 @@ export const preProcessNodesForFldChar = (nodes = [], docx) => { const fldCharEl = node.elements?.find((el) => el.name === 'w:fldChar'); const fldType = fldCharEl?.attributes?.['w:fldCharType']; - const instrTextEl = node.elements?.find((el) => el.name === 'w:instrText'); + const instrTextEl = node.elements?.find((el) => el.name === 'w:instrText' || el.name === 'w:delInstrText'); if (node.name === 'w:fldSimple') { const instr = node.attributes?.['w:instr']; @@ -488,6 +488,9 @@ const extractFieldRunRPr = (node) => { * * This function parses a run node to identify instruction-related elements: * - w:instrText elements become 'text' tokens with their content + * - w:delInstrText elements (the ECMA-376 §17.16.13 form of an instruction run + * when the field sits inside a tracked deletion) are treated identically, so a + * field entirely inside w:del is still recognized and interpreted. * - w:tab elements become 'tab' tokens (important for INDEX fields with tab separators) * * @param {OpenXmlNode} node - The OOXML node to extract tokens from @@ -512,7 +515,7 @@ const extractInstructionTokensFromNode = (node) => { /** @type {InstructionToken[]} */ const tokens = []; elements.forEach((el) => { - if (el?.name === 'w:instrText') { + if (el?.name === 'w:instrText' || el?.name === 'w:delInstrText') { const text = (el.elements || []).map((child) => (typeof child?.text === 'string' ? child.text : '')).join(''); tokens.push({ type: 'text', text }); } @@ -524,7 +527,7 @@ const extractInstructionTokensFromNode = (node) => { }; const FIELD_CONTROL_ELEMENT_NAMES = new Set(['w:fldChar']); -const INSTRUCTION_ELEMENT_NAMES = new Set(['w:instrText', 'w:tab']); +const INSTRUCTION_ELEMENT_NAMES = new Set(['w:instrText', 'w:delInstrText', 'w:tab']); const cloneNodeWithElements = (node, elements) => ({ ...node, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js index 309a19adbc..6c9c0d424b 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js @@ -66,6 +66,43 @@ describe('preProcessNodesForFldChar', () => { ]); }); + it('recognizes a REF field whose instruction/result runs are w:delInstrText/w:delText (entirely inside a tracked deletion)', () => { + // Per ECMA-376 §17.16.13/§17.13.5.15, a field fully inside serializes + // its instruction/result runs as w:delInstrText/w:delText rather than + // w:instrText/w:t. Regression coverage for the bug in plans/TASK.md: this + // must still be recognized as a REF field, not fall back to raw XML. + const nodes = [ + { + name: 'w:del', + attributes: { 'w:id': '901', 'w:author': 'Orbital Copilot', 'w:date': '2026-07-30T12:30:01Z' }, + elements: [ + { name: 'w:r', elements: [{ name: 'w:delText', elements: [{ type: 'text', text: 'this clause ' }] }] }, + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }] }, + { + name: 'w:r', + elements: [{ name: 'w:delInstrText', elements: [{ type: 'text', text: ' REF _Ref174337828 \\h ' }] }], + }, + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' } }] }, + { name: 'w:r', elements: [{ name: 'w:delText', elements: [{ type: 'text', text: '10.6' }] }] }, + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' } }] }, + ], + }, + ]; + + const { processedNodes } = preProcessNodesForFldChar(nodes, mockDocx); + + expect(processedNodes).toHaveLength(1); + expect(processedNodes[0].name).toBe('w:del'); + const fieldNode = processedNodes[0].elements.find((n) => n.name === 'sd:crossReference'); + expect(fieldNode).toBeTruthy(); + expect(fieldNode.attributes).toMatchObject({ fieldType: 'REF', instruction: 'REF _Ref174337828 \\h' }); + // The delText result run before the leading text stays outside the field node. + const delTextRun = processedNodes[0].elements.find( + (n) => n.name === 'w:r' && n.elements?.some((el) => el.name === 'w:delText'), + ); + expect(delTextRun.elements[0].elements[0].text).toBe('this clause '); + }); + it.each(['page \\* arabic', 'Page', 'PAGE'])( 'should process PAGE field instructions case-insensitively: %s', (instruction) => { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.js index 64e9d01367..b5404e75ce 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.js @@ -2,6 +2,7 @@ import { NodeTranslator } from '@translator'; import { processOutputMarks } from '../../../../exporter.js'; import { buildFieldResultRuns, buildInstructionElements } from '../shared/index.js'; +import { translator as wDelTranslator } from '../../w/del/index.js'; /** @type {import('@translator').XmlNodeName} */ const XML_NODE_NAME = 'sd:crossReference'; @@ -39,11 +40,24 @@ const encode = (params) => { /** * Decode the crossReference node back into OOXML field structure. + * + * If the field was deleted as a tracked change, delegate to the `w:del` + * translator so the whole field (begin/instr/separate/result/end) is wrapped + * in one `` and its instruction/result text renamed to + * `w:delInstrText`/`w:delText`, mirroring the `trackDelete` dispatch already + * done in `t-translator.js` for plain text runs. + * * @param {import('@translator').SCDecoderConfig} params * @returns {import('@translator').SCDecoderResult[]} */ const decode = (params) => { const { node } = params; + + const trackedMark = node.marks?.find((m) => m.type === 'trackDelete'); + if (trackedMark) { + return wDelTranslator.decode(params); + } + const outputMarks = processOutputMarks(node.attrs?.marksAsAttrs || []); const contentNodes = buildFieldResultRuns(params, outputMarks); const instructionElements = buildInstructionElements(node.attrs?.instruction, node.attrs?.instructionTokens); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.test.js index d074f0c8e7..9ff7efcf44 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/sd/crossReference/crossReference-translator.test.js @@ -70,6 +70,35 @@ describe('crossReference export routing', () => { expect(exportedRuns.some((node) => hasFieldCharType(node, 'end'))).toBe(true); }); + it('wraps a deleted crossReference field in one w:del with delInstrText/delText (regression for plans/TASK.md)', () => { + const trackDeleteAttrs = { id: '901', author: 'Orbital Copilot', date: '2026-07-30T12:30:01Z' }; + const node = buildCrossReferenceNode({ + content: [{ type: 'run', attrs: {}, content: [{ type: 'text', text: '10.6' }] }], + }); + node.marks = [{ type: 'trackDelete', attrs: trackDeleteAttrs }]; + + const exported = exportSchemaToJson({ node }); + + expect(exported.name).toBe('w:del'); + expect(exported.elements).toHaveLength(5); + expect(exported.elements.some((n) => hasFieldCharType(n, 'begin'))).toBe(true); + expect(exported.elements.some((n) => hasFieldCharType(n, 'separate'))).toBe(true); + expect(exported.elements.some((n) => hasFieldCharType(n, 'end'))).toBe(true); + + const delInstrRun = exported.elements.find( + (n) => n?.name === 'w:r' && n?.elements?.some((el) => el?.name === 'w:delInstrText'), + ); + expect(delInstrRun).toBeTruthy(); + expect( + exported.elements.some((n) => n?.name === 'w:r' && n?.elements?.some((el) => el?.name === 'w:instrText')), + ).toBe(false); + + const delTextRun = exported.elements.find( + (n) => n?.name === 'w:r' && n?.elements?.some((el) => el?.name === 'w:delText'), + ); + expect(delTextRun?.elements?.find((el) => el?.name === 'w:delText')?.elements?.[0]?.text).toBe('10.6'); + }); + it('exports resolvedText when collaborative hydration stripped cached content', () => { const exported = exportSchemaToJson({ node: buildCrossReferenceNode({ diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js index 88a121fedd..4e13242699 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js @@ -7,7 +7,7 @@ import { stampImportTrackingAttrs, withParentFrame, } from '../../../../v2/importer/importTrackingContext.js'; -import { applyTrackedMarkToRunContent } from '../r/helpers/track-change-helpers.js'; +import { applyTrackedMarkToRunContent, renameTextElementsForDeletion } from '../r/helpers/track-change-helpers.js'; /** @type {import('@translator').XmlNodeName} */ const XML_NODE_NAME = 'w:del'; @@ -89,24 +89,28 @@ function decode(params) { node.marks = marks.filter((m) => m.type !== 'trackDelete'); - const translatedTextNode = exportSchemaToJson({ ...params, node }); + const translatedResult = exportSchemaToJson({ ...params, node }); if (params.isFinalDoc) { return null; } - // ECMA-376 (17.3.3.7) requires w:delText for ALL text runs inside . A + // A decoded node's export can be a single XML node (e.g. a plain text run) + // or an array of sibling nodes (e.g. a field's begin/instr/separate/result/end + // runs from crossReference-translator.js). Normalize to an array so both + // shapes wrap correctly under one instead of nesting an array inside + // `elements`. + const translatedNodes = Array.isArray(translatedResult) ? translatedResult : [translatedResult]; + + // ECMA-376 requires w:delText for ALL text runs inside (17.3.3.7) and + // w:delInstrText for field instruction runs inside (17.16.13). A // single run can now hold multiple siblings, because the newline export // safety net splits text around (e.g. AlphaBeta), - // so rename every direct w:t, not just the first; a leftover inside - // would not be treated as deleted. Other inline content - // (w:noBreakHyphen, w:tab, w:br, etc.) stays as-is; the wrapper alone - // conveys the deletion. - (translatedTextNode.elements || []) - .filter((n) => n.name === 'w:t') - .forEach((n) => { - n.name = 'w:delText'; - }); + // so rename every w:t/w:instrText found anywhere in the translated output, not + // just the first; a leftover / inside would not be + // treated as deleted. Other inline content (w:noBreakHyphen, w:tab, w:br, + // w:fldChar, etc.) stays as-is; the wrapper alone conveys the deletion. + translatedNodes.forEach(renameTextElementsForDeletion); return { name: 'w:del', @@ -116,7 +120,7 @@ function decode(params) { 'w:authorEmail': trackedMark.attrs.authorEmail, 'w:date': trackedMark.attrs.date, }, - elements: [translatedTextNode], + elements: translatedNodes, }; } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js index d9fc2d20db..0be0fe48ef 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.test.js @@ -266,6 +266,42 @@ describe('w:del translator', () => { expect(run.elements.some((n) => n.name === 'w:t')).toBe(false); }); + it('spreads a multi-node decode result (e.g. a deleted field) as siblings, renaming w:t and w:instrText (regression for plans/TASK.md)', () => { + const mockTrackedMark = { + type: 'trackDelete', + attrs: { + id: '901', + sourceId: '', + author: 'Orbital Copilot', + authorEmail: '', + date: '2026-07-30T12:30:01Z', + }, + }; + + // crossReference-translator.js (and other field translators) decode to an + // ARRAY of sibling w:r nodes (begin/instr/separate/result/end), not a + // single node with `.elements`. Before this fix, `elements: [translatedResult]` + // nested the array inside a single-element array instead of spreading it, + // and the rename step never touched w:instrText. + exportSchemaToJson.mockReturnValue([ + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'begin' } }] }, + { name: 'w:r', elements: [{ name: 'w:instrText', elements: [{ type: 'text', text: 'REF bm \\h' }] }] }, + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'separate' } }] }, + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: '10.6' }] }] }, + { name: 'w:r', elements: [{ name: 'w:fldChar', attributes: { 'w:fldCharType': 'end' } }] }, + ]); + + const node = { type: 'crossReference', marks: [mockTrackedMark] }; + const result = config.decode({ node }); + + expect(result.name).toBe('w:del'); + expect(result.elements).toHaveLength(5); + expect(result.elements.every((n) => n.name === 'w:r')).toBe(true); + + const names = result.elements.flatMap((n) => n.elements.map((el) => el.name)); + expect(names).toEqual(['w:fldChar', 'w:delInstrText', 'w:fldChar', 'w:delText', 'w:fldChar']); + }); + it('writes sourceId to w:id for round-trip fidelity', () => { const mockTrackedMark = { type: 'trackDelete', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/helpers/track-change-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/helpers/track-change-helpers.js index 2c8a340062..1144cdc8fc 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/helpers/track-change-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/helpers/track-change-helpers.js @@ -72,6 +72,18 @@ export const prepareRunTrackingContext = (node = {}) => { */ export const TRACKABLE_RUN_CONTENT_TYPES = new Set(['text', 'noBreakHyphen']); +/** + * Node types whose decode() path is confirmed to consult trackInsert/trackDelete + * marks on the node itself, rather than on a `content` child (see + * crossReference-translator.js). These are whole-field nodes (begin/instr/ + * separate/result/end runs collapsed into one PM node during import) where the + * deletion applies to the field as a unit, not to a single trackable content + * child. Widening this set requires adding the matching decode-side branch to + * that node type's own translator first — otherwise export would silently drop + * the tracked-change metadata. + */ +export const TRACKABLE_WHOLE_NODE_TYPES = new Set(['crossReference']); + /** * Stamp a tracked-change mark (trackInsert/trackDelete) onto every trackable * content child of each encoded run, not just the first. A run imported from @@ -79,12 +91,21 @@ export const TRACKABLE_RUN_CONTENT_TYPES = new Set(['text', 'noBreakHyphen']); * before its text — marking only content[0] when it happens to be text drops * tracking for that atom and for any content after it. * - * @param {Array<{ content?: Array> }>} subElements + * Field nodes (e.g. `crossReference`) are collapsed field structure, not plain + * runs — the mark belongs on the node itself so its own decode() can wrap the + * whole field (begin/instr/separate/result/end) in one `w:del`/`w:ins`. + * + * @param {Array<{ type?: string, content?: Array> }>} subElements * @param {string} markType * @param {Record} attrs */ export const applyTrackedMarkToRunContent = (subElements = [], markType, attrs) => { subElements.forEach((subElement) => { + if (subElement && TRACKABLE_WHOLE_NODE_TYPES.has(subElement.type)) { + const marks = Array.isArray(subElement.marks) ? subElement.marks : []; + subElement.marks = [...marks, { type: markType, attrs }]; + return; + } subElement.marks = []; const content = Array.isArray(subElement?.content) ? subElement.content : []; content.forEach((child) => { @@ -104,9 +125,20 @@ const mapTrackingAttrs = (mark, attrMap) => { return mapped; }; -const renameTextElementsForDeletion = (node) => { +/** + * Recursively renames text-bearing OOXML elements to their tracked-deletion + * equivalents, per ECMA-376 §17.13.5.15 (`w:t` → `w:delText`) and §17.16.13 + * (`w:instrText` → `w:delInstrText`), so content wrapped in `` stays + * schema-valid. Exported so translators that assemble multi-run field + * structure (e.g. crossReference-translator.js) can reuse the same rename + * pass when wrapping their output in one `w:del`. + * + * @param {any} node + */ +export const renameTextElementsForDeletion = (node) => { if (!node || typeof node !== 'object') return; if (node.name === 'w:t') node.name = 'w:delText'; + if (node.name === 'w:instrText') node.name = 'w:delInstrText'; if (Array.isArray(node.elements)) node.elements.forEach(renameTextElementsForDeletion); }; diff --git a/packages/super-editor/src/editors/v1/tests/data/behavior-fixtures/tracked-delete-crossreference-field.docx b/packages/super-editor/src/editors/v1/tests/data/behavior-fixtures/tracked-delete-crossreference-field.docx new file mode 100644 index 0000000000000000000000000000000000000000..16f3e31da9dbd2ab3bf6437143680def8050e900 GIT binary patch literal 37270 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 literal 0 HcmV?d00001 diff --git a/packages/super-editor/src/editors/v1/tests/import-export/tracked-delete-crossreference-field.test.js b/packages/super-editor/src/editors/v1/tests/import-export/tracked-delete-crossreference-field.test.js new file mode 100644 index 0000000000..7f99b70d60 --- /dev/null +++ b/packages/super-editor/src/editors/v1/tests/import-export/tracked-delete-crossreference-field.test.js @@ -0,0 +1,85 @@ +import { describe, expect, it, afterEach } from 'vitest'; +import * as xmljs from 'xml-js'; +import { loadTestDataForEditorTests, initTestEditor } from '@tests/helpers/helpers.js'; +import DocxZipper from '@core/DocxZipper.js'; + +// Regression fixture for the reported bug: a REF/cross-reference field entirely +// inside a tracked deletion loses its deletion context on V1 import/export — +// the single w:del splits into fragments and the field instruction revives as +// a live w:instrText. See plans/TASK.md. +const TEST_DOC = 'behavior-fixtures/tracked-delete-crossreference-field.docx'; + +const flattenText = (node) => { + if (!node) return ''; + if (node.type === 'text') return node.text ?? ''; + return (node.elements ?? []).map(flattenText).join(''); +}; + +const findParagraphContaining = (bodyElements, needle) => + bodyElements.find((el) => el.name === 'w:p' && flattenText(el).includes(needle)); + +const countByName = (node, name, count = { value: 0 }) => { + if (!node || typeof node !== 'object') return count.value; + if (node.name === name) count.value += 1; + (node.elements ?? []).forEach((child) => countByName(child, name, count)); + return count.value; +}; + +const findFldCharTypes = (node, types = []) => { + if (!node || typeof node !== 'object') return types; + if (node.name === 'w:fldChar') types.push(node.attributes?.['w:fldCharType']); + (node.elements ?? []).forEach((child) => findFldCharTypes(child, types)); + return types; +}; + +describe('tracked deletion around a cross-reference field (round-trip, no edits)', () => { + let editor; + + afterEach(() => { + editor?.destroy(); + editor = undefined; + }); + + const exportAndParseDocumentXml = async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests(TEST_DOC); + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true })); + + const exportedBuffer = await editor.exportDocx({ isFinalDoc: false }); + const zipper = new DocxZipper(); + const exportedFiles = await zipper.getDocxData(exportedBuffer, true); + const documentXml = exportedFiles.find((entry) => entry.name === 'word/document.xml')?.content; + expect(documentXml).toBeTruthy(); + + const parsed = xmljs.xml2js(documentXml, { compact: false }); + const documentRoot = parsed.elements.find((el) => el.name === 'w:document'); + const bodyRoot = documentRoot.elements.find((el) => el.name === 'w:body'); + return bodyRoot.elements; + }; + + it('keeps the deleted CASE field in one w:del with one w:delInstrText and no live w:instrText', async () => { + const bodyElements = await exportAndParseDocumentXml(); + const caseParagraph = findParagraphContaining(bodyElements, 'CASE — Rights under'); + expect(caseParagraph).toBeTruthy(); + + expect(countByName(caseParagraph, 'w:del')).toBe(1); + expect(countByName(caseParagraph, 'w:delInstrText')).toBe(1); + expect(countByName(caseParagraph, 'w:instrText')).toBe(0); + + const fldCharTypes = findFldCharTypes(caseParagraph); + expect(fldCharTypes).toEqual(['begin', 'separate', 'end']); + }); + + it('leaves the untracked CONTROL field intact and outside any w:del', async () => { + const bodyElements = await exportAndParseDocumentXml(); + const controlParagraph = findParagraphContaining(bodyElements, 'CONTROL — Rights under'); + expect(controlParagraph).toBeTruthy(); + + expect(countByName(controlParagraph, 'w:del')).toBe(0); + expect(countByName(controlParagraph, 'w:delInstrText')).toBe(0); + expect(countByName(controlParagraph, 'w:instrText')).toBe(1); + + const fldCharTypes = findFldCharTypes(controlParagraph); + expect(fldCharTypes).toEqual(['begin', 'separate', 'end']); + expect(flattenText(controlParagraph)).toContain('10.6'); + }); +}); From eb33a386871ea26c486f4ae10ce8791033053949 Mon Sep 17 00:00:00 2001 From: Artem Nistuley <101666502+artem-harbour@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:18:08 +0300 Subject: [PATCH 4/5] fix: merge tc chains split by word run boundaries into one revision (#1285) Co-authored-by: Artem Nistuley Ported-From-Source-Repo: superdoc/orbit Ported-From-Source-Commit: a606b8376beefb3868acf3bfb96a5b86c6edc682 Ported-Public-Prefix: superdoc/public --- .../v2/importer/trackedChangeIdMapper.js | 138 ++++++++- .../v2/importer/trackedChangeIdMapper.test.js | 268 +++++++++++++++++- .../w/ins/ins-translator.integration.test.js | 21 ++ ...er.repro-nobreakhyphen.integration.test.ts | 109 +++++++ .../helpers/tracked-change-resolver.test.ts | 63 ++++ .../helpers/tracked-change-resolver.ts | 90 ++++++ 6 files changed, 671 insertions(+), 18 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.repro-nobreakhyphen.integration.test.ts diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.js index ce9562724f..548acaba19 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.js @@ -3,7 +3,14 @@ import { v4 as uuidv4 } from 'uuid'; /** * @typedef {'paired' | 'independent'} TrackChangesReplacements - * @typedef {{ type: string, author: string, date: string, internalId?: string }} TrackedChangeEntry + * @typedef {{ + * type: string, + * author: string, + * date: string, + * internalId?: string, + * chained?: boolean, + * chainable?: boolean, + * }} TrackedChangeEntry * @typedef {{ beforeLastTrackedChange: TrackedChangeEntry | null, lastTrackedChange: TrackedChangeEntry | null, replacements: TrackChangesReplacements }} WalkContext */ @@ -44,6 +51,25 @@ function isReplacementPair(previous, current) { return previous.type !== current.type && previous.author === current.author && previous.date === current.date; } +/** + * Word frequently splits a single logical insertion/deletion into several + * adjacent same-type ``/`` XML elements purely because of run- + * boundary mechanics (a formatting change, a `w:noBreakHyphen` atom starting a + * new run, etc.) — Word's own UI still shows this as one revision. This is a + * distinct concept from `isReplacementPair` (opposite-type pairing per + * ECMA-376 §17.13.5) and is intentionally NOT gated by the `replacements` + * ('paired' | 'independent') option: that option only governs whether Word + * replacement halves are treated as one logical change or two independent + * ones, a question that doesn't apply to a same-type run split. + * + * @param {TrackedChangeEntry} previous + * @param {{ type: string, author: string, date: string }} current + * @returns {boolean} + */ +function isSameTypeChainContinuation(previous, current) { + return previous.type === current.type && previous.author === current.author && previous.date === current.date; +} + /** * @param {object} element * @returns {TrackedChangeEntry} @@ -139,32 +165,81 @@ function assignInternalId(element, idMap, context, insideTrackedChange, nextTrac nextTrackedChange, ); - if ( + const canPair = shouldPair && context.lastTrackedChange && + // A chained tail (the 2nd+ link of a same-type chain) shares its whole + // chain's id — pairing it into a NEW opposite-type replacement would + // silently fuse every earlier same-type link into that replacement too. + // Restrict pairing to a genuinely standalone previous element. + !context.lastTrackedChange.chained && !shouldKeepChildSides && - isReplacementPair(context.lastTrackedChange, current) - ) { + isReplacementPair(context.lastTrackedChange, current); + + const canChain = + !canPair && + context.lastTrackedChange && + // Only a still-"chainable" previous link may be extended — see the + // `!wasAlreadyMapped` note below for why a poisoned link clears this. + context.lastTrackedChange.chainable !== false && + isSameTypeChainContinuation(context.lastTrackedChange, current) && + // A wordId that already has a mapping came from an unrelated, earlier + // position in the document (Word reuses tracked-change ids). Chaining it + // onto the live chain here would retroactively fuse that unrelated + // earlier revision into this one — and, worse, propagate that borrowed + // id forward onto later chain links via `context.lastTrackedChange`. + !idMap.has(wordId); + + if (canPair) { // Second half of a replacement — share the first half's UUID, but only // if this w:id hasn't already been mapped. A reused id that was already // part of an earlier pair must keep its original mapping. if (!idMap.has(wordId)) { idMap.set(wordId, context.lastTrackedChange.internalId); } + // A replacement pair is fully "consumed" by this match — the next + // sibling starts a fresh candidate, unlike same-type chaining below. context.lastTrackedChange = null; context.beforeLastTrackedChange = null; + } else if (canChain) { + // Another link in the same-type chain — share the chain's UUID and keep + // the chain alive (do NOT reset) so a 3rd, 4th, ... sibling can still + // extend it. + const internalId = context.lastTrackedChange.internalId; + idMap.set(wordId, internalId); + context.beforeLastTrackedChange = context.lastTrackedChange; + context.lastTrackedChange = { ...current, internalId, chained: true, chainable: true }; } else { // Reuse an existing mapping when the same w:id appears more than once // (Word reuses tracked-change ids across the document). Minting a fresh // UUID here would overwrite the earlier entry and break any replacement // pair that was already recorded for this id. + const wasAlreadyMapped = idMap.has(wordId); const internalId = idMap.get(wordId) ?? uuidv4(); idMap.set(wordId, internalId); context.beforeLastTrackedChange = context.lastTrackedChange; - context.lastTrackedChange = { ...current, internalId }; + // A wordId that was already mapped elsewhere carries a borrowed, + // possibly-unrelated identity — mark it non-chainable so it can't drag a + // following same-type sibling onto that unrelated id (see `canChain`). + context.lastTrackedChange = { ...current, internalId, chainable: !wasAlreadyMapped }; } } +/** + * A paragraph break is itself a tracked change when its mark is recorded as + * ``/`` inside `w:pPr/w:rPr`. Returns that element, or null if + * the paragraph's mark isn't tracked. + * + * @param {object} pElement A `w:p` XML element + * @returns {object | null} + */ +function getParagraphMarkTrackedChangeElement(pElement) { + if (pElement?.name !== 'w:p') return null; + const pPr = pElement.elements?.find((el) => el.name === 'w:pPr'); + const rPr = pPr?.elements?.find((el) => el.name === 'w:rPr'); + return rPr?.elements?.find((el) => TRACKED_CHANGE_NAMES.has(el.name)) ?? null; +} + /** * Recursively walks XML elements, assigning internal UUIDs to every tracked * change and pairing adjacent replacements. @@ -194,6 +269,59 @@ function walkElements(elements, idMap, context, insideTrackedChange = false) { /* insideTrackedChange */ true, ); } + } else if (element.name === 'w:p' && !insideTrackedChange) { + // A paragraph boundary normally breaks any live chain (below). But when + // the paragraph break itself is a tracked change matching the live + // chain (same type/author/date), Word treats it as a continuation, not + // a break — nothing "final" separates the two sides. Bridge the chain + // across the boundary instead of resetting. + const paragraphMarkElement = getParagraphMarkTrackedChangeElement(element); + const bridges = + paragraphMarkElement && + context.lastTrackedChange && + isSameTypeChainContinuation(context.lastTrackedChange, trackedChangeEntryFromElement(paragraphMarkElement)); + + if (!bridges) { + context.lastTrackedChange = null; + context.beforeLastTrackedChange = null; + if (element.elements) walkElements(element.elements, idMap, context, insideTrackedChange); + } else { + const pPr = element.elements.find((el) => el.name === 'w:pPr'); + const rPr = pPr?.elements?.find((el) => el.name === 'w:rPr'); + + // Fold the paragraph-mark revision into the live chain exactly like + // an ordinary same-type sibling. + assignInternalId(paragraphMarkElement, idMap, context, /* insideTrackedChange */ false, null); + + // Walk pPr's OTHER properties (numPr, jc, spacing, ...) and rPr's + // other run properties in an isolated, throwaway context so they + // cannot reset the now-bridged chain — mirrors the existing + // nested-tracked-change isolation used when descending into a + // w:ins/w:del's own children above. + if (pPr?.elements) { + const pPrRest = pPr.elements.filter((el) => el !== rPr); + walkElements( + pPrRest, + idMap, + { beforeLastTrackedChange: null, lastTrackedChange: null, replacements: context.replacements }, + insideTrackedChange, + ); + if (rPr?.elements) { + const rPrRest = rPr.elements.filter((el) => el !== paragraphMarkElement); + walkElements( + rPrRest, + idMap, + { beforeLastTrackedChange: null, lastTrackedChange: null, replacements: context.replacements }, + insideTrackedChange, + ); + } + } + + // Walk the paragraph body (everything except pPr) with the live, + // now-bridged context so the paragraph's own runs continue the chain. + const bodyElements = element.elements.filter((el) => el !== pPr); + walkElements(bodyElements, idMap, context, insideTrackedChange); + } } else { // Content-bearing elements break replacement pairing. Only non-content // markers (comment/bookmark/permission ranges) are transparent. diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.test.js index 6842cfcfd8..1ffcfb01a3 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/trackedChangeIdMapper.test.js @@ -96,19 +96,6 @@ describe('buildTrackedChangeIdMap', () => { expect(idMap.get('20')).toBe(idMap.get('21')); }); - it('does NOT pair adjacent changes of the same type', () => { - const docx = createDocx( - paragraph( - trackedChange('w:del', '30', 'Alice', '2024-01-01T00:00:00Z'), - trackedChange('w:del', '31', 'Alice', '2024-01-01T00:00:00Z'), - ), - ); - - const idMap = buildTrackedChangeIdMap(docx); - - expect(idMap.get('30')).not.toBe(idMap.get('31')); - }); - it('does NOT pair changes with different authors', () => { const docx = createDocx( paragraph( @@ -147,6 +134,261 @@ describe('buildTrackedChangeIdMap', () => { expect(idMap.get('60')).not.toBe(idMap.get('61')); }); + describe('same-type chaining (Word splitting one logical revision into several XML fragments)', () => { + it('chains adjacent same-type/author/date changes into one group', () => { + // Word frequently emits several adjacent same-type / + // elements for what its own UI shows as one revision (e.g. a run + // boundary forced by a formatting change or a w:noBreakHyphen atom). + // This replaces the old "does NOT pair adjacent changes of the same + // type" expectation — that assertion was inverted on purpose. + const docx = createDocx( + paragraph( + trackedChange('w:del', '30', 'Alice', '2024-01-01T00:00:00Z'), + trackedChange('w:del', '31', 'Alice', '2024-01-01T00:00:00Z'), + ), + ); + + const idMap = buildTrackedChangeIdMap(docx); + + expect(idMap.get('30')).toBe(idMap.get('31')); + }); + + it('chains 3+ same-type siblings into a single group, not just a pair', () => { + const docx = createDocx( + paragraph( + trackedChange('w:ins', '1', 'NBH Repro', '2026-07-24T16:25:39Z'), + trackedChange('w:ins', '2', 'NBH Repro', '2026-07-24T16:25:39Z'), + trackedChange('w:ins', '3', 'NBH Repro', '2026-07-24T16:25:39Z'), + ), + ); + + const idMap = buildTrackedChangeIdMap(docx); + + expect(idMap.get('1')).toBe(idMap.get('2')); + expect(idMap.get('2')).toBe(idMap.get('3')); + }); + + it('does NOT chain same-type changes with different authors', () => { + const docx = createDocx( + paragraph( + trackedChange('w:ins', '1', 'Alice', '2024-01-01T00:00:00Z'), + trackedChange('w:ins', '2', 'Bob', '2024-01-01T00:00:00Z'), + ), + ); + + const idMap = buildTrackedChangeIdMap(docx); + + expect(idMap.get('1')).not.toBe(idMap.get('2')); + }); + + it('does NOT chain same-type changes separated by a content run', () => { + const docx = createDocx( + paragraph( + trackedChange('w:ins', '1', 'Alice', '2024-01-01T00:00:00Z'), + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ text: 'live text' }] }] }, + trackedChange('w:ins', '2', 'Alice', '2024-01-01T00:00:00Z'), + ), + ); + + const idMap = buildTrackedChangeIdMap(docx); + + expect(idMap.get('1')).not.toBe(idMap.get('2')); + }); + + it('applies same-type chaining regardless of the replacements option', () => { + // Same-type chaining is a distinct concept from Word replacement-pair + // detection and must not be gated by the 'independent' mode, which only + // governs opposite-type pairing. + const docx = createDocx( + paragraph( + trackedChange('w:ins', '1', 'Alice', '2024-01-01T00:00:00Z'), + trackedChange('w:ins', '2', 'Alice', '2024-01-01T00:00:00Z'), + ), + ); + + const idMap = buildTrackedChangeIdMap(docx, { replacements: 'independent' }); + + expect(idMap.get('1')).toBe(idMap.get('2')); + }); + + it('does NOT let a chained tail drag its whole chain into a new replacement pair', () => { + // ins('A') + ins('B') form a same-type chain, then del('C') is adjacent + // to the chain's tail (B) with matching author/date — opposite type. + // C must pair with, at most, B — it must NOT fuse A (an earlier, + // unrelated link of the chain) into the same identity as C. + const docx = createDocx( + paragraph( + trackedChange('w:ins', 'A', 'Alice', '2024-01-01T00:00:00Z'), + trackedChange('w:ins', 'B', 'Alice', '2024-01-01T00:00:00Z'), + trackedChange('w:del', 'C', 'Alice', '2024-01-01T00:00:00Z'), + ), + ); + + const idMap = buildTrackedChangeIdMap(docx); + + // The chain itself stays intact. + expect(idMap.get('A')).toBe(idMap.get('B')); + // But C must not be fused into the chain's shared id. + expect(idMap.get('C')).not.toBe(idMap.get('A')); + expect(idMap.get('C')).not.toBe(idMap.get('B')); + }); + + it('does NOT let a w:id reused elsewhere in the document poison a live chain', () => { + // '5' is used once, standalone, earlier in the document. Later, '5' is + // reused as the middle element of what would otherwise be a live + // same-type chain (10, 5, 11). The existing reused-id mapping for '5' + // must be preserved (not overwritten) — but that borrowed identity + // must NOT propagate forward and fuse '11' onto it, nor should it + // retroactively connect '10' to the unrelated earlier '5'. + const docx = createDocx( + paragraph(trackedChange('w:ins', '5', 'Alice', '2024-01-01T00:00:00Z')), + paragraph( + trackedChange('w:ins', '10', 'Alice', '2024-01-01T00:00:00Z'), + trackedChange('w:ins', '5', 'Alice', '2024-01-01T00:00:00Z'), + trackedChange('w:ins', '11', 'Alice', '2024-01-01T00:00:00Z'), + ), + ); + + const idMap = buildTrackedChangeIdMap(docx); + + expect(idMap.get('10')).not.toBe(idMap.get('5')); + expect(idMap.get('5')).not.toBe(idMap.get('11')); + expect(idMap.get('10')).not.toBe(idMap.get('11')); + }); + }); + + describe('bridging same-type chains across a tracked paragraph-mark insertion', () => { + function paragraphWithTrackedMark(markId, markAuthor, markDate, ...children) { + return { + name: 'w:p', + elements: [ + { + name: 'w:pPr', + elements: [ + { + name: 'w:rPr', + elements: [trackedChange('w:ins', markId, markAuthor, markDate)], + }, + ], + }, + ...children, + ], + }; + } + + it('bridges a same-type chain across a paragraph boundary when the paragraph mark matches', () => { + // Mirrors the real repro shape: paragraph 1 ends with a tracked-inserted + // paragraph mark (same author/date as the surrounding runs), so Word + // shows the whole thing as one revision, not two. + const docx = createDocx( + paragraph( + trackedChange('w:ins', '1', 'NBH Repro', '2026-07-24T16:25:39Z'), + trackedChange('w:ins', '2', 'NBH Repro', '2026-07-24T16:25:39Z'), + ), + paragraphWithTrackedMark( + '99', + 'NBH Repro', + '2026-07-24T16:25:39Z', + trackedChange('w:ins', '3', 'NBH Repro', '2026-07-24T16:25:39Z'), + ), + ); + + const idMap = buildTrackedChangeIdMap(docx); + + expect(idMap.get('1')).toBe(idMap.get('2')); + expect(idMap.get('2')).toBe(idMap.get('3')); + }); + + it('does NOT bridge when the paragraph mark has a different author than the preceding chain', () => { + const docx = createDocx( + paragraph( + trackedChange('w:ins', '1', 'NBH Repro', '2026-07-24T16:25:39Z'), + trackedChange('w:ins', '2', 'NBH Repro', '2026-07-24T16:25:39Z'), + ), + paragraphWithTrackedMark( + '99', + 'Someone Else', + '2026-07-24T16:25:39Z', + trackedChange('w:ins', '3', 'NBH Repro', '2026-07-24T16:25:39Z'), + ), + ); + + const idMap = buildTrackedChangeIdMap(docx); + + expect(idMap.get('2')).not.toBe(idMap.get('3')); + }); + + it('does NOT bridge when there is no tracked paragraph-mark insertion at all', () => { + const docx = createDocx( + paragraph( + trackedChange('w:ins', '1', 'NBH Repro', '2026-07-24T16:25:39Z'), + trackedChange('w:ins', '2', 'NBH Repro', '2026-07-24T16:25:39Z'), + ), + paragraph(trackedChange('w:ins', '3', 'NBH Repro', '2026-07-24T16:25:39Z')), + ); + + const idMap = buildTrackedChangeIdMap(docx); + + expect(idMap.get('2')).not.toBe(idMap.get('3')); + }); + + it('does not extend the bridge into a following change from a different author', () => { + // The paragraph mark matches the preceding chain, but the run AFTER it + // is a different author's insertion — that run must stand on its own. + const docx = createDocx( + paragraph( + trackedChange('w:ins', '1', 'NBH Repro', '2026-07-24T16:25:39Z'), + trackedChange('w:ins', '2', 'NBH Repro', '2026-07-24T16:25:39Z'), + ), + paragraphWithTrackedMark( + '99', + 'NBH Repro', + '2026-07-24T16:25:39Z', + trackedChange('w:ins', '3', 'Someone Else', '2026-07-24T16:25:39Z'), + ), + ); + + const idMap = buildTrackedChangeIdMap(docx); + + // The bridge element ('99') still joins the preceding chain... + expect(idMap.get('2')).toBe(idMap.get('99')); + // ...but the differently-authored run after it does not. + expect(idMap.get('3')).not.toBe(idMap.get('2')); + }); + + it('collapses the full repro shape (three paragraphs bridged by tracked paragraph marks) into one id', () => { + const AUTHOR = 'NBH Repro'; + const DATE = '2026-07-24T16:25:39Z'; + const docx = createDocx( + paragraph({ name: 'w:r', elements: [{ name: 'w:t', elements: [{ text: '15. Untracked paragraph.' }] }] }), + paragraphWithTrackedMark( + '0', + AUTHOR, + DATE, + trackedChange('w:ins', '1', AUTHOR, DATE), + trackedChange('w:ins', '2', AUTHOR, DATE), + trackedChange('w:ins', '3', AUTHOR, DATE), + ), + paragraphWithTrackedMark( + '4', + AUTHOR, + DATE, + trackedChange('w:ins', '5', AUTHOR, DATE), + trackedChange('w:ins', '6', AUTHOR, DATE), + trackedChange('w:ins', '7', AUTHOR, DATE), + ), + paragraphWithTrackedMark('8', AUTHOR, DATE, trackedChange('w:ins', '9', AUTHOR, DATE)), + paragraph({ name: 'w:r', elements: [{ name: 'w:t', elements: [{ text: '17. Untracked paragraph.' }] }] }), + ); + + const idMap = buildTrackedChangeIdMap(docx); + + const ids = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; + const uuids = new Set(ids.map((id) => idMap.get(id))); + expect(uuids.size).toBe(1); + }); + }); + it('preserves pairing across non-content markers (comment/bookmark ranges)', () => { const docx = createDocx( paragraph( diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.integration.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.integration.test.js index cbfbf7f8eb..7a55950726 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.integration.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.integration.test.js @@ -68,4 +68,25 @@ describe('w:ins importer-pipeline integration: run beginning with w:noBreakHyphe expect(hasTrackInsertMark(node)).toBe(true); }); }); + + it('exports every original per-run w:id individually, even though they now share one internal id', async () => { + // trackedChangeIdMapper.js's same-type chaining (added to fix "7 bubbles + // instead of 1" — see tracked-change-resolver.repro-nobreakhyphen.integration.test.ts) + // merges the *internal* mark id shared across all runs in the chain, but + // must not affect the *exported* w:id — each run keeps writing back its + // own original Word id via `sourceId`, independently of that merge. + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests( + 'behavior-fixtures/tracked-insert-nobreakhyphen.docx', + ); + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + const xml = await editor.exportDocx({ exportXmlOnly: true, isFinalDoc: false }); + const ids = [...xml.matchAll(/ match[1]); + + // Every exported id is distinct — no accidental collapse onto a shared id. + expect(new Set(ids).size).toBe(ids.length); + // The runs that were split by w:noBreakHyphen/paragraph marks in the + // fixture (paragraphs 16, 16A, 16B) all still round-trip individually. + expect(ids).toEqual(expect.arrayContaining(['1', '2', '3', '5', '6', '7', '9'])); + }); }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.repro-nobreakhyphen.integration.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.repro-nobreakhyphen.integration.test.ts new file mode 100644 index 0000000000..d36b2501e5 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.repro-nobreakhyphen.integration.test.ts @@ -0,0 +1,109 @@ +/* @vitest-environment jsdom */ + +/** + * Full, unmocked importer-pipeline regression test for the "V1 w:ins loses + * tracking when a run begins with w:noBreakHyphen" follow-up bug: Word shows + * paragraphs 16/16A/16B as ONE tracked insertion ("Insertions: 1" in its + * Reviewing pane — confirmed by a Word-vs-SuperDoc screenshot on the ticket), + * but before this fix SuperDoc's review panel showed 7 separate cards, one + * per underlying `` XML element that Word's own serializer split the + * single revision into (run-boundary mechanics: a `w:noBreakHyphen` atom + * starting a new run, or a tracked-inserted paragraph mark). + * + * Kept separate from tracked-change-resolver.test.ts, which globally mocks + * `getTrackChanges()`/`enumerateStructuralRowChanges()` — that file is right + * for unit-level coalescing logic, but a "real fixture" assertion placed + * there would never actually exercise import/grouping on a live document. + * This file loads the real fixture through the real Editor import path + * instead, mirroring extract-adapter.consumer-simulation.test.ts. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; +import type { Editor } from '../../core/Editor.js'; +import { groupTrackedChanges } from './tracked-change-resolver.js'; + +describe('groupTrackedChanges: repro_tracked_insert_nbh (real fixture, unmocked)', () => { + let editor: Editor; + + afterEach(() => { + if (editor) { + editor.destroy(); + editor = undefined as unknown as Editor; + } + }); + + it('groups the noBreakHyphen-split w:ins chain (paragraphs 16/16A/16B) into one tracked change', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests( + 'behavior-fixtures/tracked-insert-nobreakhyphen.docx', + ); + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + const grouped = groupTrackedChanges(editor); + const insertions = grouped.filter((change) => change.hasInsert && !change.hasDelete && !change.hasFormat); + + // Before the fix this fixture produced 7 separate cards (one per + // original Word run: sourceIds 1, 2, 3, 5, 6, 7, 9). It must now be one. + expect(insertions).toHaveLength(1); + + const [change] = insertions; + expect(change?.wordRevisionIds?.insert).toBeTruthy(); + // The excerpt should span all three paragraphs' text, not just one run. + // (Paragraph-mark boundaries in the fixture are joined with an en-space, + // not a regular space, so match on paragraph-number prefixes instead of + // full sentences.) + expect(change?.excerpt).toContain('Notwithstanding any other provision'); + expect(change?.excerpt).toContain('16A.'); + expect(change?.excerpt).toContain('16B.'); + expect(change?.excerpt).toContain('This control paragraph only exists'); + }); + + it('does not regress the control paragraph (16B, plain hyphen-minus, no noBreakHyphen)', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests( + 'behavior-fixtures/tracked-insert-nobreakhyphen.docx', + ); + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + const grouped = groupTrackedChanges(editor); + const insertions = grouped.filter((change) => change.hasInsert && !change.hasDelete && !change.hasFormat); + + // 16B is part of the same bridged chain (its own tracked paragraph mark + // matches the preceding chain's author/date), so it must NOT appear as + // a second, separate insertion entry. + expect(insertions).toHaveLength(1); + }); + + it('leaves the tracked paragraph-mark insertion (w:pPr/w:rPr/w:ins) semantics untouched', async () => { + // Regression guard: chaining the paragraph-mark element's *mark id* into + // the shared UUID (trackedChangeIdMapper.js 1b) must not disturb the + // separate, pre-existing mechanism that reads the paragraph mark's *raw* + // w:id off `paragraphProperties.runProperties.trackInsert.id` (see + // markImporter.js's isMatchingParagraphMarkInsertion / getInlineParagraphMarkInsertion, + // used together with a `w:rPrChange` sibling — not present in this + // fixture, but the raw-id field itself must still read correctly). + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests( + 'behavior-fixtures/tracked-insert-nobreakhyphen.docx', + ); + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + const docJson = editor.getJSON(); + const paragraphMarkIds = (docJson.content || []) + .filter((node: { type?: string }) => node.type === 'paragraph') + .map( + (node: { attrs?: { paragraphProperties?: { runProperties?: { trackInsert?: { id?: string } } } } }) => + node.attrs?.paragraphProperties?.runProperties?.trackInsert?.id, + ) + .filter((id: string | undefined): id is string => Boolean(id)); + + // The paragraph-mark tracked-insertions for paragraphs 16 and 16A (word + // ids "0" and "4" in this fixture) must still read as their own raw + // Word ids, untouched by the shared-chain UUID assigned internally by + // trackedChangeIdMapper.js. + expect(paragraphMarkIds).toEqual(expect.arrayContaining(['0', '4'])); + + // Neither paragraph-mark insertion should surface as its own separate + // tracked-change card in the review panel. + const grouped = groupTrackedChanges(editor); + const insertions = grouped.filter((change) => change.hasInsert && !change.hasDelete && !change.hasFormat); + expect(insertions).toHaveLength(1); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.test.ts index 2eb5657ce7..4dc4fe209b 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.test.ts @@ -410,6 +410,69 @@ describe('groupTrackedChanges', () => { }); }); +describe('groupTrackedChanges: coalescing same-type imported chains', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('merges same-id, same-pure-type imported drafts that differ only by sourceId', () => { + // Mirrors the noBreakHyphen-split repro: the importer's same-type + // chaining already gives these three marks one shared `id`, but each + // still carries its own original `sourceId` (Word's per-run w:id). + vi.mocked(getTrackChanges).mockReturnValue([ + { ...makeTrackMark(TrackInsertMarkName, 'shared-uuid', { sourceId: '1' }), from: 1, to: 5 }, + { ...makeTrackMark(TrackInsertMarkName, 'shared-uuid', { sourceId: '2' }), from: 5, to: 10 }, + { ...makeTrackMark(TrackInsertMarkName, 'shared-uuid', { sourceId: '3' }), from: 10, to: 15 }, + ] as never); + + const grouped = groupTrackedChanges(makeEditor()); + + expect(grouped).toHaveLength(1); + expect(grouped[0]?.from).toBe(1); + expect(grouped[0]?.to).toBe(15); + expect(grouped[0]?.hasInsert).toBe(true); + expect(grouped[0]?.hasDelete).toBe(false); + }); + + it('does not merge across unrelated ids even when both chains are pure inserts', () => { + vi.mocked(getTrackChanges).mockReturnValue([ + { ...makeTrackMark(TrackInsertMarkName, 'chain-a', { sourceId: '1' }), from: 1, to: 5 }, + { ...makeTrackMark(TrackInsertMarkName, 'chain-a', { sourceId: '2' }), from: 5, to: 10 }, + { ...makeTrackMark(TrackInsertMarkName, 'chain-b', { sourceId: '9' }), from: 20, to: 25 }, + ] as never); + + const grouped = groupTrackedChanges(makeEditor()); + + expect(grouped).toHaveLength(2); + }); + + it('does not merge a replacement pair sharing an id (opposite pure types)', () => { + // Regression guard for the coalescing pass specifically: a Word + // replacement (one pure-insert + one pure-delete draft sharing an `id`) + // must keep rendering as two cards. + vi.mocked(getTrackChanges).mockReturnValue([ + { ...makeTrackMark(TrackInsertMarkName, 'shared-uuid', { sourceId: '11' }), from: 1, to: 5 }, + { ...makeTrackMark(TrackDeleteMarkName, 'shared-uuid', { sourceId: '10' }), from: 5, to: 10 }, + ] as never); + + const grouped = groupTrackedChanges(makeEditor()); + + expect(grouped).toHaveLength(2); + }); + + it('does not touch native marks (no sourceId) sharing an id', () => { + vi.mocked(getTrackChanges).mockReturnValue([ + { ...makeTrackMark(TrackInsertMarkName, 'tc-1'), from: 1, to: 5 }, + { ...makeTrackMark(TrackInsertMarkName, 'tc-1'), from: 5, to: 10 }, + ] as never); + + const grouped = groupTrackedChanges(makeEditor()); + + expect(grouped).toHaveLength(1); + expect(grouped[0]?.rawId).toBe('tc-1'); + }); +}); + describe('resolveTrackedChange', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts index efbd85ad33..967ae60a57 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts @@ -156,6 +156,94 @@ function getTrackedChangeGroupKey( return sourceId ? `word:${markType}:${sourceId}` : fallbackId; } +/** + * Word often splits one logical insertion/deletion into several adjacent + * ``/`` XML elements purely due to run-boundary mechanics (a + * formatting change, a `w:noBreakHyphen` atom starting a new run, ...). + * `trackedChangeIdMapper.js`'s same-type chaining already assigns the whole + * chain one shared mark `id` on import — but `getTrackedChangeGroupKey` + * above keys primarily on `sourceId` (kept distinct per run for export round- + * trip fidelity), so the main grouping loop still produces one draft per + * original Word run. This pass folds those same-`id`, same-pure-type drafts + * back into one entry so the review UI shows one card per Word revision. + * + * Deliberately excluded: replacement pairs (one pure-insert draft + one + * pure-delete draft sharing an `id`) must NOT be merged here — they are + * intentionally shown as two cards (delete-strike + insert-underline). + * Restricting to homogeneous single-type drafts (`hasInsert` XOR + * `hasDelete`, never `hasFormat`) keeps replacement pairs untouched, since + * they fall under different chain-key prefixes below. Native marks (no + * `sourceId`) are skipped entirely — they already group correctly by `id` + * alone via the main loop. + * + * Trust boundary: this pass does not independently re-verify positional + * contiguity via `segmentsByRawId` — it trusts that the importer only ever + * assigns a shared `id` to genuinely contiguous/bridged chains. It is a + * normalization layer over that upstream decision, not a second adjacency + * check; if the importer ever mis-assigns a shared id, this pass will + * faithfully widen that mistake in the UI. Note that a mis-assigned shared + * `id` can corrupt more than this pass's merge: the main loop above also + * sets `commandRawId: id` per draft, and two entries that wrongly share an + * `id` (e.g. a same-type chain fused into an unrelated opposite-type + * replacement) would carry the same `commandRawId` even without ever + * reaching this pass — see `trackedChangeIdMapper.js`'s `canPair`/`canChain` + * guards, which exist specifically to prevent that upstream. + * + * Known limitation (deferred): the merged entry's `attrs`/`wordRevisionIds` + * retain only the first-seen draft's `sourceId`, not every original run's. + * Doesn't affect the reported bug or export (export reads `sourceId` off + * the live PM mark directly, not from this resolver). + */ +function coalesceSameTypeImportedChains( + byRawId: Map, + segmentsByRawId: Map>, +): void { + const canonicalKeyByChain = new Map(); + + for (const [groupKey, draft] of byRawId) { + const id = toNonEmptyString(draft.attrs.id); + const sourceId = toNonEmptyString(draft.attrs.sourceId); + if (!id || !sourceId) continue; + + const isPureInsert = draft.hasInsert && !draft.hasDelete && !draft.hasFormat; + const isPureDelete = draft.hasDelete && !draft.hasInsert && !draft.hasFormat; + if (!isPureInsert && !isPureDelete) continue; + + const chainKey = `${isPureInsert ? TrackInsertMarkName : TrackDeleteMarkName}:${id}`; + const canonicalKey = canonicalKeyByChain.get(chainKey); + if (!canonicalKey) { + canonicalKeyByChain.set(chainKey, groupKey); + continue; + } + if (canonicalKey === groupKey) continue; + + const canonical = byRawId.get(canonicalKey); + if (!canonical) continue; + + canonical.from = Math.min(canonical.from, draft.from); + canonical.to = Math.max(canonical.to, draft.to); + for (let i = 0; i < draft.excerptParts.length; i += 1) { + const range = draft.excerptRanges[i]; + if (!canonical.excerptRanges.some((counted) => rangesOverlap(counted, range))) { + canonical.excerptRanges.push(range); + canonical.excerptParts.push(draft.excerptParts[i]); + } + } + if (draft.wordRevisionIds) { + for (const key of Object.keys(draft.wordRevisionIds) as Array) { + canonical.wordRevisionIds = mergeWordRevisionId(canonical.wordRevisionIds, key, draft.wordRevisionIds[key]); + } + } + + const canonicalSegments = segmentsByRawId.get(canonicalKey) ?? []; + const draftSegments = segmentsByRawId.get(groupKey) ?? []; + segmentsByRawId.set(canonicalKey, canonicalSegments.concat(draftSegments)); + segmentsByRawId.delete(groupKey); + + byRawId.delete(groupKey); + } +} + function isTrackedMarkName(markType: string | undefined): boolean { return markType === TrackInsertMarkName || markType === TrackDeleteMarkName || markType === TrackFormatMarkName; } @@ -487,6 +575,8 @@ export function groupTrackedChanges(editor: Editor): GroupedTrackedChange[] { } } + coalesceSameTypeImportedChains(byRawId, segmentsByRawId); + const grouped = Array.from(byRawId.values()) .map(({ excerptParts, excerptRanges: _excerptRanges, ...change }) => { const hasWordSourceId = Boolean(toNonEmptyString(change.attrs.sourceId)); From 10e0adb4c54cdc8095d8ba6148131c9f8aa1294e Mon Sep 17 00:00:00 2001 From: Andrii Orlov <120495135+andrii-harbour@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:01:38 +0200 Subject: [PATCH 5/5] feat(v1): let agents delete a whole list item as a tracked suggestion (#1305) Ported-From-Source-Repo: superdoc/orbit Ported-From-Source-Commit: 332361aac27d99ce2510f39156a3e3badfc7df96 Ported-Public-Prefix: superdoc/public --- .../langs/node/src/__tests__/actions.test.ts | 161 ++++++++++ packages/sdk/langs/node/src/agent/actions.ts | 197 +++++++++++- .../node/src/embedded-prompts.generated.ts | 2 +- .../langs/node/src/prompts/system-prompt.md | 3 +- .../langs/python/superdoc/presets/custom.py | 3 +- .../langs/python/tests/test_custom_actions.py | 3 +- .../helpers/resolve-export-word-id.js | 50 +++ .../v3/handlers/w/del/del-translator.js | 30 +- .../v3/handlers/w/ins/ins-translator.js | 30 +- .../helpers/generate-paragraph-properties.js | 47 +++ .../p/helpers/legacy-handle-paragraph-node.js | 28 ++ .../paragraph-mark-deletion-export.test.js | 105 ++++++ .../w/p/helpers/translate-paragraph-node.js | 15 + .../w/tc/helpers/translate-table-cell.js | 11 + .../plan-engine/blocks-wrappers.test.ts | 19 +- .../blocks-wrappers.tracked-delete.test.ts | 216 +++++++++++++ .../plan-engine/blocks-wrappers.ts | 72 ++++- .../v1/extensions/paragraph/paragraph.js | 9 + .../review-model/decision-engine.js | 130 +++++++- .../paragraph-mark-deletion.test.js | 301 ++++++++++++++++++ .../review-model/review-graph.js | 145 ++++++++- .../review-model/test-fixtures.js | 6 +- .../paragraphMarkChanges.js | 78 +++++ 23 files changed, 1585 insertions(+), 76 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers/resolve-export-word-id.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/paragraph-mark-deletion-export.test.js create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.tracked-delete.test.ts create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/review-model/paragraph-mark-deletion.test.js create mode 100644 packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/paragraphMarkChanges.js diff --git a/packages/sdk/langs/node/src/__tests__/actions.test.ts b/packages/sdk/langs/node/src/__tests__/actions.test.ts index aa6f553db0..5003f78a09 100644 --- a/packages/sdk/langs/node/src/__tests__/actions.test.ts +++ b/packages/sdk/langs/node/src/__tests__/actions.test.ts @@ -88,6 +88,8 @@ function createMockDoc( headingCreateOptions: Array | undefined>; /** Second (MutationOptions) arg captured from blocks.deleteRange. */ deleteRangeOptions: Array | undefined>; + /** blocks.delete calls with target, InvokeOptions and the params-level changeMode. */ + blockDeleteCalls: Array<{ target: Record; options?: Record; changeMode?: string }>; /** Second (MutationOptions) arg captured from lists.create. */ listCreateOptions: Array | undefined>; /** lists.attach calls with both dialect channels captured. */ @@ -124,6 +126,11 @@ function createMockDoc( paragraphCreateOptions: [] as Array | undefined>, headingCreateOptions: [] as Array | undefined>, deleteRangeOptions: [] as Array | undefined>, + blockDeleteCalls: [] as Array<{ + target: Record; + options?: Record; + changeMode?: string; + }>, listCreateOptions: [] as Array | undefined>, listAttachCalls: [] as Array<{ args: Record; options?: Record }>, }; @@ -240,6 +247,37 @@ function createMockDoc( revision: state.revision, }; }, + // Single-block delete by nodeId (delete_blocks, delete_table). Direct + // mode removes the block; tracked mode leaves it in place and records one + // structural revision, mirroring the engine — that asymmetry is exactly + // what delete_blocks' verification has to account for. + delete: async ( + args: { target?: { nodeId?: string; nodeType?: string }; changeMode?: string }, + options?: Record, + ) => { + // changeMode rides INSIDE params on this runtime — the second argument + // is InvokeOptions (timeouts), not MutationOptions. The mock reads it + // the same way the transport does, so a regression that passes it in + // the wrong position shows up here as an untracked hard delete instead + // of passing silently. + calls.blockDeleteCalls.push({ target: args.target ?? {}, options, changeMode: args.changeMode }); + const nodeId = args.target?.nodeId; + const idx = state.blocks.findIndex((b) => b.nodeId === nodeId); + if (idx < 0) throw new Error(`no block ${String(nodeId)}`); + const block = state.blocks[idx]!; + if (args.changeMode === 'tracked') { + state.trackedChanges.push({ id: `tc-del-${nodeId}`, type: 'delete' }); + } else { + state.blocks.splice(idx, 1); + renumberBlocks(); + } + bump(); + return { + success: true as const, + deleted: { kind: 'block', nodeType: block.nodeType, nodeId: block.nodeId }, + revision: { before: 'prev', after: state.revision }, + }; + }, // Inclusive block-range delete by nodeId, used by the structure move // workflow (move_range). Returns the count removed; renumbers + bumps. deleteRange: async ( @@ -1139,6 +1177,129 @@ describe('superdoc_perform_action', () => { expect(state.blocks[0]?.text).toBe('a b c d.'); // no mutation }); + // "delete the first list item" used to route through delete_text, + // which strikes the runs and leaves the numbered paragraph — so accepting the + // tracked change left an empty `1.` behind. delete_blocks removes the block. + test('delete_blocks removes a whole list item, bullet included', async () => { + const { doc, state, calls } = createMockDoc([ + { ordinal: 1, nodeId: 'li1', nodeType: 'listItem', text: 'First obligation.' }, + { ordinal: 2, nodeId: 'li2', nodeType: 'listItem', text: 'Second obligation.' }, + { ordinal: 3, nodeId: 'li3', nodeType: 'listItem', text: 'Third obligation.' }, + ]); + const receipt = await superdocPerformAction(doc, { + action: 'delete_blocks', + selectors: [{ kind: 'nodeId', nodeId: 'li1' }], + }); + expect(receipt.status).toBe('ok'); + expect(state.blocks.map((b) => b.nodeId)).toEqual(['li2', 'li3']); + expect(calls.blockDeleteCalls).toHaveLength(1); + expect(calls.blockDeleteCalls[0]?.target).toEqual({ kind: 'block', nodeType: 'listItem', nodeId: 'li1' }); + }); + + test('delete_blocks deletes several blocks in one call', async () => { + const { doc, state } = createMockDoc([ + { ordinal: 1, nodeId: 'li1', nodeType: 'listItem', text: 'One.' }, + { ordinal: 2, nodeId: 'li2', nodeType: 'listItem', text: 'Two.' }, + { ordinal: 3, nodeId: 'li3', nodeType: 'listItem', text: 'Three.' }, + ]); + const receipt = await superdocPerformAction(doc, { + action: 'delete_blocks', + selectors: [ + { kind: 'nodeId', nodeId: 'li1' }, + { kind: 'nodeId', nodeId: 'li3' }, + ], + }); + expect(receipt.status).toBe('ok'); + expect(state.blocks.map((b) => b.nodeId)).toEqual(['li2']); + }); + + test('delete_blocks in tracked mode records a revision and leaves the block pending', async () => { + const { doc, state, calls } = createMockDoc([ + { ordinal: 1, nodeId: 'li1', nodeType: 'listItem', text: 'First obligation.' }, + { ordinal: 2, nodeId: 'li2', nodeType: 'listItem', text: 'Second obligation.' }, + ]); + const receipt = await superdocPerformAction(doc, { + action: 'delete_blocks', + selectors: [{ kind: 'nodeId', nodeId: 'li1' }], + changeMode: 'tracked', + }); + expect(receipt.status).toBe('ok'); + expect(calls.blockDeleteCalls[0]?.changeMode, 'changeMode must ride inside params on this runtime').toBe('tracked'); + // Block survives until the revision is decided — the verification must key + // on the tracked-change count, not the block count. + expect(state.blocks.map((b) => b.nodeId)).toEqual(['li1', 'li2']); + expect(state.trackedChanges).toHaveLength(1); + expect(receipt.verification?.[0]?.check.kind).toBe('tracked-change-count-delta'); + expect(receipt.verification?.every((v) => v.passed)).toBe(true); + }); + + test('delete_blocks accepts the singular selector form', async () => { + const { doc, state } = createMockDoc([ + { ordinal: 1, nodeId: 'p1', nodeType: 'paragraph', text: 'Doomed.' }, + { ordinal: 2, nodeId: 'p2', nodeType: 'paragraph', text: 'Survivor.' }, + ]); + const receipt = await superdocPerformAction(doc, { + action: 'delete_blocks', + selector: { kind: 'nodeId', nodeId: 'p1' }, + }); + expect(receipt.status).toBe('ok'); + expect(state.blocks.map((b) => b.nodeId)).toEqual(['p2']); + }); + + test('delete_blocks reports partial when only some selectors resolve', async () => { + const { doc, state } = createMockDoc([ + { ordinal: 1, nodeId: 'li1', nodeType: 'listItem', text: 'One.' }, + { ordinal: 2, nodeId: 'li2', nodeType: 'listItem', text: 'Two.' }, + ]); + const receipt = await superdocPerformAction(doc, { + action: 'delete_blocks', + selectors: [ + { kind: 'nodeId', nodeId: 'li1' }, + { kind: 'nodeId', nodeId: 'nope' }, + ], + }); + expect(receipt.status).toBe('partial'); + expect(state.blocks.map((b) => b.nodeId)).toEqual(['li2']); + expect(JSON.stringify(receipt.errors)).toContain('did not resolve'); + }); + + // A selector that does not even parse is a caller bug, not a miss: silently + // dropping it would delete the rest and report success, hiding half the + // request — the false-success shape this action exists to end. + test('delete_blocks rejects the whole call when any selector is malformed', async () => { + const { doc, state } = createMockDoc([ + { ordinal: 1, nodeId: 'li1', nodeType: 'listItem', text: 'One.' }, + { ordinal: 2, nodeId: 'li2', nodeType: 'listItem', text: 'Two.' }, + ]); + await expect( + superdocPerformAction(doc, { + action: 'delete_blocks', + selectors: [{ kind: 'nodeId', nodeId: 'li1' }, { kind: 'bogus' }], + } as never), + ).rejects.toThrow(/selectors\[1\]/); + expect(state.blocks.map((b) => b.nodeId)).toEqual(['li1', 'li2']); + }); + + test('delete_blocks refuses a table target and points at delete_table', async () => { + const { doc } = createMockDoc(); + const receipt = await superdocPerformAction(doc, { + action: 'delete_blocks', + selectors: [{ kind: 'ordinal', ordinalKind: 'tableOrdinal', value: 1 }], + }); + expect(receipt.status).toBe('failed'); + expect(receipt.executedOperations).toEqual([]); + }); + + test('delete_blocks without selectors is a teaching argument error', async () => { + // Same shape as delete_text's empty-finds guard: a missing required arg + // throws INVALID_ARGUMENT rather than burning a mutation on nothing. + const { doc, state } = createMockDoc(); + await expect(superdocPerformAction(doc, { action: 'delete_blocks', selectors: [] })).rejects.toMatchObject({ + code: 'INVALID_ARGUMENT', + }); + expect(state.blocks).toHaveLength(2); // no mutation + }); + test('redo_changes reports nothing to redo when no undo preceded it', async () => { const { doc } = createMockDoc(); const receipt: any = await superdocPerformAction(doc, { action: 'redo_changes', steps: 1 }); diff --git a/packages/sdk/langs/node/src/agent/actions.ts b/packages/sdk/langs/node/src/agent/actions.ts index 290aad49c7..eaca3046dc 100644 --- a/packages/sdk/langs/node/src/agent/actions.ts +++ b/packages/sdk/langs/node/src/agent/actions.ts @@ -37,6 +37,7 @@ export type ActionName = | 'insert_heading' | 'replace_text' | 'delete_text' + | 'delete_blocks' | 'append_list' | 'create_table' | 'comment_paragraphs' @@ -85,6 +86,7 @@ export type ActionArgs = | InsertHeadingArgs | ReplaceTextArgs | DeleteTextArgs + | DeleteBlocksArgs | AppendListArgs | AddListItemsArgs | ConvertListArgs @@ -158,6 +160,13 @@ export type DeleteTextArgs = { changeMode?: AgentChangeMode; }; +export type DeleteBlocksArgs = { + action: 'delete_blocks'; + /** One or more selectors, each resolving to exactly ONE body block. */ + selectors: readonly AgentSelector[]; + changeMode?: AgentChangeMode; +}; + export type AppendListArgs = { action: 'append_list'; items: readonly string[]; @@ -444,6 +453,7 @@ const ACTION_NAMES: readonly ActionName[] = [ 'insert_heading', 'replace_text', 'delete_text', + 'delete_blocks', 'append_list', 'create_table', 'comment_paragraphs', @@ -496,7 +506,9 @@ export const ACTION_HINTS: Record = { replace_text: 'edits[{find,replace}], optional selector to scope replacements to one inspected block, caseSensitive?, changeMode?', delete_text: - 'finds[], optional selector to scope deletions to ONE inspected block (required to delete stray whitespace — an unscoped whitespace find matches document-wide), caseSensitive?, changeMode?', + "finds[], optional selector to scope deletions to ONE inspected block (required to delete stray whitespace — an unscoped whitespace find matches document-wide), caseSensitive?, changeMode? — deletes TEXT ONLY, leaving the block (and a list item's bullet/number) in place. To remove a whole list item, paragraph or heading use delete_blocks", + delete_blocks: + 'selectors[] (each resolving to ONE inspected block: list item, paragraph or heading), changeMode? — THE way to DELETE a whole LIST ITEM, paragraph or heading, bullet/number included. Use for "remove the first item under Article II" / "delete that clause". delete_text only strikes the text and leaves an empty numbered item behind. Deletes several blocks in ONE call; use delete_table for a whole table', append_list: 'items[], kind?: ordered|bullet, headingText?, headingLevel?, placement? {at:"after"|"before",selector} builds the list at that block instead of document end', create_table: @@ -568,6 +580,7 @@ export const ACTION_GROUPS: ReadonlyArray<{ label: string; actions: readonly Act 'insert_heading', 'replace_text', 'delete_text', + 'delete_blocks', 'append_list', 'create_table', 'rewrite_block', @@ -630,6 +643,7 @@ export const ACTION_ARGS: Record = { insert_heading: ['text', 'level', 'placement', 'changeMode'], replace_text: ['edits', 'selector', 'caseSensitive', 'changeMode'], delete_text: ['finds', 'selector', 'caseSensitive', 'changeMode'], + delete_blocks: ['selectors', 'selector', 'changeMode'], append_list: ['items', 'kind', 'headingText', 'headingLevel', 'placement', 'changeMode'], create_table: ['rows', 'columns', 'cellTexts', 'placement', 'changeMode'], comment_paragraphs: ['commentText', 'scope', 'excludeBlockQuotes'], @@ -1838,6 +1852,154 @@ async function runDeleteText(doc: BoundDocApi, args: DeleteTextArgs): Promise { + const domains = new Set(['blocks', 'trackedChanges']); + for (const selector of args.selectors) { + for (const domain of snapshotDomainsForSelector(selector)) domains.add(domain); + } + const pre = await buildDocumentSnapshot(doc, { includeDomains: [...domains] }); + try { + if (args.selectors.length === 0) { + return failedReceipt('delete_blocks', new Error('selectors must be non-empty'), pre); + } + const deleteFn = maybeMethod(doc, ['blocks', 'delete']); + if (!deleteFn) { + throw new SuperDocCliError('doc.blocks.delete is not available on the document handle.', { + code: 'TOOL_DISPATCH_NOT_FOUND', + }); + } + + // Resolve EVERY selector against the same pre-snapshot before deleting + // anything. nodeIds are stable paragraph ids, so a target resolved up front + // stays addressable after a sibling is removed — whereas an ordinal or + // text-search selector re-resolved mid-run would drift onto the wrong block. + const targets: Array<{ selector: AgentSelector; nodeId: string; nodeType: string }> = []; + const unresolved: AgentSelector[] = []; + const wrongShape: Array<{ selector: AgentSelector; nodeType: string }> = []; + const seen = new Set(); + for (const selector of args.selectors) { + const target = selectorToBlockTarget(selector, pre); + if (!target) { + unresolved.push(selector); + continue; + } + if (!DELETABLE_BLOCK_NODE_TYPES.has(target.nodeType)) { + wrongShape.push({ selector, nodeType: target.nodeType }); + continue; + } + if (seen.has(target.nodeId)) continue; + seen.add(target.nodeId); + targets.push({ selector, nodeId: target.nodeId, nodeType: target.nodeType }); + } + + const errors: Array<{ code: string; message: string; recovery?: ReceiptRecovery }> = []; + for (const selector of unresolved) { + errors.push({ + code: 'ACTION_FAILED', + message: `selector did not resolve to a unique body block: ${JSON.stringify(selector)}`, + recovery: { kind: 'reinspect' }, + }); + } + for (const entry of wrongShape) { + errors.push({ + code: 'INVALID_ARGUMENT', + message: + `delete_blocks removes paragraph-shaped blocks (list items, paragraphs, headings); ` + + `${JSON.stringify(entry.selector)} resolved to a "${entry.nodeType}"` + + (entry.nodeType === 'table' ? ' — use delete_table for a whole table.' : '.'), + recovery: { kind: 'reinspect' }, + }); + } + if (targets.length === 0) { + return { + status: 'failed', + intent: 'delete_blocks', + preSnapshot: { revision: pre.revision, counts: pre.counts }, + selectedTargets: [], + executedOperations: [], + verification: [], + errors, + }; + } + + const changeMode = parseChangeMode(args.changeMode); + const executedOperations: Array<{ operationId: string; result?: unknown }> = []; + const deleted: Array<{ nodeId: string; nodeType: string }> = []; + for (const target of targets) { + try { + // changeMode travels INSIDE the params object on this runtime: the + // transport injects it from `params.changeMode` when the operation spec + // declares that param, and treats the second argument as InvokeOptions + // (timeouts), not MutationOptions. Passing it second — the shape + // `delete_table` uses — silently drops tracking and hard-deletes the + // block, which is worse than the bug this action exists to fix. + const result = await deleteFn({ + target: { kind: 'block', nodeType: target.nodeType, nodeId: target.nodeId }, + ...(changeMode ? { changeMode } : {}), + }); + executedOperations.push({ operationId: 'doc.blocks.delete', result: compactOpResult(result) }); + deleted.push({ nodeId: target.nodeId, nodeType: target.nodeType }); + } catch (err) { + errors.push({ + code: 'ACTION_FAILED', + message: `blocks.delete failed for ${target.nodeType} ${target.nodeId}: ${ + err instanceof Error ? err.message : String(err) + }`, + recovery: { kind: 'reinspect' }, + }); + } + } + + const post = await buildDocumentSnapshot(doc, { includeDomains: [...domains] }); + // Tracked deletions leave the block in place until the revision is decided, + // so the block count cannot move — count the structural revisions instead. + // Direct deletions must show one fewer block of each deleted node type. + const checks: AgentVerificationCheck[] = + changeMode === 'tracked' + ? [{ kind: 'tracked-change-count-delta', delta: deleted.length }] + : [...new Set(deleted.map((entry) => entry.nodeType))].map((nodeType) => ({ + kind: 'block-count-delta' as const, + nodeType, + delta: -deleted.filter((entry) => entry.nodeType === nodeType).length, + })); + const verification = evaluateChecks(pre, post, checks); + + const allApplied = deleted.length === args.selectors.length && errors.length === 0; + const verified = verification.every((v) => v.passed); + return { + status: !verified || deleted.length === 0 ? 'failed' : allApplied ? 'ok' : 'partial', + intent: `delete_blocks: ${deleted.length} block(s)`, + preSnapshot: { revision: pre.revision, counts: pre.counts }, + postSnapshot: { revision: post.revision, counts: post.counts }, + selectedTargets: targets.map((target) => ({ selector: target.selector, matched: [target.nodeId] })), + executedOperations, + verification, + deletedBlocks: deleted, + ...(errors.length ? { errors } : {}), + ...(errors.length + ? { nextStep: 'Re-inspect the document and retry the selectors that did not resolve to one block.' } + : {}), + }; + } catch (err) { + return failedReceipt('delete_blocks', err, pre); + } +} + async function runAppendList(doc: BoundDocApi, args: AppendListArgs): Promise { const pre = await buildDocumentSnapshot(doc); try { @@ -5746,6 +5908,39 @@ export async function superdocPerformAction(doc: BoundDocApi, args: unknown): Pr changeMode: parseChangeMode(args.changeMode), }); } + case 'delete_blocks': { + // Accept a single `selector` too: the model reaches for the singular form + // by analogy with delete_text/rewrite_block, and rejecting it would cost a + // turn for no reason. + const rawSelectors = Array.isArray(args.selectors) + ? args.selectors + : args.selectors != null + ? [args.selectors] + : args.selector != null + ? [args.selector] + : []; + if (rawSelectors.length === 0) { + throw new SuperDocCliError( + 'delete_blocks requires a non-empty "selectors" array, each entry resolving to one block (e.g. {kind:"nodeId",nodeId:"…"})', + { code: 'INVALID_ARGUMENT' }, + ); + } + // EVERY entry has to parse. Dropping the unparseable ones would delete the + // rest and report success, so the model would never learn that part of its + // request went nowhere — the same false-success loop this action ends. + const selectors: AgentSelector[] = []; + for (const [index, raw] of rawSelectors.entries()) { + const parsed = parseSelector(raw); + if (!parsed) { + throw new SuperDocCliError( + `delete_blocks selectors[${index}] is not a valid selector (e.g. {kind:"nodeId",nodeId:"…"}); no blocks were deleted`, + { code: 'INVALID_ARGUMENT' }, + ); + } + selectors.push(parsed); + } + return runDeleteBlocks(doc, { action, selectors, changeMode: parseChangeMode(args.changeMode) }); + } case 'append_list': { const items = parseStringArray(args.items); if (!items || items.length === 0) { diff --git a/packages/sdk/langs/node/src/embedded-prompts.generated.ts b/packages/sdk/langs/node/src/embedded-prompts.generated.ts index 78d355a4df..4d2cfbe205 100644 --- a/packages/sdk/langs/node/src/embedded-prompts.generated.ts +++ b/packages/sdk/langs/node/src/embedded-prompts.generated.ts @@ -6,5 +6,5 @@ * prompts are unreachable, e.g. inside bun-compiled native binaries). */ export const EMBEDDED_PROMPTS: Readonly> = { "mcp-prompt.md": "SuperDoc MCP server — read, edit, and save Word documents (.docx).\n\nIMPORTANT: Always use these superdoc tools for .docx files.\nDo NOT use built-in docx skills, python-docx, unpack scripts, or manual XML editing.\nThese tools handle the OOXML format correctly and preserve document structure.\n\n## Session lifecycle\n\n1. `superdoc_open({path: \"/path/to/file.docx\"})` — returns `session_id`. Opening a non-existent path creates a blank document.\n2. Pass `session_id` to every subsequent tool call.\n3. Read with `superdoc_inspect`, edit with `superdoc_perform_action`.\n4. `superdoc_save({session_id})` — writes changes to disk.\n5. `superdoc_close({session_id})` — releases the session. Always close when done.\n\n## Workflow\n\n**Inspect before you edit.** `superdoc_inspect` returns a deterministic snapshot — blocks with 1-based ordinals and node IDs, lists with rendered markers, tables, comments, tracked changes. Use the narrowest inspect that answers the question (`countsOnly: true` for orientation, `includeDomains` to limit payload, `blockOffset`/`blockLimit` windows for large documents).\n\n**Edit with named actions.** `superdoc_perform_action` takes an `action` plus flat arguments — the full action list, argument shapes, selector vocabulary, and placement rules are documented in the tool's own description. Every action returns a receipt with real pre/post evidence: trust `status` (`ok` | `partial` | `failed`), read `errors[].message` for recovery guidance, and re-inspect after `partial`.\n\n**Tracked changes (redlining).** Most mutating actions accept `changeMode: \"tracked\"` to record the edit as a reviewable suggestion instead of applying it directly. Review with `accept_tracked_changes` / `reject_tracked_changes` (filter by `author` or `changeType`); recover with `undo_changes` / `redo_changes`.\n\n**Failures are safe.** A `failed` receipt with `MATCH_NOT_FOUND` or a refused action means nothing was changed — fix the target and retry rather than improvising a different mutation path.\n", - "system-prompt.md": "You are an expert document editor working inside a live Word document. You know how documents actually work — headings structure content, numbering schemes carry legal meaning, tables hold data, tracked changes record intent, comments carry review. You edit the way a skilled human editor would: you understand what people MEAN, not just what they SAY, and you leave the document the way a professional would leave it.\n\n============================================================\nDOCUMENT INTUITION — how to interpret requests\n============================================================\n\nUsers speak in what they SEE, not in file-format terms. Translate their words into document reality before picking tools:\n\n- \"Section 2\" / \"the section about X\" means the HEADING plus everything under it up to the next same-level heading — never the OOXML section property. \"The table\" includes its contents. \"The list\" is the visible bulleted/numbered block. \"The header\" usually means a heading in the body, not the page header — unless they say page header/footer.\n- \"The heading\" / \"the title\" is whatever LOOKS like one — titles and ALL-CAPS headings are often styled plain PARAGRAPHS, not heading nodes. Target them by their TEXT; never conclude \"no such heading exists\" because nodeType filtering came up empty. (This is about FINDING a named heading — ordinal counts like \"the second paragraph\" still count every visible paragraph in order, including title-like and date-line paragraphs.)\n- FORMAT conversions keep the VALUE. \"Convert the date to ISO 8601\" means rewrite the document's EXISTING date in the new format (30 March 2026 → 2026-03-30) — read it first, convert THAT value. Never substitute today's date or any other value the user didn't give.\n- MOVE means relocate the SAME thing with ALL of its content and formatting. Use real move operations: move_range for a range of blocks or a whole \"section\" identified BY TEXT (works on visual sections — ALL-CAPS/bold styled-paragraph titles like PREAMBLE or SCHEDULE A, not just Word heading nodes), move_table for a whole table, move_text for a text span. move_range moves plain paragraph/heading text ONLY — a section that contains a table, list, or image must be moved piecewise (move_table for the table, narrower move_range calls for the text around it). NEVER \"move\" something by creating an empty copy at the destination and deleting the original, and NEVER delete-and-recreate a table (or chain inserts/undos) to relocate it — that loses content, formatting, and identity. If no move operation exists for a block type, say so before improvising.\n- TRANSFORM consumes its source. \"Make a table from this list\" = build the table from the list's items AND delete the list. \"Turn this paragraph into a heading\" leaves exactly one block. A transformation that leaves both the new thing and the old thing is a bug, not a result.\n- COMPLETE the obvious intent. \"Add a summary table\" implies plausible content and a sensible position. \"It's up to you\" means invent reasonable values and proceed — do not ask again. New content should blend in: insert_paragraphs, insert_heading, and add_list_items match surrounding style automatically (table inserts — create_table/insert_table_row — do NOT yet); mirror the document's tone and conventions in any text you write.\n- TEMPLATES stay templates. When the document is full of [insert]/blank placeholders, new structures MIRROR the placeholder pattern — a new party is another '[insert] of [insert] (\"…\")' entry, not a request for real-world details the template doesn't have. Never ask for data a template deliberately leaves blank.\n- PLACEMENT: when the user names a position, honor it exactly. When they DON'T, find where a professional editor would put it — inspect the structure first, then place by document convention: the title stays first (NEVER insert above it unless explicitly asked); a summary/abstract/TOC goes right after the title or intro, not at the very top of the file; new sections go in logical reading order, before back-matter (signature blocks, annexes, schedules); signature blocks go at the very end; definitions go with other definitions. Defaulting to document start or end because it is easy is wrong when the content has an obvious home.\n- LEGAL DOCUMENTS ARE CROSS-REFERENCED. Adding a party (or any defined actor) means updating EVERY structure that enumerates it: the Parties list; the Definitions section whenever one exists (ALWAYS add the '\"X\" means …' entry for the new actor — even if the existing definitions cover other kinds of terms); and signature blocks when present. Same for removals. An edit that touches only one of these is incomplete — finish the set in the same turn.\n- CLEANUP is part of the job. No leftover empty paragraphs, duplicate blocks, or orphaned numbering after an edit. If your edit creates debris, remove it in the same turn.\n- NUMBERS COME FROM SCHEMES, NEVER FROM TEXT. Do not type \"11.\" into a paragraph and call it a numbered heading — that fakes the rendering and breaks renumbering. Real numbering = attach_numbering (existing blocks) or the automatic attach on insert. If a numbered block's text starts with a typed number, that is a bug to fix, not a pattern to copy.\n- NEW SECTIONS in clause-numbered documents (clauses 1.–10.): \"add section 11\" means a clause heading numbered 11 in the SAME scheme plus body paragraphs under it. Create the title and body with insert_paragraphs, then attach_numbering the title with likeMarker of the last top-level clause (e.g. \"10.\") — it renders as \"11.\" automatically.\n- NEVER END THE TURN WITH THE DOCUMENT WORSE THAN YOU FOUND IT. If your own cleanup or undo removed content the user wanted — including text you wrote earlier this conversation — restore it IMMEDIATELY in the same turn (you know what it said; re-insert it or redo). Do not ask permission to repair damage you caused.\n- REVIEW means READING. \"Comment on what can be improved\" / \"review this\" / \"give feedback\" = read the actual text first (superdoc_inspect), then write comments that are SPECIFIC to each passage — quote or reference what the passage says and what to change. Identical boilerplate stamped on every paragraph is a failed review, not a review. comment_paragraphs applies ONE identical text everywhere — it is ONLY for broadcast notes (\"please verify this section\"); for review feedback use add_comments per target, each with its own text.\n- ACT when intent is plain; ask only when the request is genuinely ambiguous AND the wrong guess would be destructive. One clarifying question maximum, with your best-guess default stated.\n\n============================================================\nTOOLS — two of them\n============================================================\n\n superdoc_inspect — read-only deterministic document snapshot.\n superdoc_perform_action — named edit verbs we authored, tested, and validate statically. Pass {action:\"name\", ...flat args}.\n\nROUTING\n\nsuperdoc_perform_action is your edit surface — named verbs whose argument shape, target resolution, and verification we authored. Pick the action whose name matches the intent and whose slots fit the data (including looped data: insert_paragraphs, replace_text with multiple edits). If no action expresses the request, say what is missing rather than faking it.\n\nDefault workflow: inspect if you need orientation or targets → one action → read the receipt → stop with a one-sentence answer.\n\n============================================================\nACTIONS (superdoc_perform_action with flat args)\n============================================================\n\n- insert_paragraphs: texts (in final order) — or a single text for one paragraph. headingLevel makes the first item a heading (1-6). changeMode:\"tracked\" if asked. Skip placement to default to document end; placement:{at:\"after\"|\"before\",selector:{...}} to position. insert_paragraphs and insert_heading AUTOMATICALLY match the formatting (style, font, size, color) and numbering of neighbouring blocks — do NOT re-format after inserting unless asked.\n- insert_heading: text, level (1-6).\n- append_list: items (string array), kind:\"ordered\"|\"bullet\". headingText/headingLevel only if asked for a heading above. placement:{at:\"after\"|\"before\",selector:{...}} builds the list at that block instead of document end — when the list belongs inside a section, ALWAYS pass placement. The receipt's placement-honored check is the truth.\n- add_list_items: entries:[{text, level?}] (level relative to the anchor: 0 = same level, 1 = nested sub-item) — or items:[…] plain strings at the list level. Locate the list by anchorText (text inside one of its items) or listOrdinal (1-based). THE way to ADD items into an EXISTING list — reuses its numbering + markers, matches the anchor item's font/size/bold/colour automatically (receipt.formattingMatched — do NOT re-format after adding unless asked), joins imported list-looking paragraphs in place, and is tracked-safe. NOT append_list (which starts a brand-new list).\n- convert_list: kind:\"ordered\"|\"bullet\", with listOrdinal or anchorText for real lists, OR fromMarker+toMarker (rendered clause numbers from inspect, e.g. \"2.1.\", \"2.3.\") for NUMBERED-CLAUSE ranges — including heading-styled clauses, OR fromText+toText (exact text inside the FIRST and LAST of consecutive plain paragraphs) to convert existing paragraphs into a list IN PLACE. Sub-clauses in range included automatically. THE way to convert numbering<->bullets AND the way to make existing paragraphs a list; never rewrite text to fake it, never recreate-the-content-then-delete-the-originals (two chances to lose text — convert_list fromText/toText is one lossless call).\n- split_list: anchorText (text inside the item that should START the second list), restartNumbering? (default true). Splits ONE list into two at that item — the new list restarts at 1, nested sub-items stay with their parent. THE way to \"split the list starting at item N into a new list with reset numbering\"; never fake it with convert_list/attach_numbering. Direct edit (not tracked).\n- undo_changes: untilMarker (rendered marker from the original state, e.g. \"2.1.\") or steps (1-25). Deterministic revert — steps history back until the marker reappears; the receipt proves it. NEVER use steps > 1 blindly: prefer untilMarker, and after ANY undo verify (superdoc_inspect) that content you meant to KEEP still exists — if you overshot, redo_changes {steps:N} steps forward again to recover it.\n- redo_changes: steps (1-25, default 1). Steps history FORWARD to re-apply edits a prior undo removed — THE recovery for an undo overshoot. Only reaches the forward branch until a NEW edit is made.\n- attach_numbering: anchorText (text of the block) or nodeId, likeMarker (rendered marker of a sibling clause, e.g. \"10.\"). Makes an EXISTING block a numbered clause at the same scheme/level — \"make this the section 11 heading\" in a clause-numbered document is exactly this (it will render as the next number).\n- replace_text: edits:[{find,replace}], caseSensitive default false. selector to scope. changeMode:\"tracked\" if asked. The receipt reports editsApplied and editsSkipped per find — READ IT: a skipped find is not in the selected block; re-target instead of assuming success.\n- delete_text: finds:[string]. selector to scope deletions to ONE block — REQUIRED for whitespace-only finds (unscoped whitespace matches document-wide and is refused). changeMode:\"tracked\" if asked.\n- rewrite_block: selector, text. Inspect first to gather current text; never ask the user to paste text already in the doc.\n- create_table: rows, columns, optional cellTexts (2D array). rows counts ALL rows INCLUDING the header — header plus one data row is rows:2. placement defaults to document end. changeMode:\"tracked\" if asked (\"track-changes table\") — the insertion itself becomes a tracked change.\n- comment_paragraphs: commentText. excludeBlockQuotes:true to skip block quotes. Applies the SAME text to every BODY paragraph (it does NOT comment the title/heading) — broadcast notes only, never review feedback (see REVIEW rule). To comment a heading too, add_comments on it explicitly.\n- add_comments: commentText, and either selector (one block) or selectors:[…] to comment MANY blocks in ONE call with the same text. To comment every heading/section/clause, resolve their targets and pass them all in selectors:[…] — NEVER emit a separate add_comments call per block.\n- reply_to_comment: commentText (the reply body), and either anchorText (text the target comment is anchored on / mentions) or commentId. THE way to REPLY to an existing comment thread (\"reply to the comment about X\") — a threaded reply, not a new top-level comment.\n- resolve_comments: anchorText? (resolve only comments anchored on / mentioning that text; omit to resolve ALL open comments), reopen:true to reopen resolved comments instead. THE way to \"resolve the comment(s)\" / \"mark comments resolved\".\n- accept_tracked_changes / reject_tracked_changes: optional author:\"Full Name\", optional changeType:\"insert\"|\"delete\"|\"replacement\"|\"format\". \"Accept only the formatting changes\" = changeType:\"format\" (formatting revisions — bold/italic/underline/color — are a DISTINCT tracked-change type from text edits; text changes stay pending).\n- format_text: bold/italic/underline/strike:true, highlight:\"yellow\", color (named or hex), fontSize — applied to EVERY occurrence of targetText (or targetTexts:[\"…\",\"…\"] for several phrases in one call; or selector for a whole block). caseSensitive:true for exact case. changeMode:\"tracked\" produces format-type tracked changes. THE way to bold/italicize/underline/highlight/color text — find the literal texts first (inspect), then ONE call. NOTE: one call applies ONE set of properties to ALL its targets — to color two phrases DIFFERENT colors, make a SEPARATE call per color (do NOT batch different-colored phrases into one targetTexts call).\n- apply_style: selector (the block to restyle), then ONE of styleId (\"Heading2\"), headingLevel (1-6), or likeText (text inside the block whose style AND effective look to copy). \"Make Summary match the Parties heading\" = apply_style {selector:…, likeText:\"Parties\"}. THE way to restyle an existing block — never delete-and-recreate it.\n- normalize_body_font_size: fontSize:N.\n- set_font_family: fontFamily (\"Arial\", \"Times New Roman\"). selector (one block) or targetText/targetTexts (occurrences); omit both to set the WHOLE body font. changeMode:\"tracked\" if asked. THE way to change the typeface (\"change the font to X\").\n- apply_letter_spacing: selector, letterSpacing (points).\n- format_paragraph: selector, alignment:\"left\"|\"center\"|\"right\"|\"justify\". changeMode:\"tracked\" records the former alignment as a tracked change. THE way to set paragraph alignment.\n- set_paragraph_spacing: selector, lineSpacing (multiplier, e.g. 1.5 or 2), spaceBefore/spaceAfter (points). THE way to add spacing between paragraphs — NEVER insert blank paragraphs for spacing. Direct edit (not tracked).\n- insert_page_break: selector (the block that should START on a new page). THE way to \"start X on a new page\" — never push content down with empty paragraphs. Direct edit (not tracked).\n- add_hyperlink: text (exact existing text to link), url, optional tooltip. Makes existing text a clickable hyperlink. Direct edit (not tracked).\n- fill_placeholders: values:[...] and/or fields:[{label?,value}]. changeMode:\"tracked\" if asked.\n- move_range: fromText (text in the FIRST block of the range), toText? (text in the LAST block — omit to auto-extend across the whole VISUAL SECTION: from fromText up to the next heading-like/ALL-CAPS/bold title), then exactly ONE destination: afterText OR beforeText (text in the block to land after/before). Direct-only today: changeMode:\"tracked\" fails with no mutation because block-range deletion cannot be tracked. Moves a contiguous block range or a whole \"section\" identified BY TEXT — works on styled-paragraph sections (PREAMBLE, SCHEDULE A) that are NOT Word heading nodes. afterText on a heading-like block lands the range after that block's WHOLE section. Moves plain paragraph/heading text only: a range containing a table, list, or image is REFUSED with nothing changed (move tables with move_table; narrow the range around the rest). Use move_text for tracked text-span moves.\n- insert_toc: title (optional), placement (defaults to document_start).\n- move_text: text (the exact span/clause to relocate), afterText (destination — REQUIRED for a direct move). changeMode:\"tracked\" records the move as a redline (tracked delete of the source + tracked insert at the destination; afterText may then be omitted — the copy lands right after the struck source). For a text SPAN; whole sections = move_range, tables = move_table.\n- move_table: tableOrdinal? (default 1), placement {at:\"document_end\"|\"document_start\"|\"after\"|\"before\", selector?}. THE way to move a whole table in ONE call — never delete-and-recreate or chain inserts/undos to relocate a table.\n- delete_table: tableOrdinal? (default 1), changeMode?. THE way to delete an entire table in ONE call — never delete rows one by one or reuse another table.\n- style_table: tableOrdinal? (default 1), accentColor? (header fill hex). ONE call makes a table look professional: accent header row with white bold text, bold first column, banded rows, clean borders. Use after create_table or on any existing table.\n- insert_table_row: tableOrdinal (1-based), rowIndex (0-based; omit to append), position:\"above\"|\"below\", optional cellTexts. dryRun:true for preview.\n- insert_table_column: tableOrdinal, columnIndex (0-based; omit to append right), position:\"left\"|\"right\", optional headerText.\n- delete_table_row / delete_table_column: rowIndex or columnIndex required, tableOrdinal optional.\n- split_table: tableOrdinal, rowIndex (>=1), optional separatorText.\n\nSelector shapes:\n- {kind:\"nodeId\", nodeId}\n- {kind:\"ordinal\", ordinalKind:\"bodyParagraphOrdinal\"|\"paragraphOrdinal\"|\"headingOrdinal\"|\"tableOrdinal\"|\"listOrdinal\"|\"sectionOrdinal\"|\"blockOrdinal\", value:N}\n- {kind:\"tableCell\", tableOrdinal:N, rowIndex:R, columnIndex:C}\n- {kind:\"textSearch\", terms:[\"...\"], match:\"all\"|\"any\", occurrence:N, caseSensitive?:false, nodeTypes?:[\"paragraph\"|\"heading\"|\"listItem\"]}\n- {kind:\"placement\", at:\"document_end\"|\"document_start\"}\n- {kind:\"relative\", position:\"after\"|\"before\", target:selector}\n\n============================================================\nOPERATING RULES\n============================================================\n\n- RECEIPTS ARE THE TRUTH. status \"failed\" or \"partial\" means the job is NOT done — read errors/nextStep/revertHint, adjust, retry (up to 3 attempts) before explaining the blocker. Never end the turn right after a failed or partial receipt. Never claim something the receipt cannot prove.\n- STALENESS: markers, nodeIds, and counts from earlier turns are STALE after any mutation — including your own. Re-inspect before range operations; your own previous insert may have added items the user now means to include.\n- REVERTS: \"undo / revert / make it back\" = superdoc_perform_action undo_changes with untilMarker from the original state (a convert receipt's revertHint contains the exact call) — NEVER re-convert or re-edit to approximate the old state.\n- If the request names a target descriptively (\"the indented heading\", \"the second clause\"), inspect FIRST and use the block's actual text or nodeId — never invent find text.\n- Numbered legal clauses (\"2.3.\") usually live on numbered HEADINGS, not lists: if counts.lists is 0 but blocks carry numbering markers, target those blocks by nodeId. \"Add item 2.4\" = insert_paragraphs (one text) after the \"2.3.\" block; the tool attaches numbering and matches formatting automatically (check receipt.contextualFormatting).\n- ADDING SEVERAL items to an existing list or numbered sequence = ONE add_list_items call (it joins the sequence and is tracked-safe — it works even when the \"list\" is clause numbering and counts.lists is 0). NEVER a chain of insert_paragraphs calls: multi-paragraph inserts do not auto-join numbering, and the items will land as plain paragraphs.\n- New SECTION headings use the SAME headingLevel as sibling section headings (title is usually level 1; sections 2+). Never default to level 1.\n- paragraphOrdinal counts visible non-empty paragraphs; bodyParagraphOrdinal counts substantive body paragraphs after front matter. Prefer paragraphOrdinal for \"first/second paragraph\".\n- For literal ordinal rewrites, do not switch paragraphs because the matched one looks title-like or short. If a rewrite is a no-op, keep the target and change the rewrite.\n- For anchored rewrites or multi-term edits, prefer textSearch selectors over copied nodeIds. For clause text inside tables, use a tableCell selector with replace_text.\n- For tab-indented headings, replace only the visible text with replace_text (rewrite_block can delete the tab node).\n- For BULK or PATTERN transforms (every percentage, every date), use ONE replace_text call with multiple edits. For bulk FORMATTING (\"bold all the dates\"): read the matching texts first, then ONE format_text call with targetTexts.\n- For preview-only requests (\"show what it would look like\", \"don't save\"), pass dryRun:true and make NO other mutating call; describe the preview from the receipt.\n- Pure count questions: superdoc_inspect countsOnly:true, then stop. Use includeDomains to keep snapshots small.\n- Do not include doc or sessionId in tool args. Never rely on benchmark routing, eval metadata, or fixture names.\n- If the runtime truly cannot express the request, say what is missing instead of faking success.\n", + "system-prompt.md": "You are an expert document editor working inside a live Word document. You know how documents actually work — headings structure content, numbering schemes carry legal meaning, tables hold data, tracked changes record intent, comments carry review. You edit the way a skilled human editor would: you understand what people MEAN, not just what they SAY, and you leave the document the way a professional would leave it.\n\n============================================================\nDOCUMENT INTUITION — how to interpret requests\n============================================================\n\nUsers speak in what they SEE, not in file-format terms. Translate their words into document reality before picking tools:\n\n- \"Section 2\" / \"the section about X\" means the HEADING plus everything under it up to the next same-level heading — never the OOXML section property. \"The table\" includes its contents. \"The list\" is the visible bulleted/numbered block. \"The header\" usually means a heading in the body, not the page header — unless they say page header/footer.\n- \"The heading\" / \"the title\" is whatever LOOKS like one — titles and ALL-CAPS headings are often styled plain PARAGRAPHS, not heading nodes. Target them by their TEXT; never conclude \"no such heading exists\" because nodeType filtering came up empty. (This is about FINDING a named heading — ordinal counts like \"the second paragraph\" still count every visible paragraph in order, including title-like and date-line paragraphs.)\n- FORMAT conversions keep the VALUE. \"Convert the date to ISO 8601\" means rewrite the document's EXISTING date in the new format (30 March 2026 → 2026-03-30) — read it first, convert THAT value. Never substitute today's date or any other value the user didn't give.\n- MOVE means relocate the SAME thing with ALL of its content and formatting. Use real move operations: move_range for a range of blocks or a whole \"section\" identified BY TEXT (works on visual sections — ALL-CAPS/bold styled-paragraph titles like PREAMBLE or SCHEDULE A, not just Word heading nodes), move_table for a whole table, move_text for a text span. move_range moves plain paragraph/heading text ONLY — a section that contains a table, list, or image must be moved piecewise (move_table for the table, narrower move_range calls for the text around it). NEVER \"move\" something by creating an empty copy at the destination and deleting the original, and NEVER delete-and-recreate a table (or chain inserts/undos) to relocate it — that loses content, formatting, and identity. If no move operation exists for a block type, say so before improvising.\n- TRANSFORM consumes its source. \"Make a table from this list\" = build the table from the list's items AND delete the list. \"Turn this paragraph into a heading\" leaves exactly one block. A transformation that leaves both the new thing and the old thing is a bug, not a result.\n- COMPLETE the obvious intent. \"Add a summary table\" implies plausible content and a sensible position. \"It's up to you\" means invent reasonable values and proceed — do not ask again. New content should blend in: insert_paragraphs, insert_heading, and add_list_items match surrounding style automatically (table inserts — create_table/insert_table_row — do NOT yet); mirror the document's tone and conventions in any text you write.\n- TEMPLATES stay templates. When the document is full of [insert]/blank placeholders, new structures MIRROR the placeholder pattern — a new party is another '[insert] of [insert] (\"…\")' entry, not a request for real-world details the template doesn't have. Never ask for data a template deliberately leaves blank.\n- PLACEMENT: when the user names a position, honor it exactly. When they DON'T, find where a professional editor would put it — inspect the structure first, then place by document convention: the title stays first (NEVER insert above it unless explicitly asked); a summary/abstract/TOC goes right after the title or intro, not at the very top of the file; new sections go in logical reading order, before back-matter (signature blocks, annexes, schedules); signature blocks go at the very end; definitions go with other definitions. Defaulting to document start or end because it is easy is wrong when the content has an obvious home.\n- LEGAL DOCUMENTS ARE CROSS-REFERENCED. Adding a party (or any defined actor) means updating EVERY structure that enumerates it: the Parties list; the Definitions section whenever one exists (ALWAYS add the '\"X\" means …' entry for the new actor — even if the existing definitions cover other kinds of terms); and signature blocks when present. Same for removals. An edit that touches only one of these is incomplete — finish the set in the same turn.\n- CLEANUP is part of the job. No leftover empty paragraphs, duplicate blocks, or orphaned numbering after an edit. If your edit creates debris, remove it in the same turn.\n- NUMBERS COME FROM SCHEMES, NEVER FROM TEXT. Do not type \"11.\" into a paragraph and call it a numbered heading — that fakes the rendering and breaks renumbering. Real numbering = attach_numbering (existing blocks) or the automatic attach on insert. If a numbered block's text starts with a typed number, that is a bug to fix, not a pattern to copy.\n- NEW SECTIONS in clause-numbered documents (clauses 1.–10.): \"add section 11\" means a clause heading numbered 11 in the SAME scheme plus body paragraphs under it. Create the title and body with insert_paragraphs, then attach_numbering the title with likeMarker of the last top-level clause (e.g. \"10.\") — it renders as \"11.\" automatically.\n- NEVER END THE TURN WITH THE DOCUMENT WORSE THAN YOU FOUND IT. If your own cleanup or undo removed content the user wanted — including text you wrote earlier this conversation — restore it IMMEDIATELY in the same turn (you know what it said; re-insert it or redo). Do not ask permission to repair damage you caused.\n- REVIEW means READING. \"Comment on what can be improved\" / \"review this\" / \"give feedback\" = read the actual text first (superdoc_inspect), then write comments that are SPECIFIC to each passage — quote or reference what the passage says and what to change. Identical boilerplate stamped on every paragraph is a failed review, not a review. comment_paragraphs applies ONE identical text everywhere — it is ONLY for broadcast notes (\"please verify this section\"); for review feedback use add_comments per target, each with its own text.\n- ACT when intent is plain; ask only when the request is genuinely ambiguous AND the wrong guess would be destructive. One clarifying question maximum, with your best-guess default stated.\n\n============================================================\nTOOLS — two of them\n============================================================\n\n superdoc_inspect — read-only deterministic document snapshot.\n superdoc_perform_action — named edit verbs we authored, tested, and validate statically. Pass {action:\"name\", ...flat args}.\n\nROUTING\n\nsuperdoc_perform_action is your edit surface — named verbs whose argument shape, target resolution, and verification we authored. Pick the action whose name matches the intent and whose slots fit the data (including looped data: insert_paragraphs, replace_text with multiple edits). If no action expresses the request, say what is missing rather than faking it.\n\nDefault workflow: inspect if you need orientation or targets → one action → read the receipt → stop with a one-sentence answer.\n\n============================================================\nACTIONS (superdoc_perform_action with flat args)\n============================================================\n\n- insert_paragraphs: texts (in final order) — or a single text for one paragraph. headingLevel makes the first item a heading (1-6). changeMode:\"tracked\" if asked. Skip placement to default to document end; placement:{at:\"after\"|\"before\",selector:{...}} to position. insert_paragraphs and insert_heading AUTOMATICALLY match the formatting (style, font, size, color) and numbering of neighbouring blocks — do NOT re-format after inserting unless asked.\n- insert_heading: text, level (1-6).\n- append_list: items (string array), kind:\"ordered\"|\"bullet\". headingText/headingLevel only if asked for a heading above. placement:{at:\"after\"|\"before\",selector:{...}} builds the list at that block instead of document end — when the list belongs inside a section, ALWAYS pass placement. The receipt's placement-honored check is the truth.\n- add_list_items: entries:[{text, level?}] (level relative to the anchor: 0 = same level, 1 = nested sub-item) — or items:[…] plain strings at the list level. Locate the list by anchorText (text inside one of its items) or listOrdinal (1-based). THE way to ADD items into an EXISTING list — reuses its numbering + markers, matches the anchor item's font/size/bold/colour automatically (receipt.formattingMatched — do NOT re-format after adding unless asked), joins imported list-looking paragraphs in place, and is tracked-safe. NOT append_list (which starts a brand-new list).\n- convert_list: kind:\"ordered\"|\"bullet\", with listOrdinal or anchorText for real lists, OR fromMarker+toMarker (rendered clause numbers from inspect, e.g. \"2.1.\", \"2.3.\") for NUMBERED-CLAUSE ranges — including heading-styled clauses, OR fromText+toText (exact text inside the FIRST and LAST of consecutive plain paragraphs) to convert existing paragraphs into a list IN PLACE. Sub-clauses in range included automatically. THE way to convert numbering<->bullets AND the way to make existing paragraphs a list; never rewrite text to fake it, never recreate-the-content-then-delete-the-originals (two chances to lose text — convert_list fromText/toText is one lossless call).\n- split_list: anchorText (text inside the item that should START the second list), restartNumbering? (default true). Splits ONE list into two at that item — the new list restarts at 1, nested sub-items stay with their parent. THE way to \"split the list starting at item N into a new list with reset numbering\"; never fake it with convert_list/attach_numbering. Direct edit (not tracked).\n- undo_changes: untilMarker (rendered marker from the original state, e.g. \"2.1.\") or steps (1-25). Deterministic revert — steps history back until the marker reappears; the receipt proves it. NEVER use steps > 1 blindly: prefer untilMarker, and after ANY undo verify (superdoc_inspect) that content you meant to KEEP still exists — if you overshot, redo_changes {steps:N} steps forward again to recover it.\n- redo_changes: steps (1-25, default 1). Steps history FORWARD to re-apply edits a prior undo removed — THE recovery for an undo overshoot. Only reaches the forward branch until a NEW edit is made.\n- attach_numbering: anchorText (text of the block) or nodeId, likeMarker (rendered marker of a sibling clause, e.g. \"10.\"). Makes an EXISTING block a numbered clause at the same scheme/level — \"make this the section 11 heading\" in a clause-numbered document is exactly this (it will render as the next number).\n- replace_text: edits:[{find,replace}], caseSensitive default false. selector to scope. changeMode:\"tracked\" if asked. The receipt reports editsApplied and editsSkipped per find — READ IT: a skipped find is not in the selected block; re-target instead of assuming success.\n- delete_text: finds:[string]. selector to scope deletions to ONE block — REQUIRED for whitespace-only finds (unscoped whitespace matches document-wide and is refused). changeMode:\"tracked\" if asked. Deletes TEXT ONLY: the block survives, so a list item keeps its bullet/number and an accepted tracked deletion leaves an empty numbered item behind. To remove the item itself use delete_blocks.\n- delete_blocks: selectors:[…], each resolving to ONE block (list item, paragraph or heading). changeMode:\"tracked\" if asked. THE way to DELETE a whole LIST ITEM, paragraph or heading — the bullet/number goes with it and the remaining items renumber. \"Delete the first item under Article II\" / \"remove that clause\" is exactly this, NOT delete_text. Pass every target in ONE call. Use delete_table for a whole table.\n- rewrite_block: selector, text. Inspect first to gather current text; never ask the user to paste text already in the doc.\n- create_table: rows, columns, optional cellTexts (2D array). rows counts ALL rows INCLUDING the header — header plus one data row is rows:2. placement defaults to document end. changeMode:\"tracked\" if asked (\"track-changes table\") — the insertion itself becomes a tracked change.\n- comment_paragraphs: commentText. excludeBlockQuotes:true to skip block quotes. Applies the SAME text to every BODY paragraph (it does NOT comment the title/heading) — broadcast notes only, never review feedback (see REVIEW rule). To comment a heading too, add_comments on it explicitly.\n- add_comments: commentText, and either selector (one block) or selectors:[…] to comment MANY blocks in ONE call with the same text. To comment every heading/section/clause, resolve their targets and pass them all in selectors:[…] — NEVER emit a separate add_comments call per block.\n- reply_to_comment: commentText (the reply body), and either anchorText (text the target comment is anchored on / mentions) or commentId. THE way to REPLY to an existing comment thread (\"reply to the comment about X\") — a threaded reply, not a new top-level comment.\n- resolve_comments: anchorText? (resolve only comments anchored on / mentioning that text; omit to resolve ALL open comments), reopen:true to reopen resolved comments instead. THE way to \"resolve the comment(s)\" / \"mark comments resolved\".\n- accept_tracked_changes / reject_tracked_changes: optional author:\"Full Name\", optional changeType:\"insert\"|\"delete\"|\"replacement\"|\"format\". \"Accept only the formatting changes\" = changeType:\"format\" (formatting revisions — bold/italic/underline/color — are a DISTINCT tracked-change type from text edits; text changes stay pending).\n- format_text: bold/italic/underline/strike:true, highlight:\"yellow\", color (named or hex), fontSize — applied to EVERY occurrence of targetText (or targetTexts:[\"…\",\"…\"] for several phrases in one call; or selector for a whole block). caseSensitive:true for exact case. changeMode:\"tracked\" produces format-type tracked changes. THE way to bold/italicize/underline/highlight/color text — find the literal texts first (inspect), then ONE call. NOTE: one call applies ONE set of properties to ALL its targets — to color two phrases DIFFERENT colors, make a SEPARATE call per color (do NOT batch different-colored phrases into one targetTexts call).\n- apply_style: selector (the block to restyle), then ONE of styleId (\"Heading2\"), headingLevel (1-6), or likeText (text inside the block whose style AND effective look to copy). \"Make Summary match the Parties heading\" = apply_style {selector:…, likeText:\"Parties\"}. THE way to restyle an existing block — never delete-and-recreate it.\n- normalize_body_font_size: fontSize:N.\n- set_font_family: fontFamily (\"Arial\", \"Times New Roman\"). selector (one block) or targetText/targetTexts (occurrences); omit both to set the WHOLE body font. changeMode:\"tracked\" if asked. THE way to change the typeface (\"change the font to X\").\n- apply_letter_spacing: selector, letterSpacing (points).\n- format_paragraph: selector, alignment:\"left\"|\"center\"|\"right\"|\"justify\". changeMode:\"tracked\" records the former alignment as a tracked change. THE way to set paragraph alignment.\n- set_paragraph_spacing: selector, lineSpacing (multiplier, e.g. 1.5 or 2), spaceBefore/spaceAfter (points). THE way to add spacing between paragraphs — NEVER insert blank paragraphs for spacing. Direct edit (not tracked).\n- insert_page_break: selector (the block that should START on a new page). THE way to \"start X on a new page\" — never push content down with empty paragraphs. Direct edit (not tracked).\n- add_hyperlink: text (exact existing text to link), url, optional tooltip. Makes existing text a clickable hyperlink. Direct edit (not tracked).\n- fill_placeholders: values:[...] and/or fields:[{label?,value}]. changeMode:\"tracked\" if asked.\n- move_range: fromText (text in the FIRST block of the range), toText? (text in the LAST block — omit to auto-extend across the whole VISUAL SECTION: from fromText up to the next heading-like/ALL-CAPS/bold title), then exactly ONE destination: afterText OR beforeText (text in the block to land after/before). Direct-only today: changeMode:\"tracked\" fails with no mutation because block-range deletion cannot be tracked. Moves a contiguous block range or a whole \"section\" identified BY TEXT — works on styled-paragraph sections (PREAMBLE, SCHEDULE A) that are NOT Word heading nodes. afterText on a heading-like block lands the range after that block's WHOLE section. Moves plain paragraph/heading text only: a range containing a table, list, or image is REFUSED with nothing changed (move tables with move_table; narrow the range around the rest). Use move_text for tracked text-span moves.\n- insert_toc: title (optional), placement (defaults to document_start).\n- move_text: text (the exact span/clause to relocate), afterText (destination — REQUIRED for a direct move). changeMode:\"tracked\" records the move as a redline (tracked delete of the source + tracked insert at the destination; afterText may then be omitted — the copy lands right after the struck source). For a text SPAN; whole sections = move_range, tables = move_table.\n- move_table: tableOrdinal? (default 1), placement {at:\"document_end\"|\"document_start\"|\"after\"|\"before\", selector?}. THE way to move a whole table in ONE call — never delete-and-recreate or chain inserts/undos to relocate a table.\n- delete_table: tableOrdinal? (default 1), changeMode?. THE way to delete an entire table in ONE call — never delete rows one by one or reuse another table.\n- style_table: tableOrdinal? (default 1), accentColor? (header fill hex). ONE call makes a table look professional: accent header row with white bold text, bold first column, banded rows, clean borders. Use after create_table or on any existing table.\n- insert_table_row: tableOrdinal (1-based), rowIndex (0-based; omit to append), position:\"above\"|\"below\", optional cellTexts. dryRun:true for preview.\n- insert_table_column: tableOrdinal, columnIndex (0-based; omit to append right), position:\"left\"|\"right\", optional headerText.\n- delete_table_row / delete_table_column: rowIndex or columnIndex required, tableOrdinal optional.\n- split_table: tableOrdinal, rowIndex (>=1), optional separatorText.\n\nSelector shapes:\n- {kind:\"nodeId\", nodeId}\n- {kind:\"ordinal\", ordinalKind:\"bodyParagraphOrdinal\"|\"paragraphOrdinal\"|\"headingOrdinal\"|\"tableOrdinal\"|\"listOrdinal\"|\"sectionOrdinal\"|\"blockOrdinal\", value:N}\n- {kind:\"tableCell\", tableOrdinal:N, rowIndex:R, columnIndex:C}\n- {kind:\"textSearch\", terms:[\"...\"], match:\"all\"|\"any\", occurrence:N, caseSensitive?:false, nodeTypes?:[\"paragraph\"|\"heading\"|\"listItem\"]}\n- {kind:\"placement\", at:\"document_end\"|\"document_start\"}\n- {kind:\"relative\", position:\"after\"|\"before\", target:selector}\n\n============================================================\nOPERATING RULES\n============================================================\n\n- RECEIPTS ARE THE TRUTH. status \"failed\" or \"partial\" means the job is NOT done — read errors/nextStep/revertHint, adjust, retry (up to 3 attempts) before explaining the blocker. Never end the turn right after a failed or partial receipt. Never claim something the receipt cannot prove.\n- STALENESS: markers, nodeIds, and counts from earlier turns are STALE after any mutation — including your own. Re-inspect before range operations; your own previous insert may have added items the user now means to include.\n- REVERTS: \"undo / revert / make it back\" = superdoc_perform_action undo_changes with untilMarker from the original state (a convert receipt's revertHint contains the exact call) — NEVER re-convert or re-edit to approximate the old state.\n- If the request names a target descriptively (\"the indented heading\", \"the second clause\"), inspect FIRST and use the block's actual text or nodeId — never invent find text.\n- Numbered legal clauses (\"2.3.\") usually live on numbered HEADINGS, not lists: if counts.lists is 0 but blocks carry numbering markers, target those blocks by nodeId. \"Add item 2.4\" = insert_paragraphs (one text) after the \"2.3.\" block; the tool attaches numbering and matches formatting automatically (check receipt.contextualFormatting).\n- ADDING SEVERAL items to an existing list or numbered sequence = ONE add_list_items call (it joins the sequence and is tracked-safe — it works even when the \"list\" is clause numbering and counts.lists is 0). NEVER a chain of insert_paragraphs calls: multi-paragraph inserts do not auto-join numbering, and the items will land as plain paragraphs.\n- New SECTION headings use the SAME headingLevel as sibling section headings (title is usually level 1; sections 2+). Never default to level 1.\n- paragraphOrdinal counts visible non-empty paragraphs; bodyParagraphOrdinal counts substantive body paragraphs after front matter. Prefer paragraphOrdinal for \"first/second paragraph\".\n- For literal ordinal rewrites, do not switch paragraphs because the matched one looks title-like or short. If a rewrite is a no-op, keep the target and change the rewrite.\n- For anchored rewrites or multi-term edits, prefer textSearch selectors over copied nodeIds. For clause text inside tables, use a tableCell selector with replace_text.\n- For tab-indented headings, replace only the visible text with replace_text (rewrite_block can delete the tab node).\n- For BULK or PATTERN transforms (every percentage, every date), use ONE replace_text call with multiple edits. For bulk FORMATTING (\"bold all the dates\"): read the matching texts first, then ONE format_text call with targetTexts.\n- For preview-only requests (\"show what it would look like\", \"don't save\"), pass dryRun:true and make NO other mutating call; describe the preview from the receipt.\n- Pure count questions: superdoc_inspect countsOnly:true, then stop. Use includeDomains to keep snapshots small.\n- Do not include doc or sessionId in tool args. Never rely on benchmark routing, eval metadata, or fixture names.\n- If the runtime truly cannot express the request, say what is missing instead of faking success.\n", }; diff --git a/packages/sdk/langs/node/src/prompts/system-prompt.md b/packages/sdk/langs/node/src/prompts/system-prompt.md index fa013e4af3..7d375c9dce 100644 --- a/packages/sdk/langs/node/src/prompts/system-prompt.md +++ b/packages/sdk/langs/node/src/prompts/system-prompt.md @@ -49,7 +49,8 @@ ACTIONS (superdoc_perform_action with flat args) - redo_changes: steps (1-25, default 1). Steps history FORWARD to re-apply edits a prior undo removed — THE recovery for an undo overshoot. Only reaches the forward branch until a NEW edit is made. - attach_numbering: anchorText (text of the block) or nodeId, likeMarker (rendered marker of a sibling clause, e.g. "10."). Makes an EXISTING block a numbered clause at the same scheme/level — "make this the section 11 heading" in a clause-numbered document is exactly this (it will render as the next number). - replace_text: edits:[{find,replace}], caseSensitive default false. selector to scope. changeMode:"tracked" if asked. The receipt reports editsApplied and editsSkipped per find — READ IT: a skipped find is not in the selected block; re-target instead of assuming success. -- delete_text: finds:[string]. selector to scope deletions to ONE block — REQUIRED for whitespace-only finds (unscoped whitespace matches document-wide and is refused). changeMode:"tracked" if asked. +- delete_text: finds:[string]. selector to scope deletions to ONE block — REQUIRED for whitespace-only finds (unscoped whitespace matches document-wide and is refused). changeMode:"tracked" if asked. Deletes TEXT ONLY: the block survives, so a list item keeps its bullet/number and an accepted tracked deletion leaves an empty numbered item behind. To remove the item itself use delete_blocks. +- delete_blocks: selectors:[…], each resolving to ONE block (list item, paragraph or heading). changeMode:"tracked" if asked. THE way to DELETE a whole LIST ITEM, paragraph or heading — the bullet/number goes with it and the remaining items renumber. "Delete the first item under Article II" / "remove that clause" is exactly this, NOT delete_text. Pass every target in ONE call. Use delete_table for a whole table. - rewrite_block: selector, text. Inspect first to gather current text; never ask the user to paste text already in the doc. - create_table: rows, columns, optional cellTexts (2D array). rows counts ALL rows INCLUDING the header — header plus one data row is rows:2. placement defaults to document end. changeMode:"tracked" if asked ("track-changes table") — the insertion itself becomes a tracked change. - comment_paragraphs: commentText. excludeBlockQuotes:true to skip block quotes. Applies the SAME text to every BODY paragraph (it does NOT comment the title/heading) — broadcast notes only, never review feedback (see REVIEW rule). To comment a heading too, add_comments on it explicitly. diff --git a/packages/sdk/langs/python/superdoc/presets/custom.py b/packages/sdk/langs/python/superdoc/presets/custom.py index 24fbdf79b6..c48074b302 100644 --- a/packages/sdk/langs/python/superdoc/presets/custom.py +++ b/packages/sdk/langs/python/superdoc/presets/custom.py @@ -50,7 +50,7 @@ # Built-in core action names. MUST stay in sync with ACTION_NAMES_LIST in # node/src/agent/actions.ts (source of truth). Collision checks compare against # this set. (core's getCatalog lists the 3 tools, not the 35 action names, so a -# shared constant is the safest cross-runtime source (40 names). A unit test +# shared constant is the safest cross-runtime source (41 names). A unit test # asserts the exact set so any drift from Node fails loudly. # --------------------------------------------------------------------------- @@ -60,6 +60,7 @@ 'insert_heading', 'replace_text', 'delete_text', + 'delete_blocks', 'append_list', 'create_table', 'comment_paragraphs', diff --git a/packages/sdk/langs/python/tests/test_custom_actions.py b/packages/sdk/langs/python/tests/test_custom_actions.py index a4de09a078..93d40cd850 100644 --- a/packages/sdk/langs/python/tests/test_custom_actions.py +++ b/packages/sdk/langs/python/tests/test_custom_actions.py @@ -117,7 +117,7 @@ def test_rejects_duplicate_names(): extend_preset('core', id='dup', actions=[a, b]) -# The canonical 40 names from ACTION_NAMES_LIST in +# The canonical 41 names from ACTION_NAMES_LIST in # node/src/agent/actions.ts (source of truth). Asserting the EXACT set — not # just the count — makes any drift from Node fail loudly. _CANONICAL_ACTION_NAMES = sorted([ @@ -132,6 +132,7 @@ def test_rejects_duplicate_names(): 'comment_paragraphs', 'convert_list', 'create_table', + 'delete_blocks', 'delete_table', 'delete_table_column', 'delete_table_row', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers/resolve-export-word-id.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers/resolve-export-word-id.js new file mode 100644 index 0000000000..2478f594d8 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers/resolve-export-word-id.js @@ -0,0 +1,50 @@ +/** + * Allocate the OOXML `w:id` for a tracked revision on export. + * + * Word revision ids are decimal and per-part, but internally a revision is + * keyed by a logical UUID. The allocator maps one to the other and, because + * the same `logicalId` always yields the same `w:id` within a part, every + * carrier of one revision — a run ``, the paired `` of a + * replacement, the deleted paragraph MARK in `w:pPr/w:rPr` — exports under a + * single id and Word reads them as one change. + * + * Writing the internal UUID straight into `w:id` instead would be out of + * schema (ST_DecimalNumber) and would split one deletion into several + * revisions. + * + * Falls back to the raw source/logical id when no allocator is bound, which is + * what the pre-allocator round-trip path relied on. + * + * @param {any} params Decoder params; carries `converter.wordIdAllocator` and the part path. + * @param {any} attrs Tracked attrs: `{ id: logicalId, sourceId }`. + * @returns {string} + */ +export function resolveExportWordId(params, attrs) { + const sourceId = attrs?.sourceId; + /** @type {string | number | null | undefined} */ + let exportSourceId; + if (typeof sourceId === 'string' || typeof sourceId === 'number') { + exportSourceId = sourceId; + } else if (sourceId === null) { + exportSourceId = null; + } else if (sourceId === undefined) { + exportSourceId = undefined; + } else { + exportSourceId = String(sourceId); + } + const logicalId = typeof attrs?.id === 'string' ? attrs.id : ''; + const exportParams = + /** @type {import('@translator').SCDecoderConfig & { converter?: { wordIdAllocator?: import('@extensions/track-changes/review-model/word-id-allocator.js').WordIdAllocator | null }, currentPartPath?: string, filename?: string }} */ ( + params + ); + const allocator = exportParams?.converter?.wordIdAllocator; + const partPath = + exportParams?.currentPartPath || + (typeof exportParams?.filename === 'string' && exportParams.filename.length > 0 + ? `word/${exportParams.filename}` + : 'word/document.xml'); + if (allocator) { + return allocator.allocate({ partPath, sourceId: exportSourceId, logicalId }); + } + return /** @type {string} */ (sourceId || logicalId); +} diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js index 4e13242699..d2106b2539 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/del/del-translator.js @@ -8,6 +8,7 @@ import { withParentFrame, } from '../../../../v2/importer/importTrackingContext.js'; import { applyTrackedMarkToRunContent, renameTextElementsForDeletion } from '../r/helpers/track-change-helpers.js'; +import { resolveExportWordId } from '@converter/v3/handlers/helpers/resolve-export-word-id.js'; /** @type {import('@translator').XmlNodeName} */ const XML_NODE_NAME = 'w:del'; @@ -133,35 +134,6 @@ function decode(params) { * @param {Record} attrs * @returns {string} */ -function resolveExportWordId(params, attrs) { - const sourceId = attrs?.sourceId; - /** @type {string | number | null | undefined} */ - let exportSourceId; - if (typeof sourceId === 'string' || typeof sourceId === 'number') { - exportSourceId = sourceId; - } else if (sourceId === null) { - exportSourceId = null; - } else if (sourceId === undefined) { - exportSourceId = undefined; - } else { - exportSourceId = String(sourceId); - } - const logicalId = typeof attrs?.id === 'string' ? attrs.id : ''; - const exportParams = - /** @type {import('@translator').SCDecoderConfig & { converter?: { wordIdAllocator?: import('@extensions/track-changes/review-model/word-id-allocator.js').WordIdAllocator | null }, currentPartPath?: string, filename?: string }} */ ( - params - ); - const allocator = exportParams?.converter?.wordIdAllocator; - const partPath = - exportParams?.currentPartPath || - (typeof exportParams?.filename === 'string' && exportParams.filename.length > 0 - ? `word/${exportParams.filename}` - : 'word/document.xml'); - if (allocator) { - return allocator.allocate({ partPath, sourceId: exportSourceId, logicalId }); - } - return /** @type {string} */ (sourceId || logicalId); -} /** @type {import('@translator').NodeTranslatorConfig} */ export const config = { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js index b4a020eba1..741dbc9c7d 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/ins/ins-translator.js @@ -8,6 +8,7 @@ import { withParentFrame, } from '../../../../v2/importer/importTrackingContext.js'; import { applyTrackedMarkToRunContent } from '../r/helpers/track-change-helpers.js'; +import { resolveExportWordId } from '@converter/v3/handlers/helpers/resolve-export-word-id.js'; /** @type {import('@translator').XmlNodeName} */ const XML_NODE_NAME = 'w:ins'; @@ -115,35 +116,6 @@ function decode(params) { * @param {Record} attrs * @returns {string} */ -function resolveExportWordId(params, attrs) { - const sourceId = attrs?.sourceId; - /** @type {string | number | null | undefined} */ - let exportSourceId; - if (typeof sourceId === 'string' || typeof sourceId === 'number') { - exportSourceId = sourceId; - } else if (sourceId === null) { - exportSourceId = null; - } else if (sourceId === undefined) { - exportSourceId = undefined; - } else { - exportSourceId = String(sourceId); - } - const logicalId = typeof attrs?.id === 'string' ? attrs.id : ''; - const exportParams = - /** @type {import('@translator').SCDecoderConfig & { converter?: { wordIdAllocator?: import('@extensions/track-changes/review-model/word-id-allocator.js').WordIdAllocator | null }, currentPartPath?: string, filename?: string }} */ ( - params - ); - const allocator = exportParams?.converter?.wordIdAllocator; - const partPath = - exportParams?.currentPartPath || - (typeof exportParams?.filename === 'string' && exportParams.filename.length > 0 - ? `word/${exportParams.filename}` - : 'word/document.xml'); - if (allocator) { - return allocator.allocate({ partPath, sourceId: exportSourceId, logicalId }); - } - return /** @type {string} */ (sourceId || logicalId); -} /** @type {import('@translator').NodeTranslatorConfig} */ export const config = { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js index 76a7289178..cea9fed537 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js @@ -1,6 +1,7 @@ import { carbonCopy } from '@core/utilities/carbonCopy.js'; import { translator as wPPrNodeTranslator } from '../../pPr/pPr-translator.js'; import { createParagraphSplitInsertionElement, isParagraphSplitTrackFormatMark } from '../../../helpers.js'; +import { resolveExportWordId } from '@converter/v3/handlers/helpers/resolve-export-word-id.js'; function resolveExportPartPath(params = {}) { if (typeof params.currentPartPath === 'string' && params.currentPartPath.length > 0) return params.currentPartPath; @@ -66,6 +67,36 @@ function insertRunPropertiesInOrder(pPr, runProperties) { } } +/** + * Write the paragraph MARK's tracked deletion into `w:pPr/w:rPr` as `` + * (ECMA-376 §17.13.5.14). A whole-block tracked deletion marks the runs + * AND the paragraph mark; without this half Word reopens the file seeing only + * struck text, and accepting leaves the empty numbered item behind. + * + * @param {object} pPr + * @param {{ id?: string, sourceId?: string, author?: string, authorEmail?: string, date?: string }} markTrackChange + * @param {string} [wordId] Allocated OOXML `w:id`; falls back to the source/internal id. + * @returns {object} the same pPr, with the mark deletion recorded + */ +function appendParagraphMarkDeletion(pPr, markTrackChange, wordId) { + if (!pPr || !markTrackChange) return pPr; + if (!Array.isArray(pPr.elements)) pPr.elements = []; + const existingRunProperties = pPr.elements.find((element) => element?.name === 'w:rPr'); + const runProperties = existingRunProperties || { type: 'element', name: 'w:rPr', elements: [] }; + if (!Array.isArray(runProperties.elements)) runProperties.elements = []; + if (runProperties.elements.some((element) => element?.name === 'w:del')) return pPr; + + /** @type {Record} */ + const attributes = { 'w:id': String(wordId ?? markTrackChange.sourceId ?? markTrackChange.id ?? '') }; + if (markTrackChange.author) attributes['w:author'] = markTrackChange.author; + if (markTrackChange.authorEmail) attributes['w:authorEmail'] = markTrackChange.authorEmail; + if (markTrackChange.date) attributes['w:date'] = markTrackChange.date; + runProperties.elements.push({ type: 'element', name: 'w:del', attributes }); + + if (!existingRunProperties) insertRunPropertiesInOrder(pPr, runProperties); + return pPr; +} + function prependParagraphSplitInsertion(pPr, insertionElement) { if (!pPr || !insertionElement) return pPr; if (!Array.isArray(pPr.elements)) pPr.elements = []; @@ -147,6 +178,22 @@ export function generateParagraphProperties(params) { pPr = prependParagraphSplitInsertion(ensureParagraphPropertiesNode(pPr), insertionElement); } } + // The paragraph mark's own tracked deletion. `isFinalDoc` strips + // pending revisions for a clean export, so it is skipped there like the + // paragraph-split insertion above. + const markTrackChange = node.attrs?.markTrackChange; + if (!params?.isFinalDoc && markTrackChange?.type === 'paragraphMarkDelete') { + // Through the SAME allocator the run-level `w:del` uses. `w:id` is + // ST_DecimalNumber, and the internal id is a UUID; allocating on the shared + // logical id both keeps the value in schema and gives both halves of the + // deletion one id, so Word reads them as a single revision. + pPr = appendParagraphMarkDeletion( + ensureParagraphPropertiesNode(pPr), + markTrackChange, + resolveExportWordId(params, markTrackChange), + ); + } + const sectPr = node.attrs?.paragraphProperties?.sectPr; if (sectPr) { if (!pPr) { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js index 796b1e3f75..07f4670b67 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js @@ -3,6 +3,7 @@ import { mergeTextNodes } from '@converter/v2/importer/index.js'; import { parseProperties } from '@converter/v2/importer/importerHelpers.js'; import { resolveParagraphProperties } from '@converter/styles'; import { translator as w_pPrTranslator } from '@converter/v3/handlers/w/pPr'; +import { resolveTrackedChangeImportIds } from '@converter/v2/importer/importTrackingContext.js'; import { isInlineNode } from '../../../helpers/is-inline-node.js'; function getTableStyleId(path) { @@ -170,6 +171,33 @@ export const handleParagraphNode = (params) => { schemaNode.attrs.rsidRDefault = node.attributes?.['w:rsidRDefault']; schemaNode.attrs.filename = filename; + // The paragraph MARK's own tracked deletion, `w:pPr/w:rPr/w:del` + // (ECMA-376 §17.13.5.14). Word writes this alongside the run-level `w:del` + // when a whole paragraph or list item is deleted in tracked mode. Without + // reading it back, a Word-authored list-item deletion imports as struck text + // only and accepting it strands an empty numbered item. + const markRunProperties = pPr?.elements?.find((el) => el.name === 'w:rPr'); + const markDeletionElement = markRunProperties?.elements?.find((el) => el.name === 'w:del'); + if (markDeletionElement) { + const markAttributes = markDeletionElement.attributes || {}; + // Route the id through the SAME per-part map the run-level `w:del` uses + // (`del-translator.encode`). Word writes both halves of a whole-block + // deletion with one `w:id`, and the map folds them onto one logical id — + // keeping the raw source id here would split them into two revisions, so + // accepting the run deletion could not find the paragraph mark and the + // emptied block would survive. + const { sourceId, logicalId } = resolveTrackedChangeImportIds(params, markAttributes['w:id']); + schemaNode.attrs.markTrackChange = { + type: 'paragraphMarkDelete', + id: logicalId || (sourceId ? String(sourceId) : ''), + sourceId: sourceId ? String(sourceId) : undefined, + author: markAttributes['w:author'] || '', + authorEmail: markAttributes['w:authorEmail'] || '', + date: markAttributes['w:date'] || '', + importedAuthor: `${markAttributes['w:author'] || ''} (imported)`, + }; + } + // Pass through this paragraph's sectPr, if any const sectPr = pPr?.elements?.find((el) => el.name === 'w:sectPr'); if (sectPr) { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/paragraph-mark-deletion-export.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/paragraph-mark-deletion-export.test.js new file mode 100644 index 0000000000..dcba88f43f --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/paragraph-mark-deletion-export.test.js @@ -0,0 +1,105 @@ +// Export shape for a whole-block tracked deletion's paragraph MARK. +// +// Two things have to hold, and both were reported as defects on review: +// 1. `w:id` is ST_DecimalNumber, and the internal revision id is a UUID, so +// the mark must go through the same allocator the run-level `w:del` uses. +// Sharing the logical id also keeps both halves of one deletion under a +// single `w:id`, which is what makes Word treat them as one revision. +// 2. A final-doc export is the ACCEPTED state, so a block whose mark was +// deleted must not survive it — otherwise the export reproduces the empty +// numbered item the feature exists to remove. + +import { describe, it, expect } from 'vitest'; +import { generateParagraphProperties } from './generate-paragraph-properties.js'; +import { translateParagraphNode } from './translate-paragraph-node.js'; + +const MARK = { + type: 'paragraphMarkDelete', + id: 'b3f1c2de-0000-4000-8000-000000000001', + author: 'Alice Reviewer', + authorEmail: 'alice@example.com', + date: '2026-07-31T10:00:00Z', +}; + +const paragraphNode = (attrs = {}) => ({ + type: 'paragraph', + attrs: { markTrackChange: MARK, ...attrs }, + content: [], +}); + +const findMarkDeletion = (pPr) => + pPr?.elements?.find((el) => el.name === 'w:rPr')?.elements?.find((el) => el.name === 'w:del'); + +describe('paragraph-mark deletion export', () => { + it('allocates a decimal w:id instead of writing the internal UUID', () => { + const allocated = []; + const allocator = { + allocate: ({ logicalId }) => { + allocated.push(logicalId); + return '7'; + }, + }; + const pPr = generateParagraphProperties({ + node: paragraphNode(), + converter: { wordIdAllocator: allocator }, + currentPartPath: 'word/document.xml', + }); + const del = findMarkDeletion(pPr); + expect(del, 'the paragraph mark must be exported').toBeTruthy(); + expect(del.attributes['w:id']).toBe('7'); + // Allocated on the LOGICAL id, so the run-level half of the same deletion + // resolves to the same w:id. + expect(allocated).toEqual([MARK.id]); + }); + + it('falls back to the internal id when no allocator is bound', () => { + const pPr = generateParagraphProperties({ node: paragraphNode() }); + expect(findMarkDeletion(pPr).attributes['w:id']).toBe(MARK.id); + }); + + it('drops a fully deleted block from a final-doc export', () => { + // No surviving content: the struck runs are already gone by this point. + const result = translateParagraphNode({ + node: paragraphNode(), + isFinalDoc: true, + relationships: [], + children: [], + }); + expect(result, 'an accepted whole-block deletion must not export').toBeUndefined(); + }); + + it('keeps the paragraph in a final-doc export when content survives', () => { + // Partially deleted: the accepted result is a merge into the successor, + // which this per-node translator cannot express — dropping the paragraph + // would silently lose text that was never deleted. + const result = translateParagraphNode({ + node: { + type: 'paragraph', + attrs: { markTrackChange: MARK }, + content: [{ type: 'text', text: 'survives' }], + }, + isFinalDoc: true, + relationships: [], + children: [], + }); + expect(result?.name).toBe('w:p'); + }); +}); + +describe('table cell content invariant on final export', () => { + it('keeps a paragraph in a cell whose only paragraph was deleted', async () => { + const { translateTableCell } = await import('../../tc/helpers/translate-table-cell.js'); + // Every child dropped — what a final export does to a cell holding one + // fully tracked-deleted paragraph. `` alone is not valid CT_Tc + // content, so Word would offer to repair the file. + const cell = translateTableCell({ + node: { type: 'tableCell', attrs: {}, content: [] }, + relationships: [], + isFinalDoc: true, + }); + expect(cell.name).toBe('w:tc'); + const blocks = cell.elements.filter((el) => el && el.name !== 'w:tcPr'); + expect(blocks.length, 'a cell must keep at least one block-level child').toBeGreaterThan(0); + expect(blocks[0].name).toBe('w:p'); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js index 66a3ea83f6..fe0583841a 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/translate-paragraph-node.js @@ -233,6 +233,21 @@ export function translateParagraphNode(params) { // Insert paragraph properties at the beginning of the elements array const pPr = generateParagraphProperties(params); + // A final-doc export is the ACCEPTED state, so a block whose paragraph mark + // was deleted must not survive it. `del-translator.decode` has already + // dropped the struck runs, which leaves nothing but the properties — and + // emitting that is precisely the empty numbered item whole-block deletion + // exists to remove. Returning nothing drops the paragraph, the same shape + // the del translator uses to drop deleted content. + // + // Only when nothing survives. If some content is still live the accepted + // result is a MERGE into the successor, which this per-node translator + // cannot express, so the paragraph is kept rather than silently dropping + // text that was never deleted. + if (params.isFinalDoc && params.node?.attrs?.markTrackChange?.type === 'paragraphMarkDelete' && !elements.length) { + return undefined; + } + if (pPr) elements.unshift(pPr); let attributes = {}; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tc/helpers/translate-table-cell.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tc/helpers/translate-table-cell.js index 2b799627e3..bd256440ec 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tc/helpers/translate-table-cell.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tc/helpers/translate-table-cell.js @@ -17,6 +17,17 @@ export function translateTableCell(params) { tableCell: params.node, }); + // A `` must contain at least one block-level element (ECMA-376 + // CT_Tc); `` alone is not valid content and Word offers to repair the + // file. A cell can end up with nothing when every child drops out of the + // export — which a final-doc export does to a paragraph whose mark was + // tracked-deleted. Keep the cell inhabited, matching what accepting that + // deletion does in the editor: the collapse refuses to cross a cell + // boundary, so a cell's paragraph is emptied, never removed. + if (!elements.length) { + elements.push({ name: 'w:p', elements: [] }); + } + const cellProps = generateTableCellProperties(params.node); elements.unshift(cellProps); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.test.ts index b20c82d8c7..328c0ba31b 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.test.ts @@ -347,13 +347,24 @@ describe('blocksDeleteWrapper', () => { } }); - it('uses tracked transaction metadata when tracked mode is requested', () => { - const { editor, dispatch, tr } = makeBlockDeleteEditor(); + it('uses tracked transaction metadata for non-paragraph-shaped tracked deletes', () => { + // Whole-block deletion split this path in two. A table has no paragraph mark, so it + // still goes through tr.delete + forceTrackChanges and lets the inline + // compiler do the work. Paragraph-shaped blocks now author the run marks + // AND the paragraph mark themselves — covered against a real schema in + // blocks-wrappers.tracked-delete.test.ts, since that path needs a live + // doc rather than this stub transaction. + const table = createNode('table', [], { + attrs: { blockId: 't1', sdBlockId: 't1' }, + isBlock: true, + inlineContent: false, + }); + const { editor, dispatch, tr } = makeBlockDeleteEditor({ children: [table] }); - const result = blocksDeleteWrapper(editor, makeInput('paragraph', 'p1'), { changeMode: 'tracked' }); + const result = blocksDeleteWrapper(editor, makeInput('table', 't1'), { changeMode: 'tracked' }); expect(result.success).toBe(true); - expect(tr.delete).toHaveBeenCalledWith(0, 7); + expect(tr.delete).toHaveBeenCalled(); expect(tr.setMeta).toHaveBeenCalledWith('inputType', 'programmatic'); expect(tr.setMeta).toHaveBeenCalledWith('forceTrackChanges', true); expect(dispatch).toHaveBeenCalledWith(tr); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.tracked-delete.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.tracked-delete.test.ts new file mode 100644 index 0000000000..f4ff2573a0 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.tracked-delete.test.ts @@ -0,0 +1,216 @@ +// @ts-nocheck +/** + * Tracked whole-block deletion authoring, against a REAL ProseMirror + * schema and state. + * + * The sibling blocks-wrappers.test.ts drives a stub transaction, which is fine + * for target resolution and receipt shape but cannot exercise mark authoring + * (no live doc). This file covers what actually changed: a tracked + * `blocks.delete` on a paragraph-shaped block must mark every run AND stamp the + * paragraph mark, both under ONE revision id. Marking only the runs is the + * reported bug — accepting that leaves an empty numbered list item behind. + */ + +import { describe, expect, it, vi } from 'vitest'; +import { Schema } from 'prosemirror-model'; +import { EditorState } from 'prosemirror-state'; + +import { blocksDeleteWrapper } from './blocks-wrappers.js'; +import { registerBuiltInExecutors } from './register-executors.js'; +import { + TrackDeleteMarkName, + TrackInsertMarkName, + TrackFormatMarkName, +} from '../../extensions/track-changes/constants.js'; + +registerBuiltInExecutors(); + +const USER = { name: 'Alice Reviewer', email: 'alice@example.com' }; + +const MARK_ATTRS = { + id: { default: '' }, + author: { default: '' }, + authorId: { default: '' }, + authorEmail: { default: '' }, + authorImage: { default: '' }, + date: { default: '' }, + sourceId: { default: '' }, + importedAuthor: { default: '' }, + revisionGroupId: { default: '' }, + splitFromId: { default: '' }, + changeType: { default: '' }, + before: { default: null }, + after: { default: null }, +}; + +const schema = new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { + content: 'inline*', + group: 'block', + attrs: { + paraId: { default: null }, + sdBlockId: { default: null }, + paragraphProperties: { default: null }, + markTrackChange: { default: null }, + }, + parseDOM: [{ tag: 'p' }], + toDOM: () => ['p', 0], + }, + text: { group: 'inline' }, + // Mirrors the real image node: an inline LEAF, which is what decides + // whether markDeletion reaches it. + image: { group: 'inline', inline: true, atom: true, attrs: { src: { default: '' } }, toDOM: () => ['img'] }, + }, + marks: { + [TrackInsertMarkName]: { attrs: MARK_ATTRS }, + [TrackDeleteMarkName]: { attrs: MARK_ATTRS }, + [TrackFormatMarkName]: { attrs: MARK_ATTRS }, + }, +}); + +function makeEditor() { + const doc = schema.node('doc', null, [ + schema.node('paragraph', { paraId: 'p1', sdBlockId: 'p1' }, [schema.text('First item')]), + schema.node('paragraph', { paraId: 'p2', sdBlockId: 'p2' }, [schema.text('Second item')]), + ]); + let state = EditorState.create({ doc, schema }); + + const editor = { + schema, + options: { user: USER }, + commands: { insertTrackedChange: vi.fn(() => true) }, + helpers: { + blockNode: { + getBlockNodeById: (id: string) => { + const matches: Array<{ node: unknown; pos: number }> = []; + state.doc.descendants((node, pos) => { + if (node.attrs?.sdBlockId === id) matches.push({ node, pos }); + }); + return matches; + }, + }, + }, + get state() { + return state; + }, + dispatch: (tr: unknown) => { + state = state.apply(tr as never); + }, + }; + + return { + editor: editor as never, + getState: () => state, + }; +} + +const deleteMarksIn = (node) => { + let count = 0; + node.descendants((child) => { + count += child.marks.filter((mark) => mark.type.name === TrackDeleteMarkName).length; + }); + return count; +}; + +describe('tracked blocks.delete authoring', () => { + it('marks the runs AND the paragraph mark under one revision id', () => { + const { editor, getState } = makeEditor(); + + const result = blocksDeleteWrapper( + editor, + { target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' } }, + { changeMode: 'tracked' }, + ); + expect(result.success).toBe(true); + + const next = getState(); + // The block survives until the revision is decided — this is a suggestion. + expect(next.doc.childCount).toBe(2); + + const target = next.doc.child(0); + const markTrackChange = target.attrs.markTrackChange; + expect(markTrackChange, 'paragraph mark must be recorded as deleted').toBeTruthy(); + expect(markTrackChange.type).toBe('paragraphMarkDelete'); + expect(markTrackChange.author).toBe(USER.name); + + // Every run is struck, and shares the paragraph mark's revision id — one + // decidable change, not two. + expect(deleteMarksIn(target)).toBeGreaterThan(0); + target.descendants((child) => { + for (const mark of child.marks.filter((m) => m.type.name === TrackDeleteMarkName)) { + expect(mark.attrs.id).toBe(markTrackChange.id); + } + }); + + // The untouched sibling stays clean. + expect(next.doc.child(1).attrs.markTrackChange).toBeFalsy(); + expect(deleteMarksIn(next.doc.child(1))).toBe(0); + }); + + // The V2 kernel strikes a TEXT SPAN, so a drawing (no visible length) slipped + // through unstruck and survived acceptance. V1 marks inline LEAVES, which + // includes images — this pins that difference so it stays true. + it('strikes a picture in the block, not just its text', () => { + const doc = schema.node('doc', null, [ + schema.node('paragraph', { paraId: 'p1', sdBlockId: 'p1' }, [ + schema.node('image', { src: 'rId50' }), + schema.text('Caption'), + ]), + schema.node('paragraph', { paraId: 'p2', sdBlockId: 'p2' }, [schema.text('Second item')]), + ]); + let state = EditorState.create({ doc, schema }); + const editor = { + schema, + options: { user: USER }, + commands: { insertTrackedChange: vi.fn(() => true) }, + helpers: { + blockNode: { + getBlockNodeById: (id: string) => { + const matches: Array<{ node: unknown; pos: number }> = []; + state.doc.descendants((node, pos) => { + if (node.attrs?.sdBlockId === id) matches.push({ node, pos }); + }); + return matches; + }, + }, + }, + get state() { + return state; + }, + dispatch: (tr: unknown) => { + state = state.apply(tr as never); + }, + }; + + const result = blocksDeleteWrapper( + editor as never, + { target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' } }, + { changeMode: 'tracked' }, + ); + expect(result.success).toBe(true); + + const target = state.doc.child(0); + const changeId = target.attrs.markTrackChange.id; + let imageMarked = false; + target.descendants((child) => { + if (child.type.name !== 'image') return; + imageMarked = child.marks.some((mark) => mark.type.name === TrackDeleteMarkName && mark.attrs.id === changeId); + }); + expect(imageMarked, 'the picture must be struck under the same revision').toBe(true); + }); + + it('direct mode still removes the block outright', () => { + const { editor, getState } = makeEditor(); + + const result = blocksDeleteWrapper(editor, { + target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, + }); + expect(result.success).toBe(true); + + const next = getState(); + expect(next.doc.childCount).toBe(1); + expect(next.doc.child(0).textContent).toBe('Second item'); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.ts index 83dae76eb3..a26c091172 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/blocks-wrappers.ts @@ -7,6 +7,7 @@ */ import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import type { Transaction } from 'prosemirror-state'; import type { Editor } from '../../core/Editor.js'; import { DELETABLE_BLOCK_NODE_TYPES, @@ -25,6 +26,8 @@ import { import { clearIndexCache, getBlockIndex } from '../helpers/index-cache.js'; import { textContentInBlock } from '../helpers/text-offset-resolver.js'; import { TrackDeleteMarkName } from '../../extensions/track-changes/constants.js'; +import { markDeletion } from '../../extensions/track-changes/trackChangesHelpers/markDeletion.js'; +import { v4 as uuidv4 } from 'uuid'; import { findBlockByIdStrict, mapBlockNodeType, @@ -480,15 +483,78 @@ export function blocksDeleteWrapper( checkRevision(editor, options?.expectedRevision); const tr = editor.state.tr; - tr.delete(candidate.pos, candidate.end); - if (mode === 'tracked') applyTrackedMutationMeta(tr); - else applyDirectMutationMeta(tr); + if (mode === 'tracked' && isParagraphShaped(candidate)) { + // A plain `tr.delete` with forceTrackChanges routes through the + // inline compiler, which marks the RUNS and leaves the paragraph node + // untouched — so accepting the change strands an empty numbered item and + // the rest of the list never renumbers. Word instead marks both the runs + // and the paragraph MARK, and acceptance collapses the emptied paragraph + // into its successor. Author both halves here under one revision id. + if (!authorTrackedBlockDeletion(editor, tr, candidate)) { + throw new DocumentApiAdapterError( + 'CAPABILITY_UNAVAILABLE', + 'blocks.delete could not record a tracked deletion for this block.', + { reason: 'tracked_authoring_failed' }, + ); + } + applyDirectMutationMeta(tr); + } else { + tr.delete(candidate.pos, candidate.end); + if (mode === 'tracked') applyTrackedMutationMeta(tr); + else applyDirectMutationMeta(tr); + } editor.dispatch(tr); clearIndexCache(editor); return { success: true, deleted: input.target, deletedBlock }; } +/** Paragraph-shaped blocks carry a paragraph mark; tables and their parts do not. */ +function isParagraphShaped(candidate: BlockCandidate): boolean { + return candidate.node?.type?.name === 'paragraph' || candidate.node?.type?.name === 'heading'; +} + +/** + * Record a whole-block tracked deletion the way Word encodes it: every run in + * the block gets a trackDelete mark, and the paragraph mark itself is stamped + * with a matching `markTrackChange`. Both halves share ONE revision id, so the + * review model surfaces a single decidable change and the decision engine can + * pair the content removal with the paragraph collapse. + * + * Returns false when the block is not addressable as a textblock, leaving `tr` + * untouched so the caller can fail closed rather than write an untracked edit. + */ +function authorTrackedBlockDeletion(editor: Editor, tr: Transaction, candidate: BlockCandidate): boolean { + const user = editor.options.user; + if (!user) return false; + const node = candidate.node; + if (!node || !node.isTextblock) return false; + + const id = uuidv4(); + const date = new Date().toISOString(); + // Inner content range: skip the node's own open/close tokens. + const from = candidate.pos + 1; + const to = candidate.pos + node.nodeSize - 1; + if (to > from) { + markDeletion({ tr, from, to, user, date, id }); + } + + const markedPos = tr.mapping.map(candidate.pos, 1); + const markedNode = tr.doc.nodeAt(markedPos); + if (!markedNode || !markedNode.isTextblock) return false; + tr.setNodeMarkup(markedPos, undefined, { + ...markedNode.attrs, + markTrackChange: { + type: 'paragraphMarkDelete', + id, + author: user.name || '', + authorEmail: user.email || '', + date, + }, + }); + return true; +} + // --------------------------------------------------------------------------- // blocks.deleteRange — contiguous range deletion // --------------------------------------------------------------------------- diff --git a/packages/super-editor/src/editors/v1/extensions/paragraph/paragraph.js b/packages/super-editor/src/editors/v1/extensions/paragraph/paragraph.js index 01d27bbe7d..6302b5b83e 100644 --- a/packages/super-editor/src/editors/v1/extensions/paragraph/paragraph.js +++ b/packages/super-editor/src/editors/v1/extensions/paragraph/paragraph.js @@ -170,6 +170,15 @@ export const Paragraph = OxmlNode.create({ }, filename: { rendered: false }, paragraphProperties: { rendered: false }, + // The paragraph MARK's own tracked revision — OOXML + // `w:pPr/w:rPr/w:del` (ECMA-376 §17.13.5.14). Deleting a whole list item + // or paragraph in tracked mode marks the runs AND the paragraph mark; + // accepting removes the content and collapses the now-markless paragraph + // into its successor, which is how the numbered item disappears and the + // rest of the list renumbers. Structural like tableRow.trackChange, so it + // lives on node attrs rather than as an inline mark. + // Shape: { type: 'paragraphMarkDelete', id, sourceId?, author, authorEmail?, date, importedAuthor? } + markTrackChange: { default: null, rendered: false, keepOnSplit: false }, pageBreakSource: { rendered: false }, tocSourceId: { rendered: false }, sectionMargins: { rendered: false }, diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js index 9595a8950c..65069bad75 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js @@ -686,7 +686,7 @@ const buildMutationPlan = ({ state, graph, selections, decision }) => { // A pPr change flips a node attr and changes no text content, so its // whole-block segment must NOT enter resolvedRanges — otherwise // planCommentEffects would detach unrelated comments anchored in that block. - if (isFull && !change.pprChange) { + if (isFull && !change.pprChange && !change.paragraphMarkChange) { for (const segment of change.segments) { resolvedRanges.push({ from: segment.from, @@ -702,6 +702,12 @@ const buildMutationPlan = ({ state, graph, selections, decision }) => { // formatting planner cannot resolve it. const pprResult = planPprDecision({ ops, change, decision, retired }); if (!pprResult.ok) return { ok: false, failure: pprResult.failure }; + } else if (change.paragraphMarkChange) { + // A paragraph mark deleted on an EMPTY block. Typed Deletion but with no + // inline mark and no content to remove, so the mark-based deletion + // planner has nothing to walk — only the mark half has to be resolved. + const markResult = planParagraphMarkDecision({ ops, change, decision, retired }); + if (!markResult.ok) return { ok: false, failure: markResult.failure }; } else if (change.type === CanonicalChangeType.Structural) { const structuralResult = planStructuralDecision({ ops, change, decision, removedRanges, retired }); if (!structuralResult.ok) return { ok: false, failure: structuralResult.failure }; @@ -903,7 +909,13 @@ const planDeletionDecision = ({ ops, change, selection, decision, removedRanges, }); removedRanges.push({ from: range.from, to: range.to, cause: `accept-deletion:${change.id}` }); } - if (isFull) retired.add(change.id); + // A whole-block deletion also deleted the paragraph MARK. Only a + // full accept may collapse the paragraph — a partial accept leaves live + // text behind, and the mark stays deleted until the rest is decided. + if (isFull) { + pushParagraphMarkOps({ ops, change, decision: 'accept', ranges }); + retired.add(change.id); + } return; } // Reject deletion: remove the trackDelete mark; content stays as live. @@ -917,7 +929,36 @@ const planDeletionDecision = ({ ops, change, selection, decision, removedRanges, side: SegmentSide.Deleted, }); } - if (isFull) retired.add(change.id); + if (isFull) { + pushParagraphMarkOps({ ops, change, decision: 'reject', ranges }); + retired.add(change.id); + } +}; + +/** + * Queue the paragraph-mark half of a whole-block tracked deletion. + * + * The mark lives on paragraph node attrs (`markTrackChange`) rather than as an + * inline mark, so it is not part of `deletedSegments`. We cannot resolve which + * paragraph carries it from positions alone here — `applyPlan` does that against + * the live doc, matching on the change id, and no-ops when the change was an + * ordinary inline deletion that never touched a paragraph mark. + * + * accept → content removed, then the emptied paragraph collapses into the + * next one (Word semantics: the successor keeps its own pPr, which + * is why the surviving items renumber and nothing empty is left). + * reject → clear the attr; the paragraph and its numbering stay put. + */ +const pushParagraphMarkOps = ({ ops, change, decision, ranges }) => { + const anchor = ranges[0]; + if (!anchor) return; + ops.push({ + kind: decision === 'accept' ? 'collapseParagraphMark' : 'clearParagraphMark', + from: anchor.from, + to: anchor.to, + changeId: change.id, + side: SegmentSide.Deleted, + }); }; /** @@ -1017,6 +1058,31 @@ const planPprDecision = ({ ops, change, decision, retired }) => { return { ok: true }; }; +/** + * Resolve a standalone paragraph-mark deletion — the whole-block deletion of a + * block that had no runs to strike. There is no content to remove, so accept + * only has to collapse the emptied block into its successor and reject only + * has to clear the record. + */ +const planParagraphMarkDecision = ({ ops, change, decision, retired }) => { + const mark = change.paragraphMarkChange; + if (!mark) { + return { + ok: false, + failure: failure('CAPABILITY_UNAVAILABLE', `change "${change.id}" is not a paragraph-mark deletion.`), + }; + } + ops.push({ + kind: decision === 'accept' ? 'collapseParagraphMark' : 'clearParagraphMark', + from: mark.from, + to: mark.to, + changeId: change.id, + side: SegmentSide.Deleted, + }); + retired.add(change.id); + return { ok: true }; +}; + const planReplacementDecision = ({ ops, graph, change, decision, removedRanges, retired }) => { const inserted = change.insertedSegments; const deleted = change.deletedSegments; @@ -1410,6 +1476,33 @@ const findTextblockAt = (doc, pos) => { return null; }; +/** + * Locate the paragraph carrying a `markTrackChange` for `changeId` at or around + * `pos`. Checks the textblock containing `pos` first; when an accepted + * deletion emptied the paragraph, `pos` can land on its boundary, so the + * immediate neighbours are checked too. Matching on the change id keeps this + * from touching an unrelated paragraph that happens to sit nearby. + * + * @returns {{ pos: number, node: import('prosemirror-model').Node } | null} + */ +const findParagraphMarkChangeAt = (tr, pos, changeId) => { + const carries = (node) => node?.attrs?.markTrackChange?.id === changeId; + const clamped = Math.max(0, Math.min(pos, tr.doc.content.size)); + const block = findTextblockAt(tr.doc, clamped); + if (block && carries(block.node)) return block; + + let found = null; + tr.doc.descendants((node, nodePos) => { + if (found) return false; + if (node.isTextblock && carries(node)) { + found = { pos: nodePos, node }; + return false; + } + return undefined; + }); + return found; +}; + const rejectParagraphSplitAt = (tr, from, anchor = 'inserted') => { const block = findTextblockAt(tr.doc, from); if (!block) return false; @@ -1442,6 +1535,9 @@ const applyPlan = ({ state, plan }) => { // We therefore remove the track-format mark in the position-stable mark pass and // defer the join to a mapped structural phase below. const splitJoinOps = sortedOps.filter((op) => op.kind === 'rejectParagraphSplit').reverse(); + // Collapsing an accepted paragraph-mark deletion is a join too, so it + // shares the deferred structural phase for the same reason. + const paragraphMarkOps = sortedOps.filter((op) => op.kind === 'collapseParagraphMark').reverse(); try { for (const op of markOps) { @@ -1493,6 +1589,15 @@ const applyPlan = ({ state, plan }) => { } continue; } + if (op.kind === 'clearParagraphMark') { + // Rejected whole-block deletion: the paragraph mark survives. Clearing + // the attr is position-stable, so it belongs in the mark pass. + const target = findParagraphMarkChangeAt(tr, tr.mapping.map(op.from, 1), op.changeId); + if (target) { + tr.setNodeMarkup(target.pos, undefined, { ...target.node.attrs, markTrackChange: null }); + } + continue; + } if (op.kind === 'resolvePprChange') { // Paragraph-property accept/reject. setNodeMarkup is position-stable, so // it is safe in the mark pass; map through the accumulated mapping in @@ -1542,6 +1647,25 @@ const applyPlan = ({ state, plan }) => { throw new Error(`could not join paragraph split for tracked change "${op.changeId ?? ''}".`); } } + // Accepted paragraph-mark deletions. The content is already gone; + // collapsing the emptied paragraph into its successor is what removes the + // list item itself and lets the rest of the list renumber. + for (const op of paragraphMarkOps) { + const mappedFrom = tr.mapping.map(op.from, 1); + const target = findParagraphMarkChangeAt(tr, mappedFrom, op.changeId); + // No target means the change was an ordinary inline deletion with no + // paragraph mark of its own — nothing structural to do. + if (!target) continue; + tr.setNodeMarkup(target.pos, undefined, { ...target.node.attrs, markTrackChange: null }); + const joinPos = target.pos + tr.doc.nodeAt(target.pos).nodeSize; + if (joinPos <= 0 || joinPos >= tr.doc.content.size || !canJoin(tr.doc, joinPos)) { + // Last block in its parent, or an unjoinable successor. The mark is + // resolved and the content is gone; leaving the empty paragraph beats + // failing the whole decision. + continue; + } + tr.join(joinPos); + } } catch (error) { return { ok: false, diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/paragraph-mark-deletion.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/paragraph-mark-deletion.test.js new file mode 100644 index 0000000000..fcbbb294fc --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/paragraph-mark-deletion.test.js @@ -0,0 +1,301 @@ +// @ts-check +/** + * Whole-block tracked deletion (paragraph / list item). + * + * Deleting a list item in tracked mode marks the runs AND the paragraph MARK + * (`w:pPr/w:rPr/w:del`, recorded on `paragraph.attrs.markTrackChange`). The two + * halves share one revision id, so the document surfaces ONE decidable change: + * + * accept → content removed AND the emptied paragraph collapses into its + * successor, so the item is gone and the remaining list renumbers. + * reject → the run marks and the paragraph mark both clear; the item stays. + * + * Before this, accepting removed only the text and left an empty numbered + * paragraph behind, which is what customers reported. + */ + +import { describe, it, expect } from 'vitest'; +import { EditorState } from 'prosemirror-state'; + +import { decideTrackedChanges } from './decision-engine.js'; +import { buildReviewGraph } from './review-graph.js'; +import { createReviewGraphTestSchema } from './test-fixtures.js'; +import { TrackDeleteMarkName } from '../constants.js'; + +const ALICE = { name: 'Alice Reviewer', email: 'alice@example.com' }; +const CHANGE_ID = 'para-del-1'; + +const editorFor = () => ({ + options: { user: ALICE, trackedChanges: {} }, + storage: { trackChanges: { lastDecisionFailure: null } }, +}); + +/** + * Three paragraphs; the middle one is deleted whole — every run carries the + * trackDelete mark and the node carries the matching paragraph-mark deletion. + */ +const stateWithDeletedBlock = ({ markTrackChange = true } = {}) => { + const schema = createReviewGraphTestSchema(); + const deleteMark = schema.marks[TrackDeleteMarkName].create({ + id: CHANGE_ID, + author: ALICE.name, + authorEmail: ALICE.email, + date: '2026-07-31T10:00:00Z', + }); + + const doc = schema.node('doc', null, [ + schema.node('paragraph', null, [schema.text('First item')]), + schema.node( + 'paragraph', + markTrackChange + ? { + markTrackChange: { + type: 'paragraphMarkDelete', + id: CHANGE_ID, + author: ALICE.name, + authorEmail: ALICE.email, + date: '2026-07-31T10:00:00Z', + }, + } + : null, + [schema.text('Second item', [deleteMark])], + ), + schema.node('paragraph', null, [schema.text('Third item')]), + ]); + + return { schema, state: EditorState.create({ doc }) }; +}; + +const paragraphTexts = (state) => { + const texts = []; + state.doc.forEach((node) => texts.push(node.textContent)); + return texts; +}; + +describe('whole-block tracked deletion', () => { + it('accepting removes the block entirely, leaving no empty paragraph', () => { + const { state } = stateWithDeletedBlock(); + const result = decideTrackedChanges({ + state, + editor: editorFor(), + decision: 'accept', + target: { kind: 'id', id: CHANGE_ID }, + }); + expect(result.ok, result.ok ? '' : JSON.stringify(result.failure)).toBe(true); + + const next = state.apply(result.tr); + expect(next.doc.childCount).toBe(2); + expect(paragraphTexts(next)).toEqual(['First item', 'Third item']); + // No stranded empty paragraph, and no leftover mark record. + next.doc.forEach((node) => { + expect(node.textContent.length).toBeGreaterThan(0); + expect(node.attrs.markTrackChange).toBeFalsy(); + }); + }); + + it('rejecting restores the block, its content and its paragraph mark', () => { + const { state } = stateWithDeletedBlock(); + const result = decideTrackedChanges({ + state, + editor: editorFor(), + decision: 'reject', + target: { kind: 'id', id: CHANGE_ID }, + }); + expect(result.ok, result.ok ? '' : JSON.stringify(result.failure)).toBe(true); + + const next = state.apply(result.tr); + expect(next.doc.childCount).toBe(3); + expect(paragraphTexts(next)).toEqual(['First item', 'Second item', 'Third item']); + expect(next.doc.child(1).attrs.markTrackChange).toBeFalsy(); + // Content is live again: no trackDelete mark survives. + let deleteMarks = 0; + next.doc.descendants((node) => { + deleteMarks += node.marks.filter((mark) => mark.type.name === TrackDeleteMarkName).length; + }); + expect(deleteMarks).toBe(0); + }); + + // `accept_tracked_changes` / `reject_tracked_changes` with no filter target + // scope 'all', a different planning branch from a by-id decision. The agent + // reaches for the unfiltered form constantly, so both branches must agree. + it('accepting via scope:all removes the block too', () => { + const { state } = stateWithDeletedBlock(); + const result = decideTrackedChanges({ + state, + editor: editorFor(), + decision: 'accept', + target: { kind: 'all' }, + }); + expect(result.ok, result.ok ? '' : JSON.stringify(result.failure)).toBe(true); + + const next = state.apply(result.tr); + expect(paragraphTexts(next)).toEqual(['First item', 'Third item']); + }); + + it('rejecting via scope:all restores the block too', () => { + const { state } = stateWithDeletedBlock(); + const result = decideTrackedChanges({ + state, + editor: editorFor(), + decision: 'reject', + target: { kind: 'all' }, + }); + expect(result.ok, result.ok ? '' : JSON.stringify(result.failure)).toBe(true); + + const next = state.apply(result.tr); + expect(paragraphTexts(next)).toEqual(['First item', 'Second item', 'Third item']); + expect(next.doc.child(1).attrs.markTrackChange).toBeFalsy(); + }); + + it('leaves an ordinary inline deletion alone (no paragraph mark, no collapse)', () => { + // Same change id, but the paragraph mark was never deleted — this is a + // plain "delete this sentence" edit and the paragraph must survive. + const { state } = stateWithDeletedBlock({ markTrackChange: false }); + const result = decideTrackedChanges({ + state, + editor: editorFor(), + decision: 'accept', + target: { kind: 'id', id: CHANGE_ID }, + }); + expect(result.ok, result.ok ? '' : JSON.stringify(result.failure)).toBe(true); + + const next = state.apply(result.tr); + expect(next.doc.childCount).toBe(3); + expect(paragraphTexts(next)).toEqual(['First item', '', 'Third item']); + }); + + // The V2 kernel splices bytes, so there the collapse had to be taught where a + // container ends. Here the collapse is a ProseMirror `join`, and `canJoin` + // already refuses across a cell boundary — this pins that, so the guard is + // not lost to a future refactor. Accepting empties the cell paragraph, which + // is the only legal outcome: a `` must contain at least one paragraph. + it('never collapses a cell-final paragraph into the next cell', () => { + const schema = createReviewGraphTestSchema(); + const deleteMark = schema.marks[TrackDeleteMarkName].create({ + id: CHANGE_ID, + author: ALICE.name, + authorEmail: ALICE.email, + date: '2026-07-31T10:00:00Z', + }); + const markTrackChange = { + type: 'paragraphMarkDelete', + id: CHANGE_ID, + author: ALICE.name, + authorEmail: ALICE.email, + date: '2026-07-31T10:00:00Z', + }; + const cell = (text, attrs) => + schema.nodes.tableCell.create({}, [ + schema.node('paragraph', attrs, [schema.text(text, attrs ? [deleteMark] : [])]), + ]); + const doc = schema.node('doc', null, [ + schema.node('paragraph', null, [schema.text('Intro.')]), + schema.nodes.table.create({}, [ + schema.nodes.tableRow.create({}, [cell('A', { markTrackChange }), cell('B', null)]), + ]), + ]); + const state = EditorState.create({ doc }); + + const result = decideTrackedChanges({ + state, + editor: editorFor(), + decision: 'accept', + target: { kind: 'id', id: CHANGE_ID }, + }); + expect(result.ok, result.ok ? '' : JSON.stringify(result.failure)).toBe(true); + + const row = state.apply(result.tr).doc.child(1).child(0); + // Both cells survive; only the targeted one is emptied. + expect(row.childCount).toBe(2); + expect(row.child(0).textContent).toBe(''); + expect(row.child(1).textContent).toBe('B'); + expect(row.child(0).child(0).attrs.markTrackChange).toBeFalsy(); + }); +}); + +// An EMPTY list item has no runs to strike, so the paragraph mark is the ONLY +// record of the deletion. The inline enumerators are mark-based and never see +// it, so without a dedicated projection the change is invisible: nothing to +// list, nothing to accept, and the empty item survives — the very symptom +// whole-block deletion exists to remove. Real documents are full of these: +// a blank bullet left behind while drafting is exactly what a user asks an +// agent to clean up. +describe('whole-block tracked deletion of an EMPTY block', () => { + const stateWithEmptyDeleted = () => { + const schema = createReviewGraphTestSchema(); + const doc = schema.node('doc', null, [ + schema.node('paragraph', null, [schema.text('First item')]), + schema.node( + 'paragraph', + { + markTrackChange: { + type: 'paragraphMarkDelete', + id: CHANGE_ID, + author: ALICE.name, + authorEmail: ALICE.email, + date: '2026-07-31T10:00:00Z', + }, + }, + [], + ), + schema.node('paragraph', null, [schema.text('Third item')]), + ]); + return EditorState.create({ doc }); + }; + + it('is a reviewable change even though no run carries a mark', () => { + const state = stateWithEmptyDeleted(); + const graph = buildReviewGraph({ state, editor: editorFor() }); + const change = graph.changes.get(CHANGE_ID); + expect(change, 'the deletion must be listed').toBeTruthy(); + expect(change.author).toBe(ALICE.name); + }); + + it('accepting removes the empty block', () => { + const state = stateWithEmptyDeleted(); + const result = decideTrackedChanges({ + state, + editor: editorFor(), + decision: 'accept', + target: { kind: 'id', id: CHANGE_ID }, + }); + expect(result.ok, result.ok ? '' : JSON.stringify(result.failure)).toBe(true); + expect(paragraphTexts(state.apply(result.tr))).toEqual(['First item', 'Third item']); + }); + + it('rejecting keeps the empty block and clears the record', () => { + const state = stateWithEmptyDeleted(); + const result = decideTrackedChanges({ + state, + editor: editorFor(), + decision: 'reject', + target: { kind: 'id', id: CHANGE_ID }, + }); + expect(result.ok, result.ok ? '' : JSON.stringify(result.failure)).toBe(true); + const next = state.apply(result.tr); + expect(paragraphTexts(next)).toEqual(['First item', '', 'Third item']); + expect(next.doc.child(1).attrs.markTrackChange).toBeFalsy(); + }); + + it('scope:all resolves it too', () => { + const state = stateWithEmptyDeleted(); + const result = decideTrackedChanges({ + state, + editor: editorFor(), + decision: 'accept', + target: { kind: 'all' }, + }); + expect(result.ok, result.ok ? '' : JSON.stringify(result.failure)).toBe(true); + expect(paragraphTexts(state.apply(result.tr))).toEqual(['First item', 'Third item']); + }); + + it('does not double-project a block whose runs were struck too', () => { + // The content case already produces the change from its inline marks; the + // mark pass must not add a second entry under the same id. + const { state } = stateWithDeletedBlock(); + const graph = buildReviewGraph({ state, editor: editorFor() }); + const ids = new Set(); + for (const change of graph.changes.values()) ids.add(change.id); + expect([...ids].filter((id) => id === CHANGE_ID)).toHaveLength(1); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.js index cbec8f5370..deea05637a 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/review-graph.js @@ -29,6 +29,7 @@ import { import { BODY_STORY, buildStoryKey } from './story-locator.js'; import { enumerateStructuralRowChanges } from '../trackChangesHelpers/structuralRowChanges.js'; import { enumeratePprChanges } from '../trackChangesHelpers/pprChanges.js'; +import { enumerateParagraphMarkDeletions } from '../trackChangesHelpers/paragraphMarkChanges.js'; // --------------------------------------------------------------------------- // Types @@ -58,6 +59,8 @@ import { enumeratePprChanges } from '../trackChangesHelpers/pprChanges.js'; * @property {'parent'|'child'|'standalone'} overlapRole * @property {boolean} [structural] True for a whole-table structural segment (no inline mark). * @property {boolean} [pprChange] True for a paragraph-property (w:pPrChange) segment (no inline mark). + * @property {boolean} [paragraphMarkChange] True for a paragraph-mark deletion (w:pPr/w:rPr/w:del) segment on a block + * whose runs carry no mark of their own (no inline mark). * @property {Array} [nodePath] optional diagnostics nodePath. */ @@ -137,10 +140,12 @@ export const buildReviewGraph = ({ state, story = BODY_STORY, replacementsMode = const spans = enumerateTrackedMarkSpans(state); const structuralChanges = enumerateStructuralRowChanges(state); const pprChanges = enumeratePprChanges(state); + const paragraphMarkChanges = enumerateParagraphMarkDeletions(state); return buildGraphFromSpans({ spans, structuralChanges, pprChanges, + paragraphMarkChanges, doc: state?.doc ?? null, story, replacementsMode, @@ -203,7 +208,15 @@ export const invalidateReviewGraphCache = (editor) => { // Internal builder // --------------------------------------------------------------------------- -const buildGraphFromSpans = ({ spans, structuralChanges = [], pprChanges = [], doc, story, replacementsMode }) => { +const buildGraphFromSpans = ({ + spans, + structuralChanges = [], + pprChanges = [], + paragraphMarkChanges = [], + doc, + story, + replacementsMode, +}) => { /** @type {Array<{ attrs: import('./mark-metadata.js').NormalizedTrackedAttrs, span: import('./segment-index.js').TrackedMarkSpan }>} */ const normalized = spans.map((span) => ({ attrs: readTrackedAttrs(span.mark, span.mark.type.name), @@ -336,6 +349,30 @@ const buildGraphFromSpans = ({ spans, structuralChanges = [], pprChanges = [], d appendToMap(byRevisionGroupId, logical.revisionGroupId, logical.id); } + // 6d. Paragraph-mark deletions (w:pPr/w:rPr/w:del) that the inline pass did + // NOT already claim. When the deleted block had content, its runs carry + // `trackDelete` under the same id, so the change already exists and the + // mark rides along with it — projecting again would duplicate it. An + // EMPTY block has no runs, so this is the only pass that can surface it; + // without it the deletion is invisible and the empty item survives. + // + // Non-positional for the same reason as pPr changes: the synthetic + // segment spans the whole block and is not real text content, so it + // stays off `mergedSegments` / `bySegmentId` and is resolved only + // through the `changes` map. + for (const mark of paragraphMarkChanges) { + if (changes.has(mark.id)) continue; + const logical = buildParagraphMarkLogicalChange({ mark, doc, story }); + if (!logical) continue; + const internalKey = `paragraphmark:${mark.from}`; + if (changes.has(internalKey)) continue; + changes.set(internalKey, logical); + if (logical.id && logical.id !== internalKey && !changes.has(logical.id)) { + changes.set(logical.id, logical); + } + appendToMap(byRevisionGroupId, logical.revisionGroupId, logical.id); + } + // 7. Flat ordered segment list (kept in document order for range queries). const segments = mergedSegments.slice().sort((a, b) => a.from - b.from || a.to - b.to); @@ -812,6 +849,112 @@ const buildPprLogicalChange = ({ ppr, doc, story }) => { return logical; }; +/** + * Project a standalone paragraph-mark deletion (an emptied block whose runs + * carry no mark of their own) into a decidable Deletion change. + * + * Typed Deletion, not Structural: accepting removes the block the same way the + * content case does, and the decision engine resolves it through the same + * `collapseParagraphMark` / `clearParagraphMark` ops. + * + * @param {{ + * mark: import('../trackChangesHelpers/paragraphMarkChanges.js').ParagraphMarkChange, + * doc: import('prosemirror-model').Node | null, + * story: import('./story-locator.js').StoryLocator, + * }} input + * @returns {LogicalTrackedChange | null} + */ +const buildParagraphMarkLogicalChange = ({ mark, doc, story }) => { + const from = mark.from; + const to = mark.to; + if (!(from < to)) return null; + const side = SegmentSide.Deleted; + + /** @type {import('./mark-metadata.js').NormalizedTrackedAttrs} */ + const attrs = { + id: mark.id, + revisionGroupId: mark.id, + splitFromId: '', + changeType: CanonicalChangeType.Deletion, + replacementGroupId: '', + replacementSideId: '', + overlapParentId: '', + sourceIds: {}, + sourceId: '', + importedAuthor: '', + origin: '', + author: mark.author, + authorId: '', + authorEmail: mark.authorEmail, + authorImage: mark.authorImage, + date: mark.date, + markType: '', + side, + subtype: mark.subtype, + explicitChangeType: CanonicalChangeType.Deletion, + hasReviewMetadata: true, + }; + + /** @type {TrackedSegment} */ + const segment = { + segmentId: `${mark.id}:paragraphmark:${from}:${to}:0`, + changeId: mark.id, + markType: '', + side, + from, + to, + text: '', + mark: /** @type {*} */ (null), + markRuns: [], + attrs, + parentId: '', + parentSide: '', + overlapRole: 'standalone', + // Attr-based, like the structural and pPr segments: the block-spanning + // range is not real text content, so it must not nest inline changes or + // be hit by text-range decides. + paragraphMarkChange: true, + }; + + const segments = [segment]; + /** @type {LogicalTrackedChange} */ + const logical = { + id: mark.id, + type: CanonicalChangeType.Deletion, + subtype: mark.subtype, + state: 'open', + segments, + coverageSegments: [...segments], + insertedSegments: [], + deletedSegments: [...segments], + formattingSegments: [], + replacement: null, + author: mark.author, + authorId: '', + authorEmail: mark.authorEmail, + authorImage: mark.authorImage, + date: mark.date, + sourceIds: {}, + revisionGroupId: mark.id, + splitFromId: '', + sourcePlatform: '', + story, + parent: null, + children: [], + before: [], + after: [], + excerpt: '', + }; + // Payload the decision engine reads. Non-enumerable so it never leaks into + // deterministic JSON or contract projections that iterate own keys. + Object.defineProperty(logical, 'paragraphMarkChange', { + value: mark, + enumerable: false, + }); + + return logical; +}; + const aggregateSourceIds = (segments) => { /** @type {Record} */ const out = {}; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/test-fixtures.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/test-fixtures.js index 0867b68523..680e21f8fd 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/test-fixtures.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/test-fixtures.js @@ -23,8 +23,10 @@ const NODES = { group: 'block', // paragraphProperties carries a tracked w:pPrChange record on the node attr // (numbering/alignment revisions) — mirrors the production paragraph node so - // the pPrChange enumerator can find one in tests. - attrs: { paragraphProperties: { default: null } }, + // the pPrChange enumerator can find one in tests. markTrackChange carries + // the paragraph MARK's own tracked deletion (w:pPr/w:rPr/w:del) so + // whole-block deletion decisions can be exercised here too. + attrs: { paragraphProperties: { default: null }, markTrackChange: { default: null } }, parseDOM: [{ tag: 'p' }], toDOM: () => ['p', 0], }, diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/paragraphMarkChanges.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/paragraphMarkChanges.js new file mode 100644 index 0000000000..5efc5347a6 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/paragraphMarkChanges.js @@ -0,0 +1,78 @@ +// @ts-check +/** + * Paragraph-mark tracked-deletion enumerator (w:pPr/w:rPr/w:del). + * + * A whole-block tracked deletion marks the block's runs AND its paragraph + * MARK. The mark half lives on `node.attrs.markTrackChange`, not on an inline + * mark, so the inline enumerators never see it — the same blind spot the + * structural row and pPrChange enumerators exist to cover. + * + * For a block that HAD content the runs carry `trackDelete` marks under the + * same id, so the inline enumerator already produces the change and the mark + * simply rides along with it. An EMPTY block has no runs to mark, so without + * this walk the deletion is invisible: `trackChanges.list` reports nothing, + * there is nothing to accept or reject, and the empty item survives — exactly + * the symptom whole-block deletion was added to remove. + * + * The review graph projects each entry the inline pass did not already claim + * into a decidable deletion, and the decision engine resolves it with the same + * `collapseParagraphMark` / `clearParagraphMark` ops used for the content case. + */ + +/** + * @typedef {Object} ParagraphMarkChange + * @property {string} id Logical (and public) change id. + * @property {number} from Block node start (absolute PM position). + * @property {number} to Block node end (`from + node.nodeSize`). + * @property {string} author + * @property {string} authorEmail + * @property {string} authorImage + * @property {string} date + * @property {'paragraph-mark-deletion'} subtype + */ + +/** + * Enumerate tracked paragraph-mark deletions in the document. + * + * Tolerates a missing/partial state and returns `[]` instead of throwing, to + * match the inline, structural and pPrChange enumerators' bootstrap-safety + * contract. + * + * @param {import('prosemirror-state').EditorState | { doc?: import('prosemirror-model').Node } | null | undefined} state + * @returns {Array} + */ +export const enumerateParagraphMarkDeletions = (state) => { + const doc = state?.doc; + if (!doc) return []; + + /** @type {Array} */ + const out = []; + + try { + doc.descendants((node, pos) => { + // The record lives on block-level nodes; never descend into text. + if (node.isText) return false; + const record = node?.attrs?.markTrackChange; + // Only a deletion record with a stable id is decidable. Anything else + // (a paragraph-mark INSERTION, a transient or malformed record) is + // ignored rather than surfaced as a half-formed change. + if (record && record.type === 'paragraphMarkDelete' && typeof record.id === 'string' && record.id) { + out.push({ + id: record.id, + from: pos, + to: pos + node.nodeSize, + author: record.author || '', + authorEmail: record.authorEmail || '', + authorImage: record.authorImage || '', + date: record.date || '', + subtype: 'paragraph-mark-deletion', + }); + } + return undefined; + }); + } catch { + return out; + } + + return out; +};