diff --git a/packages/super-editor/src/editors/v1/core/super-converter/custom-xml-parts.js b/packages/super-editor/src/editors/v1/core/super-converter/custom-xml-parts.js index 2a5f135503..7bae0f5ec8 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/custom-xml-parts.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/custom-xml-parts.js @@ -610,7 +610,7 @@ export function removeCustomXmlPart(convertedXml, target, converter) { return true; } -function invalidateConverterCachesForPath(converter, partName) { +export function invalidateConverterCachesForPath(converter, partName) { if (!converter || typeof partName !== 'string') return; const biblio = converter.bibliographyPart; if (biblio && biblio.partPath === partName) { diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-anchors.integration.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-anchors.integration.test.ts new file mode 100644 index 0000000000..940fbeeb30 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-anchors.integration.test.ts @@ -0,0 +1,336 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import { NodeSelection, TextSelection } from 'prosemirror-state'; +import type { SelectionTarget } from '@superdoc/document-api'; +import type { Editor } from '../../core/Editor.js'; +import { resolveSelectionTarget } from '../helpers/selection-target-resolver.js'; +import { metadataAttachWrapper, metadataListWrapper, metadataRemoveWrapper } from './anchored-metadata-wrappers.js'; +import { registerBuiltInExecutors } from './register-executors.js'; +import { initTestEditor } from '@tests/helpers/helpers.js'; + +registerBuiltInExecutors(); + +const BLOCK_ID = 'metadata-anchor-p1'; +const NAMESPACE = 'urn:test:metadata'; + +type FoundAnchor = { + node: ProseMirrorNode; + pos: number; +}; + +function makeEditor(text = 'abcdefghij'): Editor { + return initTestEditor({ + loadFromSchema: true, + content: { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { paraId: BLOCK_ID, sdBlockId: BLOCK_ID }, + content: [{ type: 'run', attrs: {}, content: [{ type: 'text', text }] }], + }, + ], + }, + user: { name: 'Integration User', email: 'integration@example.com' }, + }).editor as Editor; +} + +function textTarget(start: number, end: number): SelectionTarget { + return { + kind: 'selection', + start: { kind: 'text', blockId: BLOCK_ID, offset: start }, + end: { kind: 'text', blockId: BLOCK_ID, offset: end }, + }; +} + +function findInlineAnchors(editor: Editor): FoundAnchor[] { + const anchors: FoundAnchor[] = []; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'structuredContent') anchors.push({ node, pos }); + return true; + }); + return anchors; +} + +// Headless test editors have no mounted view; dispatch mirrors the +// adapter's own view-or-editor fallback. +function dispatchTr(editor: Editor, tr: ReturnType): void { + if (editor.view?.dispatch) { + editor.view.dispatch(tr); + return; + } + editor.dispatch(tr); +} + +function selectFormerAnchorRange(editor: Editor, target: SelectionTarget): void { + const { absFrom, absTo } = resolveSelectionTarget(editor, target); + dispatchTr(editor, editor.state.tr.setSelection(TextSelection.create(editor.state.doc, absFrom, absTo))); +} + +describe('anchored metadata anchors', () => { + let editor: Editor | undefined; + + afterEach(() => { + editor?.destroy(); + editor = undefined; + }); + + it('preserves an unrelated selection when removing an anchor by id', () => { + editor = makeEditor(); + + const attached = metadataAttachWrapper(editor, { + id: 'meta-a', + target: textTarget(6, 9), + namespace: NAMESPACE, + payload: { label: 'A' }, + }); + expect(attached.success).toBe(true); + + // Park the user's selection well before the anchor. + dispatchTr(editor, editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 2, 4))); + const before = editor.state.selection; + + const removed = metadataRemoveWrapper(editor, { id: 'meta-a' }); + expect(removed.success).toBe(true); + + // Removal by id must not steal a selection that never touched the anchor. + expect(editor.state.selection).toBeInstanceOf(TextSelection); + expect(editor.state.selection.from).toBe(before.from); + expect(editor.state.selection.to).toBe(before.to); + }); + + it('does not reject attach over a foreign content control whose tag collides with a metadata id', () => { + editor = makeEditor(); + + const attached = metadataAttachWrapper(editor, { + id: 'meta-a', + target: textTarget(1, 3), + namespace: NAMESPACE, + payload: { label: 'A' }, + }); + expect(attached.success).toBe(true); + + // Forge a foreign inline content control elsewhere whose w:tag equals the + // stored metadata id but which was not created by metadata.attach (no + // 'Anchored metadata' alias, no hidden appearance). + const anchors = findInlineAnchors(editor); + expect(anchors.length).toBe(1); + const sdtType = anchors[0]!.node.type; + const { absFrom, absTo } = resolveSelectionTarget(editor, textTarget(6, 7)); + const foreign = sdtType.create( + { id: '999999', tag: 'meta-a', alias: 'Customer control', controlType: 'richText', type: 'richText' }, + editor.state.schema.text('x'), + ); + dispatchTr(editor, editor.state.tr.replaceWith(absFrom, absTo, foreign)); + + // Attaching over the foreign control must not be misclassified as an + // overlap with our own anchor. + const second = metadataAttachWrapper(editor, { + id: 'meta-b', + target: textTarget(5, 8), + namespace: NAMESPACE, + payload: { label: 'B' }, + }); + expect(second.success).toBe(true); + }); + + it('rejects a partially overlapping attach without duplicating the existing anchor', () => { + editor = makeEditor(); + + const first = metadataAttachWrapper(editor, { + id: 'meta-a', + target: textTarget(2, 6), + namespace: NAMESPACE, + payload: { label: 'A' }, + }); + expect(first.success).toBe(true); + const before = editor.state.doc.toJSON(); + + const crossing = metadataAttachWrapper(editor, { + id: 'meta-b', + target: textTarget(4, 8), + namespace: NAMESPACE, + payload: { label: 'B' }, + }); + + expect(crossing).toMatchObject({ success: false, failure: { code: 'INVALID_TARGET' } }); + expect(editor.state.doc.toJSON()).toEqual(before); + + const anchors = findInlineAnchors(editor); + expect(anchors).toHaveLength(1); + expect(anchors[0].node.attrs.tag).toBe('meta-a'); + expect(anchors[0].node.textContent).toBe('cdef'); + expect(new Set(anchors.map((anchor) => anchor.node.attrs.tag)).size).toBe(anchors.length); + expect(new Set(anchors.map((anchor) => anchor.node.attrs.id)).size).toBe(anchors.length); + expect(metadataListWrapper(editor).items.map((item) => item.id)).toEqual(['meta-a']); + }); + + it.each([ + { label: 'inside an existing anchor', target: textTarget(3, 5) }, + { label: 'around an existing anchor', target: textTarget(1, 8) }, + ])('rejects an attach $label without mutating the existing anchor', ({ target }) => { + editor = makeEditor(); + + const first = metadataAttachWrapper(editor, { + id: 'meta-a', + target: textTarget(2, 6), + namespace: NAMESPACE, + payload: { label: 'A' }, + }); + expect(first.success).toBe(true); + const before = editor.state.doc.toJSON(); + + const overlapping = metadataAttachWrapper(editor, { + id: 'meta-b', + target, + namespace: NAMESPACE, + payload: { label: 'B' }, + }); + + expect(overlapping).toMatchObject({ success: false, failure: { code: 'INVALID_TARGET' } }); + expect(editor.state.doc.toJSON()).toEqual(before); + + const anchors = findInlineAnchors(editor); + expect(anchors).toHaveLength(1); + expect(anchors[0].node.attrs.tag).toBe('meta-a'); + expect(anchors[0].node.textContent).toBe('cdef'); + expect(metadataListWrapper(editor).items.map((item) => item.id)).toEqual(['meta-a']); + }); + + it('attach dry-run does not mutate the document or metadata storage', () => { + editor = makeEditor(); + const before = editor.state.doc.toJSON(); + + const preview = metadataAttachWrapper( + editor, + { + id: 'meta-dry', + target: textTarget(2, 6), + namespace: NAMESPACE, + payload: { label: 'Preview' }, + }, + { changeMode: 'direct', dryRun: true }, + ); + + expect(preview).toMatchObject({ success: true, id: 'meta-dry', partName: 'customXml/item1.xml' }); + expect(editor.state.doc.toJSON()).toEqual(before); + expect(findInlineAnchors(editor)).toHaveLength(0); + expect(metadataListWrapper(editor).total).toBe(0); + }); + + it('remove unwraps the anchor and leaves a collapsed TextSelection that can select the former range', () => { + editor = makeEditor(); + const target = textTarget(2, 6); + + expect( + metadataAttachWrapper(editor, { + id: 'meta-remove', + target, + namespace: NAMESPACE, + payload: { label: 'Remove' }, + }).success, + ).toBe(true); + + const [anchor] = findInlineAnchors(editor); + expect(anchor).toBeDefined(); + selectFormerAnchorRange(editor, target); + + const removed = metadataRemoveWrapper(editor, { id: 'meta-remove' }, { changeMode: 'direct' }); + + expect(removed).toEqual({ success: true, id: 'meta-remove' }); + expect(findInlineAnchors(editor)).toHaveLength(0); + expect(editor.state.selection).toBeInstanceOf(TextSelection); + expect(editor.state.selection.from).toBe(anchor.pos); + expect(editor.state.selection.to).toBe(anchor.pos); + + selectFormerAnchorRange(editor, target); + expect(editor.state.selection).toBeInstanceOf(TextSelection); + expect(editor.state.selection.from).toBeLessThan(editor.state.selection.to); + expect(editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to)).toBe('cdef'); + }); + + it('remove normalizes a NodeSelection on the anchor before dispatching', () => { + editor = makeEditor(); + + expect( + metadataAttachWrapper(editor, { + id: 'meta-node-selection', + target: textTarget(2, 6), + namespace: NAMESPACE, + payload: { label: 'NodeSelection' }, + }).success, + ).toBe(true); + + const [anchor] = findInlineAnchors(editor); + expect(anchor).toBeDefined(); + dispatchTr(editor, editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, anchor.pos))); + expect(editor.state.selection).toBeInstanceOf(NodeSelection); + + const removed = metadataRemoveWrapper(editor, { id: 'meta-node-selection' }, { changeMode: 'direct' }); + + expect(removed).toEqual({ success: true, id: 'meta-node-selection' }); + expect(findInlineAnchors(editor)).toHaveLength(0); + expect(editor.state.selection).toBeInstanceOf(TextSelection); + expect(editor.state.selection.from).toBe(anchor.pos); + expect(editor.state.selection.to).toBe(anchor.pos); + + selectFormerAnchorRange(editor, textTarget(2, 6)); + expect(editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to)).toBe('cdef'); + }); + + it('leaves a collapsed caret sitting exactly on the anchor end boundary untouched when removing by id', () => { + editor = makeEditor(); + + expect( + metadataAttachWrapper(editor, { + id: 'meta-end-boundary', + target: textTarget(2, 6), + namespace: NAMESPACE, + payload: { label: 'End boundary' }, + }).success, + ).toBe(true); + + const [anchor] = findInlineAnchors(editor); + expect(anchor).toBeDefined(); + const from = anchor.pos; + const endBoundary = anchor.pos + anchor.node.nodeSize; + const contentSize = anchor.node.content.size; + + // Park a collapsed caret exactly on the wrapper's end boundary. It is + // adjacent to the wrapper, not inside it, so removal must not move it. + dispatchTr(editor, editor.state.tr.setSelection(TextSelection.create(editor.state.doc, endBoundary))); + expect(editor.state.selection.empty).toBe(true); + + const removed = metadataRemoveWrapper(editor, { id: 'meta-end-boundary' }, { changeMode: 'direct' }); + + expect(removed).toEqual({ success: true, id: 'meta-end-boundary' }); + expect(findInlineAnchors(editor)).toHaveLength(0); + expect(editor.state.selection).toBeInstanceOf(TextSelection); + expect(editor.state.selection.empty).toBe(true); + + // The caret maps to the end of the reinserted content (right after 'f'), + // its original text location, and does NOT jump to the anchor start. + expect(editor.state.selection.from).toBe(from + contentSize); + expect(editor.state.selection.from).not.toBe(from); + expect(editor.state.doc.textBetween(from, editor.state.selection.from)).toBe('cdef'); + }); + + it('wraps same-run selected content in a run inside the metadata SDT', () => { + editor = makeEditor(); + + expect( + metadataAttachWrapper(editor, { + id: 'meta-run-shape', + target: textTarget(2, 6), + namespace: NAMESPACE, + payload: { label: 'Run shape' }, + }).success, + ).toBe(true); + + const [anchor] = findInlineAnchors(editor); + expect(anchor).toBeDefined(); + expect(anchor.node.childCount).toBe(1); + expect(anchor.node.firstChild?.type.name).toBe('run'); + expect(anchor.node.firstChild?.textContent).toBe('cdef'); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-export.integration.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-export.integration.test.ts index 203cf3c7f8..2bedba85aa 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-export.integration.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-export.integration.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import * as Y from 'yjs'; +import { Awareness } from 'y-protocols/awareness.js'; +import JSZip from 'jszip'; import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; function seedMetadataPart( @@ -38,6 +41,58 @@ async function createEditorWithEmptyPackage() { return editor; } +function createProviderStub(ydoc: Y.Doc) { + return { + synced: true, + isSynced: true, + on: vi.fn(), + off: vi.fn(), + disconnect: vi.fn(), + awareness: new Awareness(ydoc), + }; +} + +async function createCollaborativeEditorWithEmptyPackage(ydoc: Y.Doc) { + const docData = await loadTestDataForEditorTests('blank-doc.docx'); + const { editor } = initTestEditor({ + content: docData.docx, + media: docData.media, + mediaFiles: docData.mediaFiles, + fonts: docData.fonts, + ydoc, + collaborationProvider: createProviderStub(ydoc), + useImmediateSetTimeout: false, + isHeadless: true, + user: { name: 'Test', email: 'test@example.com' }, + }); + return editor; +} + +function resolveBlockId(insertReceipt: unknown): string { + const receipt = insertReceipt as { + target?: { blockId?: unknown }; + resolution?: { target?: { blockId?: unknown } }; + }; + const direct = receipt.target?.blockId; + if (typeof direct === 'string' && direct.length > 0) return direct; + const resolved = receipt.resolution?.target?.blockId; + if (typeof resolved === 'string' && resolved.length > 0) return resolved; + throw new Error('insert receipt did not include a blockId'); +} + +function convertedXml(editor: unknown): Record { + return (editor as { converter: { convertedXml: Record } }).converter.convertedXml; +} + +function syncYDocs(source: Y.Doc, target: Y.Doc): void { + Y.applyUpdate(target, Y.encodeStateAsUpdate(source)); +} + +async function readZipPart(buffer: Uint8Array, path: string): Promise { + const zip = await JSZip.loadAsync(buffer); + return zip.files[path]?.async('string'); +} + describe('anchored metadata export filtering', () => { it('removes anchored-metadata entries from customXml when exporting final doc', async () => { const editor = await createEditorWithEmptyPackage(); @@ -73,4 +128,78 @@ describe('anchored metadata export filtering', () => { editor.destroy(); } }); + + it('exports metadata customXml parts received from a collaborator', async () => { + const ydocA = new Y.Doc(); + const ydocB = new Y.Doc(); + const editorA = await createCollaborativeEditorWithEmptyPackage(ydocA); + + await vi.waitFor(() => { + expect((editorA as unknown as { _partPublisher?: unknown })._partPublisher).toBeDefined(); + }); + + // Model the real join order: A seeds the room first, then B receives that + // state BEFORE bootstrapping, so B hydrates instead of seeding. Creating + // both editors on unsynced ydocs makes both seed the same baseline keys as + // concurrent Yjs writes, and the map-key conflict is then resolved by + // clientID — with the wrong winner, B's baseline word/_rels/document.xml.rels + // silently discards A's published rels update. + syncYDocs(ydocA, ydocB); + const editorB = await createCollaborativeEditorWithEmptyPackage(ydocB); + const hostText = 'Alpha metadata target.'; + + try { + await vi.waitFor(() => { + expect((editorB as unknown as { _partPublisher?: unknown })._partPublisher).toBeDefined(); + }); + + const inserted = await Promise.resolve(editorA.doc.insert({ value: hostText })); + const blockId = resolveBlockId(inserted); + const start = hostText.indexOf('metadata'); + const end = start + 'metadata'.length; + + const attached = editorA.doc.metadata.attach({ + id: 'meta-export-sync', + namespace: 'urn:test:metadata-export-sync', + payload: { label: 'Remote export' }, + target: { + kind: 'selection', + start: { kind: 'text', blockId, offset: start }, + end: { kind: 'text', blockId, offset: end }, + }, + }); + expect(attached.success).toBe(true); + + syncYDocs(ydocA, ydocB); + await vi.waitFor(() => { + expect(convertedXml(editorB)['customXml/item1.xml']).toBeDefined(); + expect(convertedXml(editorB)['customXml/itemProps1.xml']).toBeDefined(); + expect(convertedXml(editorB)['customXml/_rels/item1.xml.rels']).toBeDefined(); + // The baseline package already contains document.xml.rels, so checking + // mere existence would pass before the remote UPDATE lands — wait for + // the received rels content to reference the custom XML part. + expect(JSON.stringify(convertedXml(editorB)['word/_rels/document.xml.rels'])).toContain( + 'relationships/customXml', + ); + }); + + const exported = (await editorB.exportDocx({ getUpdatedDocs: false })) as Uint8Array; + const metadataXml = await readZipPart(exported, 'customXml/item1.xml'); + const propsXml = await readZipPart(exported, 'customXml/itemProps1.xml'); + const itemRelsXml = await readZipPart(exported, 'customXml/_rels/item1.xml.rels'); + const documentRelsXml = await readZipPart(exported, 'word/_rels/document.xml.rels'); + + expect(metadataXml).toContain('meta-export-sync'); + expect(metadataXml).toContain('Remote export'); + expect(propsXml).toContain('datastoreItem'); + expect(itemRelsXml).toContain('customXmlProps'); + expect(documentRelsXml).toContain('relationships/customXml'); + expect(documentRelsXml).toContain('../customXml/item1.xml'); + } finally { + editorA.destroy(); + editorB.destroy(); + ydocA.destroy(); + ydocB.destroy(); + } + }); }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-wrappers.test.ts index e469d0203b..c71583a3b8 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-wrappers.test.ts @@ -288,6 +288,26 @@ describe('anchored metadata wrappers', () => { expect(metadataListWrapper(editor).total).toBe(0); }); + it('rejects a non-serializable payload before mutating the document', () => { + const editor = makeEditor(); + + // A cyclic object cannot be represented in the JSON envelope and cannot be + // ruled out by the payload type. The preview must fail serialization before + // the live path inserts the SDT anchor; otherwise attach dispatches the + // anchor transaction and only then throws while building the envelope, + // leaving an anchor with no matching customXml payload. + const cyclic: Record = {}; + cyclic.self = cyclic; + + expect(() => + metadataAttachWrapper(editor, { id: 'cyclic', target: TARGET, namespace: 'urn:test:metadata', payload: cyclic }), + ).toThrow(); + + // No anchor transaction was dispatched and no payload entry was written. + expect(editor.view!.dispatch).not.toHaveBeenCalled(); + expect(metadataListWrapper(editor).total).toBe(0); + }); + it('attach dry-run throws REVISION_MISMATCH when expectedRevision is stale', () => { const editor = makeEditor(); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-wrappers.ts index ae016513b1..3e324ba3c3 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-wrappers.ts @@ -1,4 +1,5 @@ import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import { TextSelection } from 'prosemirror-state'; import { v4 as uuidv4 } from 'uuid'; import type { AnchoredMetadataAdapter, @@ -40,6 +41,7 @@ import { findAllSdtNodes, SDT_INLINE_NAME } from '../helpers/content-controls/in import { executeOutOfBandMutation } from '../out-of-band-mutation.js'; import { executeDomainCommand } from './plan-wrappers.js'; import { checkRevision, getRevision } from './revision-tracker.js'; +import { mutateCustomXmlParts } from './custom-xml-part-mutation.js'; type XmlNode = { type?: string; @@ -247,6 +249,7 @@ function wrapRangeInAnchor(editor: Editor, target: SelectionTarget, id: string): if (absFrom >= absTo) { throw new DocumentApiAdapterError('INVALID_TARGET', 'metadata.attach requires a non-empty text range.'); } + assertNoOverlappingMetadataAnchor(editor, absFrom, absTo); const nodeType = editor.schema.nodes[SDT_INLINE_NAME]; if (!nodeType) { @@ -268,7 +271,8 @@ function wrapRangeInAnchor(editor: Editor, target: SelectionTarget, id: string): const selectedContent = parentRun.content.cut($from.parentOffset, $to.parentOffset); const leftContent = parentRun.content.cut(0, $from.parentOffset); const rightContent = parentRun.content.cut($to.parentOffset); - const sdtNode = nodeType.create(attrs, selectedContent); + const sdtContent = runType.create(parentRun.attrs, selectedContent, parentRun.marks); + const sdtNode = nodeType.create(attrs, sdtContent); const replacement: ProseMirrorNode[] = []; if (leftContent.size > 0) replacement.push(runType.create(parentRun.attrs, leftContent, parentRun.marks)); replacement.push(sdtNode); @@ -288,8 +292,20 @@ function wrapRangeInAnchor(editor: Editor, target: SelectionTarget, id: string): function unwrapAnchor(editor: Editor, id: string): boolean { const anchor = findAnchorsById(editor, id)[0]; if (!anchor) return false; + const { selection } = editor.state; const { tr } = editor.state; - tr.replaceWith(anchor.pos, anchor.pos + anchor.node.nodeSize, anchor.node.content); + const from = anchor.pos; + const to = from + anchor.node.nodeSize; + tr.replaceWith(from, to, anchor.node.content); + // Normalize only a selection that genuinely intersects the wrapper interior + // or is a NodeSelection on the wrapper (a stale NodeSelection maps to an + // invalid selection). A strict overlap test excludes collapsed carets sitting + // exactly on the start or end boundary: those are adjacent, map cleanly + // through the replaceWith, and must be left alone so removal-by-id does not + // steal the caret. + if (selection.from < to && from < selection.to) { + tr.setSelection(TextSelection.create(tr.doc, tr.mapping.map(from, -1))); + } dispatchTransaction(editor, tr); clearIndexCache(editor); return true; @@ -330,6 +346,36 @@ function rangesOverlap(aFrom: number, aTo: number, bFrom: number, bTo: number): return aFrom < bTo && bFrom < aTo; } +function isMetadataAnchorNode(metadataIds: ReadonlySet, node: ProseMirrorNode): boolean { + const tag = node.attrs?.tag; + if (typeof tag !== 'string' || tag.length === 0) return false; + if (node.attrs?.alias === 'Anchored metadata') return true; + // Payload-id match alone is not enough: a foreign content control whose + // w:tag collides with a stored entry id must not block attach. Require the + // hidden appearance metadata.attach stamps on every anchor it creates. + return metadataIds.has(tag) && node.attrs?.appearance === 'hidden'; +} + +function findOverlappingMetadataAnchor(editor: Editor, absFrom: number, absTo: number) { + const convertedXml = getConvertedXml(editor); + const metadataIds = new Set(listMetadataParts(convertedXml).flatMap((part) => part.entries.map((entry) => entry.id))); + return findAllSdtNodes(editor.state.doc).find((sdt) => { + if (sdt.kind !== 'inline') return false; + if (!isMetadataAnchorNode(metadataIds, sdt.node)) return false; + const anchorFrom = sdt.pos + 1; + const anchorTo = sdt.pos + sdt.node.nodeSize - 1; + return rangesOverlap(anchorFrom, anchorTo, absFrom, absTo); + }); +} + +function assertNoOverlappingMetadataAnchor(editor: Editor, absFrom: number, absTo: number): void { + // AIDEV-NOTE: v1 text-range metadata anchors are non-overlapping. Reject + // containment too; nested SDTs are schema-valid, but safely supporting them + // needs a range model that does not duplicate existing SDT ids through open slices. + if (!findOverlappingMetadataAnchor(editor, absFrom, absTo)) return; + throw new DocumentApiAdapterError('INVALID_TARGET', 'metadata.attach does not support overlapping metadata anchors.'); +} + function anchorOverlaps(editor: Editor, id: string, within: SelectionTarget): boolean { const anchor = findAnchorsById(editor, id)[0]; if (!anchor) return false; @@ -349,56 +395,83 @@ function writeEntry( const convertedXml = getConvertedXml(editor); const converter = getConverter(editor); const existing = findPartByNamespace(convertedXml, namespace); - const entries = existing?.entries.filter((entry) => entry.id !== id) ?? []; const next: MetadataEntry = { id, namespace, partName: existing?.partName ?? predictPartName(convertedXml, converter), payload, }; - const xml = buildEnvelopeXml(namespace, [...entries, next]); if (dryRun) { + // Serialize just the new entry during the preview so a non-serializable + // payload (BigInt, cyclic object) fails here, before the live path mutates + // the document. metadata.attach runs this preview before wrapRangeInAnchor; + // without it, the live path would insert the SDT anchor and only then throw + // while building the envelope, leaving an anchor with no customXml payload. + // Only the new entry needs checking: existing entries were validated when + // they were written, and the live path re-serializes the full envelope. + buildEnvelopeXml(namespace, [next]); return { partName: next.partName }; } - if (existing) { - patchCustomXmlPart( - convertedXml, - { partName: existing.partName }, - { content: xml, schemaRefs: undefined }, - converter ?? undefined, + const partName = mutateCustomXmlParts(editor, 'metadata.writeEntry', (sandboxXml, sandboxConverter) => { + const sandboxExisting = findPartByNamespace(sandboxXml, namespace); + const sandboxEntries = sandboxExisting?.entries.filter((entry) => entry.id !== id) ?? []; + const sandboxNext: MetadataEntry = { + id, + namespace, + partName: sandboxExisting?.partName ?? predictPartName(sandboxXml, sandboxConverter), + payload, + }; + const sandboxEnvelope = buildEnvelopeXml(namespace, [...sandboxEntries, sandboxNext]); + + if (sandboxExisting) { + patchCustomXmlPart( + sandboxXml, + { partName: sandboxExisting.partName }, + { content: sandboxEnvelope, schemaRefs: undefined }, + sandboxConverter, + ); + return sandboxExisting.partName; + } + + const created = createCustomXmlPart( + sandboxXml, + { content: sandboxEnvelope, schemaRefs: undefined }, + sandboxConverter, ); - markConverterDirty(editor); - return { partName: existing.partName }; - } + return created.partName; + }); - const created = createCustomXmlPart(convertedXml, { content: xml, schemaRefs: undefined }, converter ?? undefined); - markConverterDirty(editor); - return { partName: created.partName }; + return { partName }; } function removeEntry(editor: Editor, id: string, dryRun: boolean): boolean { const convertedXml = getConvertedXml(editor); - const converter = getConverter(editor); const part = listMetadataParts(convertedXml).find((candidate) => candidate.entries.some((entry) => entry.id === id)); if (!part) return false; if (dryRun) return true; - const remaining = part.entries.filter((entry) => entry.id !== id); - if (remaining.length === 0) { - removeCustomXmlPart(convertedXml, { partName: part.partName }, converter ?? undefined); - } else { - patchCustomXmlPart( - convertedXml, - { partName: part.partName }, - { content: buildEnvelopeXml(part.namespace, remaining), schemaRefs: undefined }, - converter ?? undefined, + return mutateCustomXmlParts(editor, 'metadata.removeEntry', (sandboxXml, sandboxConverter) => { + const sandboxPart = listMetadataParts(sandboxXml).find((candidate) => + candidate.entries.some((entry) => entry.id === id), ); - } - markConverterDirty(editor); - return true; + if (!sandboxPart) return false; + + const remaining = sandboxPart.entries.filter((entry) => entry.id !== id); + if (remaining.length === 0) { + removeCustomXmlPart(sandboxXml, { partName: sandboxPart.partName }, sandboxConverter); + } else { + patchCustomXmlPart( + sandboxXml, + { partName: sandboxPart.partName }, + { content: buildEnvelopeXml(sandboxPart.namespace, remaining), schemaRefs: undefined }, + sandboxConverter, + ); + } + return true; + }); } function toSummary(entry: MetadataEntry, editor: Editor): AnchoredMetadataSummary { @@ -481,9 +554,10 @@ export function metadataAttachWrapper( rejectTrackedMode('metadata.attach', options); const id = input.id ?? uuidv4(); const convertedXml = getConvertedXml(editor); + let resolvedTarget: ReturnType; try { - resolveSelectionTarget(editor, input.target); + resolvedTarget = resolveSelectionTarget(editor, input.target); } catch (error) { const message = error instanceof Error ? error.message : String(error); // Preserve the distinction the resolver makes between a missing @@ -506,6 +580,18 @@ export function metadataAttachWrapper( return failure('INVALID_INPUT', `Anchored metadata id "${id}" already exists.`); } + try { + assertNoOverlappingMetadataAnchor(editor, resolvedTarget.absFrom, resolvedTarget.absTo); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (error instanceof DocumentApiAdapterError) { + if (error.code === 'TARGET_NOT_FOUND' || error.code === 'INVALID_TARGET') { + return failure(error.code, message); + } + } + throw error; + } + const preview = writeEntry(editor, input.namespace, id, input.payload, true); if (options?.dryRun) { // Mirror the revision guard that the live path runs inside @@ -515,15 +601,26 @@ export function metadataAttachWrapper( return { success: true, id, namespace: input.namespace, partName: preview.partName }; } - const receipt = executeDomainCommand( - editor, - () => { - wrapRangeInAnchor(editor, input.target, id); - writeEntry(editor, input.namespace, id, input.payload, false); - return true; - }, - { expectedRevision: options?.expectedRevision }, - ); + let receipt: ReturnType; + try { + receipt = executeDomainCommand( + editor, + () => { + wrapRangeInAnchor(editor, input.target, id); + writeEntry(editor, input.namespace, id, input.payload, false); + return true; + }, + { expectedRevision: options?.expectedRevision }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (error instanceof DocumentApiAdapterError) { + if (error.code === 'TARGET_NOT_FOUND' || error.code === 'INVALID_TARGET') { + return failure(error.code, message); + } + } + throw error; + } if (receipt.steps[0]?.effect !== 'changed') { return failure('INVALID_TARGET', 'metadata.attach did not change the document.'); @@ -550,7 +647,7 @@ export function metadataUpdateWrapper( if (!dryRun) { writeEntry(editor, existing.namespace, input.id, input.payload, false); } - return { changed: true, payload: { success: true, id: input.id } }; + return { changed: false, payload: { success: true, id: input.id } }; }, { dryRun: options?.dryRun ?? false, expectedRevision: options?.expectedRevision }, ); @@ -579,8 +676,8 @@ export function metadataRemoveWrapper( return executeOutOfBandMutation( editor, (dryRun) => { - const changed = removeEntry(editor, input.id, dryRun); - return { changed, payload: { success: true, id: input.id } }; + removeEntry(editor, input.id, dryRun); + return { changed: false, payload: { success: true, id: input.id } }; }, { dryRun: false, expectedRevision: options?.expectedRevision }, ); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/custom-xml-part-mutation.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/custom-xml-part-mutation.test.ts new file mode 100644 index 0000000000..9afbd34ce5 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/custom-xml-part-mutation.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Editor } from '../../core/Editor.js'; +import type { PreparedCustomXmlPartMutation } from './custom-xml-part-mutation.js'; + +const compoundMockState = vi.hoisted(() => ({ + forceRollback: false, +})); + +vi.mock('../../core/parts/mutation/compound-mutation.js', () => ({ + compoundMutation: vi.fn((request: { editor: Editor; execute: () => boolean }) => { + const editorWithConverter = request.editor as unknown as { + converter?: { convertedXml?: Record }; + }; + const snapshot = editorWithConverter.converter?.convertedXml + ? structuredClone(editorWithConverter.converter.convertedXml) + : undefined; + + let executeSuccess = false; + try { + executeSuccess = request.execute(); + } catch (error) { + if (snapshot && editorWithConverter.converter) { + editorWithConverter.converter.convertedXml = snapshot; + } + throw error; + } + + const success = executeSuccess && !compoundMockState.forceRollback; + if (!success && snapshot && editorWithConverter.converter) { + editorWithConverter.converter.convertedXml = snapshot; + } + return { success }; + }), +})); + +import { cleanupParts, createTestEditor, withPart } from '../../core/parts/testing/test-helpers.js'; +import { initRevision } from './revision-tracker.js'; +import { commitPreparedCustomXmlPartMutation, prepareCustomXmlPartMutation } from './custom-xml-part-mutation.js'; + +type TestEditor = ReturnType; +type ConverterWithCustomXmlState = TestEditor['converter'] & { + removedCustomXmlPaths?: Set; + bibliographyPart?: unknown; +}; + +function asEditor(editor: TestEditor): Editor { + return editor as unknown as Editor; +} + +function converterOf(editor: TestEditor): ConverterWithCustomXmlState { + return editor.converter as ConverterWithCustomXmlState; +} + +describe('custom-xml-part-mutation', () => { + let editor: TestEditor; + + beforeEach(() => { + compoundMockState.forceRollback = false; + editor = createTestEditor(); + initRevision(asEditor(editor)); + }); + + afterEach(() => { + vi.clearAllMocks(); + cleanupParts(); + }); + + it('leaves converter custom-XML state unchanged when the compound mutation rolls back', () => { + const partId = 'customXml/item1.xml'; + const originalPart = { type: 'element', name: 'original-root', elements: [] }; + const expectedOriginalPart = structuredClone(originalPart); + const nextPart = { type: 'element', name: 'next-root', elements: [] }; + const originalRemoved = new Set(['customXml/original-deleted.xml']); + const originalBibliography = { partPath: 'customXml/original.xml', cached: true }; + const nextRemoved = new Set(['customXml/next-deleted.xml']); + const nextBibliography = { partPath: partId, cached: false }; + const converter = converterOf(editor); + + withPart(editor, partId, originalPart); + converter.removedCustomXmlPaths = originalRemoved; + converter.bibliographyPart = originalBibliography; + compoundMockState.forceRollback = true; + + const prepared = prepareCustomXmlPartMutation( + asEditor(editor), + (convertedXml, sandbox) => { + convertedXml[partId] = nextPart; + sandbox.removedCustomXmlPaths = nextRemoved; + sandbox.bibliographyPart = nextBibliography; + return 'rollback-result'; + }, + 'customXml.parts.test', + ); + + const result = commitPreparedCustomXmlPartMutation(asEditor(editor), prepared, { + source: 'customXml.parts.test', + }); + + expect(result).toBe('rollback-result'); + expect(converter.convertedXml[partId]).toEqual(expectedOriginalPart); + expect(converter.removedCustomXmlPaths).toBe(originalRemoved); + expect(converter.removedCustomXmlPaths).toEqual(new Set(['customXml/original-deleted.xml'])); + expect(converter.bibliographyPart).toBe(originalBibliography); + }); + + it('updates converter custom-XML state after a successful compound mutation', () => { + const partId = 'customXml/item1.xml'; + const originalPart = { type: 'element', name: 'original-root', elements: [] }; + const nextPart = { type: 'element', name: 'next-root', elements: [] }; + const nextRemoved = new Set(['customXml/next-deleted.xml']); + const nextBibliography = { partPath: partId, cached: false }; + const converter = converterOf(editor); + + withPart(editor, partId, originalPart); + + const prepared = prepareCustomXmlPartMutation( + asEditor(editor), + (convertedXml, sandbox) => { + convertedXml[partId] = nextPart; + sandbox.removedCustomXmlPaths = nextRemoved; + sandbox.bibliographyPart = nextBibliography; + return { success: true }; + }, + 'customXml.parts.test', + ); + + const result = commitPreparedCustomXmlPartMutation(asEditor(editor), prepared, { + source: 'customXml.parts.test', + }); + + expect(result).toEqual({ success: true }); + expect(converter.convertedXml[partId]).toEqual(nextPart); + expect(converter.removedCustomXmlPaths).toEqual(nextRemoved); + expect(converter.removedCustomXmlPaths).not.toBe(nextRemoved); + expect(converter.bibliographyPart).toEqual(nextBibliography); + expect(converter.bibliographyPart).not.toBe(nextBibliography); + }); + + it('does not half-apply converter state when bibliography cloning fails', () => { + const originalRemoved = new Set(['customXml/original-deleted.xml']); + const originalBibliography = { partPath: 'customXml/original.xml', cached: true }; + const converter = converterOf(editor); + converter.removedCustomXmlPaths = originalRemoved; + converter.bibliographyPart = originalBibliography; + + const prepared: PreparedCustomXmlPartMutation = { + result: 'unreachable', + operations: [], + affectedParts: [], + removedCustomXmlPaths: new Set(['customXml/next-deleted.xml']), + bibliographyPart: () => undefined, + hasBibliographyPart: true, + }; + + expect(() => + commitPreparedCustomXmlPartMutation(asEditor(editor), prepared, { + source: 'customXml.parts.test', + }), + ).toThrow(); + + expect(converter.removedCustomXmlPaths).toBe(originalRemoved); + expect(converter.removedCustomXmlPaths).toEqual(new Set(['customXml/original-deleted.xml'])); + expect(converter.bibliographyPart).toBe(originalBibliography); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/custom-xml-part-mutation.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/custom-xml-part-mutation.ts new file mode 100644 index 0000000000..f6c29c4416 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/custom-xml-part-mutation.ts @@ -0,0 +1,217 @@ +import type { Editor } from '../../core/Editor.js'; +import type { PartId, PartOperation } from '../../core/parts/types.js'; +import { compoundMutation } from '../../core/parts/mutation/compound-mutation.js'; +import { mutateParts } from '../../core/parts/mutation/mutate-part.js'; +import { checkRevision } from './revision-tracker.js'; + +type ConverterWithCustomXmlState = { + convertedXml?: Record; + removedCustomXmlPaths?: Set; + bibliographyPart?: unknown; +}; + +type SandboxConverter = { + convertedXml: Record; + removedCustomXmlPaths?: Set; + bibliographyPart?: unknown; +}; + +export type PreparedCustomXmlPartMutation = { + result: TResult; + operations: PartOperation[]; + affectedParts: PartId[]; + removedCustomXmlPaths?: Set; + bibliographyPart?: unknown; + hasBibliographyPart: boolean; +}; + +export type CustomXmlPartMutationOptions = { + source: string; + expectedRevision?: string; +}; + +function getConverter(editor: Editor): ConverterWithCustomXmlState { + const converter = (editor as unknown as { converter?: ConverterWithCustomXmlState }).converter; + if (!converter?.convertedXml) { + throw new Error('Custom XML part mutation requires editor.converter.convertedXml.'); + } + return converter; +} + +function cloneValue(value: T): T { + return structuredClone(value); +} + +function hasOwn(record: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(record, key); +} + +function valuesEqual(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function toPartId(path: string): PartId { + return path as PartId; +} + +function replacePartData(target: unknown, source: unknown): void { + if (!target || typeof target !== 'object' || !source || typeof source !== 'object') { + throw new Error('Custom XML part mutation can only replace object-shaped XML parts.'); + } + + const targetRecord = target as Record; + const sourceRecord = source as Record; + + for (const key of Object.keys(targetRecord)) { + if (!hasOwn(sourceRecord, key)) delete targetRecord[key]; + } + for (const [key, value] of Object.entries(sourceRecord)) { + targetRecord[key] = cloneValue(value); + } +} + +function createSandboxConverter(converter: ConverterWithCustomXmlState): SandboxConverter { + const sandbox: SandboxConverter = { + convertedXml: cloneValue(converter.convertedXml ?? {}), + }; + + if (converter.removedCustomXmlPaths instanceof Set) { + sandbox.removedCustomXmlPaths = new Set(converter.removedCustomXmlPaths); + } + if (converter.bibliographyPart !== undefined) { + sandbox.bibliographyPart = cloneValue(converter.bibliographyPart); + } + + return sandbox; +} + +function createDeltaOperations( + editor: Editor, + source: string, + before: Record, + after: Record, +): PartOperation[] { + const paths = new Set([...Object.keys(before), ...Object.keys(after)]); + const operations: PartOperation[] = []; + + for (const path of paths) { + const existedBefore = hasOwn(before, path); + const existsAfter = hasOwn(after, path); + const partId = toPartId(path); + + if (!existedBefore && existsAfter) { + operations.push({ + editor, + partId, + operation: 'create', + source, + initial: cloneValue(after[path]), + }); + continue; + } + + if (existedBefore && !existsAfter) { + operations.push({ + editor, + partId, + operation: 'delete', + source, + }); + continue; + } + + if (existedBefore && existsAfter && !valuesEqual(before[path], after[path])) { + const nextPart = cloneValue(after[path]); + operations.push({ + editor, + partId, + operation: 'mutate', + source, + mutate: ({ part }) => replacePartData(part, nextPart), + }); + } + } + + return operations; +} + +function applyConverterState(editor: Editor, prepared: PreparedCustomXmlPartMutation): void { + const converter = getConverter(editor); + const nextRemovedCustomXmlPaths = prepared.removedCustomXmlPaths + ? new Set(prepared.removedCustomXmlPaths) + : undefined; + const nextBibliographyPart = prepared.hasBibliographyPart ? cloneValue(prepared.bibliographyPart) : undefined; + + if (nextRemovedCustomXmlPaths) { + converter.removedCustomXmlPaths = nextRemovedCustomXmlPaths; + } + if (prepared.hasBibliographyPart) { + converter.bibliographyPart = nextBibliographyPart; + } +} + +export function prepareCustomXmlPartMutation( + editor: Editor, + mutate: (convertedXml: Record, converter: SandboxConverter) => TResult, + source = 'customXml.parts', +): PreparedCustomXmlPartMutation { + const converter = getConverter(editor); + const before = converter.convertedXml ?? {}; + const sandbox = createSandboxConverter(converter); + const result = mutate(sandbox.convertedXml, sandbox); + const operations = createDeltaOperations(editor, source, before, sandbox.convertedXml); + + return { + result, + operations, + affectedParts: operations.map((operation) => operation.partId), + removedCustomXmlPaths: sandbox.removedCustomXmlPaths, + bibliographyPart: sandbox.bibliographyPart, + hasBibliographyPart: Object.prototype.hasOwnProperty.call(sandbox, 'bibliographyPart'), + }; +} + +export function commitPreparedCustomXmlPartMutation( + editor: Editor, + prepared: PreparedCustomXmlPartMutation, + options: CustomXmlPartMutationOptions, +): TResult { + if (prepared.operations.length === 0) { + checkRevision(editor, options.expectedRevision); + applyConverterState(editor, prepared); + return prepared.result; + } + + const result = compoundMutation({ + editor, + source: options.source, + affectedParts: prepared.affectedParts, + execute() { + const mutation = mutateParts({ + editor, + source: options.source, + expectedRevision: options.expectedRevision, + operations: prepared.operations.map((operation) => ({ ...operation, source: options.source })), + }); + return mutation.changed; + }, + }); + if (result.success) { + applyConverterState(editor, prepared); + } + + return prepared.result; +} + +export function mutateCustomXmlParts( + editor: Editor, + source: string, + mutate: (convertedXml: Record, converter: SandboxConverter) => TResult, + options?: { expectedRevision?: string }, +): TResult { + const prepared = prepareCustomXmlPartMutation(editor, mutate, source); + return commitPreparedCustomXmlPartMutation(editor, prepared, { + source, + expectedRevision: options?.expectedRevision, + }); +} diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/custom-xml-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/custom-xml-wrappers.ts index 686b10f287..9e90b8937d 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/custom-xml-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/custom-xml-wrappers.ts @@ -26,6 +26,7 @@ import { removeCustomXmlPart, resolveTargetPartName, } from '../../core/super-converter/custom-xml-parts.js'; +import { commitPreparedCustomXmlPartMutation, prepareCustomXmlPartMutation } from './custom-xml-part-mutation.js'; // --------------------------------------------------------------------------- // Converter access @@ -169,14 +170,16 @@ export function customXmlPartsCreateWrapper( }; } const probe = safeValidate(() => - createCustomXmlPart( - getConvertedXml(editor), - { content: input.content, schemaRefs: input.schemaRefs }, - getConverter(editor), + prepareCustomXmlPartMutation( + editor, + (convertedXml, converter) => + createCustomXmlPart(convertedXml, { content: input.content, schemaRefs: input.schemaRefs }, converter), + 'customXml.parts.create', ), ); if (isWriteFailure(probe)) return { changed: false, payload: probe }; - return { changed: true, payload: { ok: true, payload: probe.payload } }; + const created = commitPreparedCustomXmlPartMutation(editor, probe.payload, { source: 'customXml.parts.create' }); + return { changed: false, payload: { ok: true, payload: created } }; }, { dryRun: options?.dryRun === true, expectedRevision: options?.expectedRevision }, ); @@ -211,16 +214,22 @@ export function customXmlPartsPatchWrapper( const partName = resolveTargetPartName(getConvertedXml(editor), input.target); if (!partName) return { changed: false, payload: targetNotFound() }; const probe = safeValidate(() => - patchCustomXmlPart( - getConvertedXml(editor), - input.target, - { content: input.content, schemaRefs: input.schemaRefs }, - getConverter(editor), + prepareCustomXmlPartMutation( + editor, + (convertedXml, converter) => + patchCustomXmlPart( + convertedXml, + input.target, + { content: input.content, schemaRefs: input.schemaRefs }, + converter, + ), + 'customXml.parts.patch', ), ); if (isWriteFailure(probe)) return { changed: false, payload: probe }; - if (!probe.payload) return { changed: false, payload: targetNotFound() }; - return { changed: true, payload: { ok: true, payload: { id: probe.payload.id ?? null } } }; + if (!probe.payload.result) return { changed: false, payload: targetNotFound() }; + const patched = commitPreparedCustomXmlPartMutation(editor, probe.payload, { source: 'customXml.parts.patch' }); + return { changed: false, payload: { ok: true, payload: { id: patched.id ?? null } } }; }, { dryRun: options?.dryRun === true, expectedRevision: options?.expectedRevision }, ); @@ -245,9 +254,14 @@ export function customXmlPartsRemoveWrapper( ? { changed: false, payload: { ok: true, payload: true } } : { changed: false, payload: targetNotFound() }; } - const ok = removeCustomXmlPart(getConvertedXml(editor), input.target, getConverter(editor)); - if (!ok) return { changed: false, payload: targetNotFound() }; - return { changed: true, payload: { ok: true, payload: true } }; + const probe = prepareCustomXmlPartMutation( + editor, + (convertedXml, converter) => removeCustomXmlPart(convertedXml, input.target, converter), + 'customXml.parts.remove', + ); + if (!probe.result) return { changed: false, payload: targetNotFound() }; + commitPreparedCustomXmlPartMutation(editor, probe, { source: 'customXml.parts.remove' }); + return { changed: false, payload: { ok: true, payload: true } }; }, { dryRun: options?.dryRun === true, expectedRevision: options?.expectedRevision }, ); diff --git a/packages/super-editor/src/editors/v1/extensions/collaboration/part-sync/bootstrap.test.ts b/packages/super-editor/src/editors/v1/extensions/collaboration/part-sync/bootstrap.test.ts index ed131f16c6..1316497724 100644 --- a/packages/super-editor/src/editors/v1/extensions/collaboration/part-sync/bootstrap.test.ts +++ b/packages/super-editor/src/editors/v1/extensions/collaboration/part-sync/bootstrap.test.ts @@ -180,6 +180,190 @@ describe('bootstrapPartSync', () => { handle.destroy(); }); + it('prunes and tombstones local custom-XML parts absent from an authoritative parts map (late joiner)', () => { + const editor = createMockEditor(); + + // Late joiner loaded the original DOCX: convertedXml holds a custom-XML part + // (item1) that a peer already deleted, plus one (item2) still shared. + const survivingData = { type: 'element', name: 'root', elements: [] }; + editor.converter.convertedXml['customXml/item1.xml'] = { type: 'element', name: 'root', elements: [] }; + editor.converter.convertedXml['customXml/itemProps1.xml'] = { + type: 'element', + name: 'ds:datastoreItem', + elements: [], + }; + editor.converter.convertedXml['customXml/_rels/item1.xml.rels'] = { + type: 'element', + name: 'Relationships', + elements: [], + }; + editor.converter.convertedXml['customXml/item2.xml'] = survivingData; + + // Authoritative map (capability marker present) carries item2 but NOT item1. + const metaMap = ydoc.getMap(META_MAP_KEY); + metaMap.set(META_PARTS_CAPABILITY_KEY, { version: 1, enabledAt: '', clientId: 0 }); + const partsMap = ydoc.getMap(PARTS_MAP_KEY); + partsMap.set('customXml/item2.xml', encodeEnvelopeToYjs({ v: 1, clientId: 0, data: survivingData })); + + const handle = bootstrapPartSync(editor, ydoc); + + // item1 family pruned locally and tombstoned for export. + expect(editor.converter.convertedXml['customXml/item1.xml']).toBeUndefined(); + expect(editor.converter.convertedXml['customXml/itemProps1.xml']).toBeUndefined(); + expect(editor.converter.convertedXml['customXml/_rels/item1.xml.rels']).toBeUndefined(); + + const removed = (editor.converter as unknown as { removedCustomXmlPaths?: Set }).removedCustomXmlPaths; + expect(removed?.has('customXml/item1.xml')).toBe(true); + expect(removed?.has('customXml/itemProps1.xml')).toBe(true); + expect(removed?.has('customXml/_rels/item1.xml.rels')).toBe(true); + + // The part still present in the authoritative map survives. + expect(editor.converter.convertedXml['customXml/item2.xml']).toBeDefined(); + expect(removed?.has('customXml/item2.xml')).toBeFalsy(); + + handle.destroy(); + }); + + it('clears stale custom-XML tombstones for parts present in the authoritative parts map', () => { + const editor = createMockEditor(); + const converter = editor.converter as typeof editor.converter & { + removedCustomXmlPaths: Set; + }; + const recreatedItemData = { type: 'element', name: 'recreated-root', elements: [] }; + const recreatedPropsData = { + type: 'element', + name: 'ds:datastoreItem', + elements: [], + }; + const recreatedRelsData = { + type: 'element', + name: 'Relationships', + elements: [], + }; + const absentItemData = { type: 'element', name: 'absent-root', elements: [] }; + const staleTombstonePaths = ['customXml/item1.xml', 'customXml/itemProps1.xml', 'customXml/_rels/item1.xml.rels']; + + converter.removedCustomXmlPaths = new Set(staleTombstonePaths); + converter.convertedXml['customXml/item2.xml'] = absentItemData; + + const metaMap = ydoc.getMap(META_MAP_KEY); + metaMap.set(META_PARTS_CAPABILITY_KEY, { version: 1, enabledAt: '', clientId: 0 }); + const partsMap = ydoc.getMap(PARTS_MAP_KEY); + partsMap.set('customXml/item1.xml', encodeEnvelopeToYjs({ v: 1, clientId: 0, data: recreatedItemData })); + partsMap.set('customXml/itemProps1.xml', encodeEnvelopeToYjs({ v: 1, clientId: 0, data: recreatedPropsData })); + partsMap.set('customXml/_rels/item1.xml.rels', encodeEnvelopeToYjs({ v: 1, clientId: 0, data: recreatedRelsData })); + + const handle = bootstrapPartSync(editor, ydoc); + + expect(converter.convertedXml['customXml/item1.xml']).toEqual(recreatedItemData); + expect(converter.convertedXml['customXml/itemProps1.xml']).toEqual(recreatedPropsData); + expect(converter.convertedXml['customXml/_rels/item1.xml.rels']).toEqual(recreatedRelsData); + for (const path of staleTombstonePaths) { + expect(converter.removedCustomXmlPaths.has(path)).toBe(false); + } + + expect(converter.convertedXml['customXml/item2.xml']).toBeUndefined(); + expect(converter.removedCustomXmlPaths.has('customXml/item2.xml')).toBe(true); + + handle.destroy(); + }); + + it('keeps stale custom-XML tombstones when an authoritative part fails hydration', () => { + const editor = createMockEditor(); + const converter = editor.converter as typeof editor.converter & { + removedCustomXmlPaths: Set; + }; + const staleLocalData = { type: 'element', name: 'stale-root', elements: [] }; + const hydratedData = { type: 'element', name: 'hydrated-root', elements: [] }; + + converter.convertedXml['customXml/item1.xml'] = staleLocalData; + converter.removedCustomXmlPaths = new Set(['customXml/item1.xml', 'customXml/item2.xml']); + + const metaMap = ydoc.getMap(META_MAP_KEY); + metaMap.set(META_PARTS_CAPABILITY_KEY, { version: 1, enabledAt: '', clientId: 0 }); + const partsMap = ydoc.getMap(PARTS_MAP_KEY); + partsMap.set('customXml/item1.xml', { v: 'invalid', clientId: 0, data: staleLocalData }); + partsMap.set('customXml/item2.xml', encodeEnvelopeToYjs({ v: 1, clientId: 0, data: hydratedData })); + + const handle = bootstrapPartSync(editor, ydoc); + + expect(converter.convertedXml['customXml/item1.xml']).toEqual(staleLocalData); + expect(converter.convertedXml['customXml/item2.xml']).toEqual(hydratedData); + expect(converter.removedCustomXmlPaths.has('customXml/item1.xml')).toBe(true); + expect(converter.removedCustomXmlPaths.has('customXml/item2.xml')).toBe(false); + + handle.destroy(); + }); + + it('invalidates the bibliography cache when hydration receives the cached storage part', () => { + const editor = createMockEditor(); + const converter = editor.converter as typeof editor.converter & { + bibliographyPart?: { partPath: string | null } | null; + }; + + const bibliographyPartPath = 'customXml/item1.xml'; + // Local cache points at a storage part the authoritative map has updated. + converter.bibliographyPart = { partPath: bibliographyPartPath }; + converter.convertedXml[bibliographyPartPath] = { type: 'element', name: 'stale-root', elements: [] }; + + const freshData = { type: 'element', name: 'fresh-root', elements: [] }; + const metaMap = ydoc.getMap(META_MAP_KEY); + metaMap.set(META_PARTS_CAPABILITY_KEY, { version: 1, enabledAt: '', clientId: 0 }); + const partsMap = ydoc.getMap(PARTS_MAP_KEY); + partsMap.set(bibliographyPartPath, encodeEnvelopeToYjs({ v: 1, clientId: 0, data: freshData })); + + const handle = bootstrapPartSync(editor, ydoc); + + // The map's content replaces the local part… + expect(converter.convertedXml[bibliographyPartPath]).toEqual(freshData); + // …and the stale cache is invalidated so export cannot resurrect it. + expect(converter.bibliographyPart?.partPath).toBeNull(); + + handle.destroy(); + }); + + it('does not tombstone absent custom-XML parts when hydration rolls back', () => { + const editor = createMockEditor(); + const prunedPartId = 'customXml/item1.xml' as const; + const survivingPartId = 'customXml/item2.xml' as const; + const converter = editor.converter as typeof editor.converter & { + bibliographyPart?: { partPath: string | null } | null; + removedCustomXmlPaths?: Set; + }; + const originalSurvivingData = { type: 'element', name: 'local-root', elements: [] }; + const expectedSurvivingData = { type: 'element', name: 'local-root', elements: [] }; + const remoteSurvivingData = { type: 'element', name: 'remote-root', elements: [] }; + + converter.convertedXml[prunedPartId] = { type: 'element', name: 'pruned-root', elements: [] }; + converter.convertedXml[survivingPartId] = originalSurvivingData; + converter.bibliographyPart = { partPath: prunedPartId }; + + registerPartDescriptor({ + id: prunedPartId, + ensurePart() { + return { type: 'element', name: 'pruned-root', elements: [] }; + }, + onDelete() { + throw new Error('delete failed'); + }, + }); + + const metaMap = ydoc.getMap(META_MAP_KEY); + metaMap.set(META_PARTS_CAPABILITY_KEY, { version: 1, enabledAt: '', clientId: 0 }); + const partsMap = ydoc.getMap(PARTS_MAP_KEY); + partsMap.set(survivingPartId, encodeEnvelopeToYjs({ v: 1, clientId: 0, data: remoteSurvivingData })); + + const handle = bootstrapPartSync(editor, ydoc); + + expect(handle.publisher).toBeNull(); + expect(converter.convertedXml[prunedPartId]).toBeDefined(); + expect(converter.convertedXml[survivingPartId]).toEqual(expectedSurvivingData); + expect(converter.removedCustomXmlPaths?.has(prunedPartId)).toBeFalsy(); + expect(converter.bibliographyPart?.partPath).toBe(prunedPartId); + + handle.destroy(); + }); + it('registers partChanged listener and cleans up on destroy', () => { const editor = createMockEditor(); const metaMap = ydoc.getMap(META_MAP_KEY); diff --git a/packages/super-editor/src/editors/v1/extensions/collaboration/part-sync/bootstrap.ts b/packages/super-editor/src/editors/v1/extensions/collaboration/part-sync/bootstrap.ts index cff6087bb6..04fb571679 100644 --- a/packages/super-editor/src/editors/v1/extensions/collaboration/part-sync/bootstrap.ts +++ b/packages/super-editor/src/editors/v1/extensions/collaboration/part-sync/bootstrap.ts @@ -14,8 +14,17 @@ import type { Editor } from '../../../core/Editor.js'; import type { PartId } from '../../../core/parts/types.js'; import type { PartsCapability } from './types.js'; import { createPartPublisher, type PartPublisher } from './publisher.js'; -import { createPartConsumer, replacePartData, type PartConsumer } from './consumer.js'; +import { + createPartConsumer, + replacePartData, + isCustomXmlPartPath, + isCustomXmlTombstonePath, + getCustomXmlTombstoneConverter, + recordCustomXmlTombstone, + type PartConsumer, +} from './consumer.js'; import { decodeYjsToEnvelope } from './json-crdt.js'; +import { invalidateConverterCachesForPath } from '../../../core/super-converter/custom-xml-parts.js'; import { isMigrationNeeded, migrateMetaDocxToParts } from './migration-from-meta-docx.js'; import { seedPartsFromEditor } from './seed-parts.js'; import { mutateParts, hasPart } from '../../../core/parts/index.js'; @@ -213,9 +222,17 @@ function hydrateFromPartsMap(editor: Editor, ydoc: Y.Doc, partsMap: Y.Map(); + const hydratedCustomXmlPaths = new Set(); + for (const [key, value] of partsMap.entries()) { if (EXCLUDED_PART_IDS.has(key)) continue; + presentKeys.add(key); const partId = key as PartId; // Resolve sectionId (rId) for header/footer parts so afterCommit @@ -248,6 +265,9 @@ function hydrateFromPartsMap(editor: Editor, ydoc: Y.Doc, partsMap: Y.Map, + operations: import('../../../core/parts/types.js').PartOperation[], +): Set { + const pathsToTombstone = new Set(); + if (presentKeys.size === 0) return pathsToTombstone; + + const converter = getCustomXmlTombstoneConverter(editor); + const convertedXml = converter?.convertedXml; + if (!converter || !convertedXml) return pathsToTombstone; + + for (const key of Object.keys(convertedXml)) { + if (EXCLUDED_PART_IDS.has(key)) continue; + if (presentKeys.has(key)) continue; + if (!isCustomXmlTombstonePath(editor, key)) continue; + + pathsToTombstone.add(key); + if (hasPart(editor, key as PartId)) { + operations.push({ + editor, + partId: key as PartId, + operation: 'delete', + source: SOURCE_COLLAB_REMOTE_PARTS, + }); + } + } + + return pathsToTombstone; +} + +function applyCustomXmlHydrationTombstoneChanges( + editor: Editor, + pathsToTombstone: Set, + pathsToClear: Set, +): void { + if (pathsToTombstone.size === 0 && pathsToClear.size === 0) return; + + const converter = getCustomXmlTombstoneConverter(editor); + if (!converter) return; + + for (const path of pathsToClear) { + // Clear only when the part actually hydrated into local state. A key that + // is present in the authoritative map but failed to decode never landed in + // convertedXml; clearing its tombstone would let export copy the stale + // original zip entry through instead of dropping the part. + if (!converter.convertedXml || !Object.prototype.hasOwnProperty.call(converter.convertedXml, path)) continue; + // The part hydrated locally: drop any stale tombstone and invalidate the + // bibliography cache so the next export rebuilds from hydrated content + // instead of resurrecting the stale cache. + if (converter.removedCustomXmlPaths instanceof Set) { + converter.removedCustomXmlPaths.delete(path); + } + invalidateConverterCachesForPath(converter, path); + } + + for (const path of pathsToTombstone) { + recordCustomXmlTombstone(converter, path); + } +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/packages/super-editor/src/editors/v1/extensions/collaboration/part-sync/consumer.test.ts b/packages/super-editor/src/editors/v1/extensions/collaboration/part-sync/consumer.test.ts index 0bdc2b3fa9..82f1be741f 100644 --- a/packages/super-editor/src/editors/v1/extensions/collaboration/part-sync/consumer.test.ts +++ b/packages/super-editor/src/editors/v1/extensions/collaboration/part-sync/consumer.test.ts @@ -1,11 +1,19 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as Y from 'yjs'; -import { createPartConsumer, isApplyingRemotePartChanges } from './consumer.js'; +import { + createPartConsumer, + isApplyingRemotePartChanges, + isCustomXmlPartPath, + isCustomXmlTombstonePath, +} from './consumer.js'; import { encodeEnvelopeToYjs } from './json-crdt.js'; import { PARTS_MAP_KEY } from './constants.js'; import { registerPartDescriptor, clearPartDescriptors } from '../../../core/parts/registry/part-registry.js'; import { clearInvalidationHandlers } from '../../../core/parts/invalidation/part-invalidation-registry.js'; +const CUSTOM_XML_PROPS_RELATIONSHIP_TYPE = + 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXmlProps'; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -44,6 +52,35 @@ function writeRemoteEnvelope(localDoc: Y.Doc, partId: string, data: unknown, v = remoteDoc.destroy(); } +function deleteRemoteParts(localDoc: Y.Doc, partIds: string[]) { + const remoteDoc = new Y.Doc(); + Y.applyUpdate(remoteDoc, Y.encodeStateAsUpdate(localDoc)); + + const remotePartsMap = remoteDoc.getMap(PARTS_MAP_KEY); + remoteDoc.transact(() => { + for (const partId of partIds) { + remotePartsMap.delete(partId); + } + }); + + Y.applyUpdate(localDoc, Y.encodeStateAsUpdate(remoteDoc)); + remoteDoc.destroy(); +} + +function getMockConverter(editor: import('../../../core/Editor.js').Editor): { + convertedXml: Record; + removedCustomXmlPaths?: Set; +} { + return ( + editor as unknown as { + converter: { + convertedXml: Record; + removedCustomXmlPaths?: Set; + }; + } + ).converter; +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -157,4 +194,129 @@ describe('PartConsumer', () => { // The part should remain as-is (null data is invalid) consumer.destroy(); }); + + it('marks remote customXml deletes as converter tombstones', () => { + const editor = createMockEditor(); + const converter = getMockConverter(editor); + const customXmlPaths = [ + 'customXml/item1.xml', + 'customXml/itemProps1.xml', + 'customXml/itemPropsFOREIGN.xml', + 'customXml/_rels/item1.xml.rels', + ]; + const itemRelsPart = { + elements: [ + { + name: 'Relationships', + elements: [ + { + name: 'Relationship', + attributes: { + Type: CUSTOM_XML_PROPS_RELATIONSHIP_TYPE, + Target: './itemPropsFOREIGN.xml', + }, + }, + ], + }, + ], + }; + + for (const path of customXmlPaths) { + const data = path.endsWith('.rels') ? itemRelsPart : { elements: [] }; + converter.convertedXml[path] = data; + writeRemoteEnvelope(ydoc, path, data); + } + + const consumer = createPartConsumer(editor, ydoc); + deleteRemoteParts(ydoc, customXmlPaths); + + for (const path of customXmlPaths) { + expect(converter.convertedXml[path]).toBeUndefined(); + expect(converter.removedCustomXmlPaths?.has(path)).toBe(true); + } + + consumer.destroy(); + }); + + it('clears converter.bibliographyPart when the deleted part is the bibliography storage part', () => { + const editor = createMockEditor(); + const converter = getMockConverter(editor) as ReturnType & { + bibliographyPart?: { partPath: string | null } | null; + }; + + const bibliographyPartPath = 'customXml/item1.xml'; + const customXmlPaths = [bibliographyPartPath, 'customXml/itemProps1.xml', 'customXml/_rels/item1.xml.rels']; + + // Stub the bibliography cache the way the exporter would leave it after + // import: it points at the storage part that a peer is about to delete. + converter.bibliographyPart = { partPath: bibliographyPartPath }; + + for (const path of customXmlPaths) { + converter.convertedXml[path] = { elements: [] }; + writeRemoteEnvelope(ydoc, path, { elements: [] }); + } + + const consumer = createPartConsumer(editor, ydoc); + deleteRemoteParts(ydoc, customXmlPaths); + + // Tombstones recorded for every removed path… + for (const path of customXmlPaths) { + expect(converter.removedCustomXmlPaths?.has(path)).toBe(true); + } + // …and the bibliography cache no longer points at the deleted part, so + // syncBibliographyPartToPackage cannot resurrect it on the next export. + expect(converter.bibliographyPart?.partPath).toBeNull(); + + consumer.destroy(); + }); + + it('invalidates the bibliography cache when a remote write targets the cached storage part', () => { + const editor = createMockEditor(); + const converter = getMockConverter(editor) as ReturnType & { + bibliographyPart?: { partPath: string | null } | null; + }; + + const bibliographyPartPath = 'customXml/item1.xml'; + // Cache points at the storage part a collaborator is about to overwrite. + converter.bibliographyPart = { partPath: bibliographyPartPath }; + + const consumer = createPartConsumer(editor, ydoc); + + // Remote collaborator creates/updates the very part the cache references. + writeRemoteEnvelope(ydoc, bibliographyPartPath, { type: 'element', name: 'root', elements: [] }); + + // The written content must be applied locally… + expect(converter.convertedXml[bibliographyPartPath]).toBeDefined(); + // …and the stale bibliography cache must be invalidated so the next export + // rebuilds from the received content instead of overwriting it. + expect(converter.bibliographyPart?.partPath).toBeNull(); + + consumer.destroy(); + }); + + it('custom-XML path predicates agree on canonical-but-uppercased part names', () => { + const path = 'CustomXML/item1.xml'; + expect(isCustomXmlPartPath(path)).toBe(isCustomXmlTombstonePath({} as never, path)); + expect(isCustomXmlPartPath(path)).toBe(true); + }); + + it('marks remote customXml deletes as tombstones when the local part is already absent', () => { + const editor = createMockEditor(); + const converter = getMockConverter(editor); + const customXmlPaths = ['customXml/item1.xml', 'customXml/itemProps1.xml', 'customXml/_rels/item1.xml.rels']; + + for (const path of customXmlPaths) { + writeRemoteEnvelope(ydoc, path, { elements: [] }); + } + + const consumer = createPartConsumer(editor, ydoc); + deleteRemoteParts(ydoc, customXmlPaths); + + expect(Object.keys(converter.convertedXml)).toEqual([]); + for (const path of customXmlPaths) { + expect(converter.removedCustomXmlPaths?.has(path)).toBe(true); + } + + consumer.destroy(); + }); }); diff --git a/packages/super-editor/src/editors/v1/extensions/collaboration/part-sync/consumer.ts b/packages/super-editor/src/editors/v1/extensions/collaboration/part-sync/consumer.ts index 837d43597a..983663d9a3 100644 --- a/packages/super-editor/src/editors/v1/extensions/collaboration/part-sync/consumer.ts +++ b/packages/super-editor/src/editors/v1/extensions/collaboration/part-sync/consumer.ts @@ -18,6 +18,10 @@ import { ensureHeaderFooterDescriptor, } from '../../../core/parts/adapters/header-footer-part-descriptor.js'; import { resolveHeaderFooterRId } from '../../../core/parts/adapters/header-footer-sync.js'; +import { invalidateConverterCachesForPath } from '../../../core/super-converter/custom-xml-parts.js'; + +const CUSTOM_XML_PROPS_RELATIONSHIP_TYPE = + 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXmlProps'; // --------------------------------------------------------------------------- // Consumer State @@ -28,6 +32,12 @@ export interface PartConsumer { destroy(): void; } +export type ConverterWithCustomXmlTombstones = { + convertedXml?: Record; + removedCustomXmlPaths?: Set; + bibliographyPart?: { partPath?: string | null } | null; +}; + // --------------------------------------------------------------------------- // Guard: prevents publisher from re-publishing remote applies // --------------------------------------------------------------------------- @@ -51,6 +61,8 @@ export function createPartConsumer(editor: Editor, ydoc: Y.Doc): PartConsumer { if (transaction.local) return; const operations: PartOperation[] = []; + const removedCustomXmlPaths = new Set(); + const writtenCustomXmlPaths = new Set(); // Decode rels from Yjs for header/footer rId resolution const relsData = decodeYjsToEnvelope(partsMap.get('word/_rels/document.xml.rels'))?.data ?? null; @@ -65,6 +77,9 @@ export function createPartConsumer(editor: Editor, ydoc: Y.Doc): PartConsumer { try { if (change.action === 'delete') { + if (isCustomXmlTombstonePath(editor, key)) { + removedCustomXmlPaths.add(key); + } if (hasPart(editor, partId)) { operations.push({ editor, @@ -114,6 +129,9 @@ export function createPartConsumer(editor: Editor, ydoc: Y.Doc): PartConsumer { initial: envelope.data, }); } + if (isCustomXmlPartPath(key)) { + writtenCustomXmlPaths.add(key); + } // Clear from failed on successful build failedParts.delete(key); @@ -123,11 +141,15 @@ export function createPartConsumer(editor: Editor, ydoc: Y.Doc): PartConsumer { } }); - if (operations.length === 0) return; + if (operations.length === 0) { + applyCustomXmlTombstoneChanges(editor, removedCustomXmlPaths, writtenCustomXmlPaths); + return; + } isApplyingRemoteParts = true; try { mutateParts({ editor, source: SOURCE_COLLAB_REMOTE_PARTS, operations }); + applyCustomXmlTombstoneChanges(editor, removedCustomXmlPaths, writtenCustomXmlPaths); } catch (err) { console.error('[part-sync] Failed to apply remote part changes:', err); } finally { @@ -185,6 +207,133 @@ function ensureHeaderFooterSectionId(partId: PartId, relsData: unknown | null, e return sectionId; } +export function getCustomXmlTombstoneConverter(editor: Editor): ConverterWithCustomXmlTombstones | undefined { + return (editor as unknown as { converter?: ConverterWithCustomXmlTombstones }).converter; +} + +export function isCustomXmlPartPath(path: string): boolean { + // Case-insensitive to stay consistent with the tombstone predicates + // (isCustomXmlTombstonePath) which already match /i. + return /^customxml\//i.test(path); +} + +export function isCustomXmlTombstonePath(editor: Editor, path: string): boolean { + return ( + /^customXml\/item\d+\.xml$/i.test(path) || + /^customXml\/itemProps\d+\.xml$/i.test(path) || + /^customXml\/_rels\/item\d+\.xml\.rels$/i.test(path) || + isLinkedCustomXmlPropsPath(editor, path) + ); +} + +function isLinkedCustomXmlPropsPath(editor: Editor, path: string): boolean { + if (!isCustomXmlPartPath(path) || !/\.xml$/i.test(path)) return false; + + const convertedXml = getCustomXmlTombstoneConverter(editor)?.convertedXml; + if (!convertedXml) return false; + + for (const [relsPath, relsDoc] of Object.entries(convertedXml)) { + if (!/^customXml\/_rels\/item\d+\.xml\.rels$/i.test(relsPath)) continue; + for (const target of getCustomXmlPropsTargets(relsDoc)) { + if (target === path) return true; + } + } + + return false; +} + +function getCustomXmlPropsTargets(relsDoc: unknown): string[] { + const relationshipsRoot = getElements(relsDoc).find((element) => getLocalName(getName(element)) === 'Relationships'); + if (!relationshipsRoot) return []; + + return getElements(relationshipsRoot) + .map((relationship) => { + const attributes = getAttributes(relationship); + if (attributes.Type !== CUSTOM_XML_PROPS_RELATIONSHIP_TYPE) return null; + return resolveCustomXmlRelationshipTarget(attributes.Target); + }) + .filter((target): target is string => typeof target === 'string'); +} + +function getElements(value: unknown): unknown[] { + if (!value || typeof value !== 'object') return []; + const elements = (value as { elements?: unknown }).elements; + return Array.isArray(elements) ? elements : []; +} + +function getName(value: unknown): string { + if (!value || typeof value !== 'object') return ''; + const name = (value as { name?: unknown }).name; + return typeof name === 'string' ? name : ''; +} + +function getAttributes(value: unknown): Record { + if (!value || typeof value !== 'object') return {}; + const attributes = (value as { attributes?: unknown }).attributes; + return attributes && typeof attributes === 'object' && !Array.isArray(attributes) + ? (attributes as Record) + : {}; +} + +function getLocalName(name: string): string { + const separatorIndex = name.indexOf(':'); + return separatorIndex >= 0 ? name.slice(separatorIndex + 1) : name; +} + +function resolveCustomXmlRelationshipTarget(target: unknown): string | null { + if (typeof target !== 'string' || target.length === 0 || target.includes('://')) return null; + if (target.startsWith('/')) return target.slice(1); + + const resolved: string[] = []; + for (const segment of `customXml/${target}`.split('/')) { + if (segment === '..') { + resolved.pop(); + } else if (segment !== '.' && segment !== '') { + resolved.push(segment); + } + } + + return resolved.join('/'); +} + +function applyCustomXmlTombstoneChanges(editor: Editor, removedPaths: Set, writtenPaths: Set): void { + const converter = getCustomXmlTombstoneConverter(editor); + if (!converter) return; + + for (const path of writtenPaths) { + // A remote write (create/update/recreate) supersedes any tombstone and also + // makes the local bibliography cache stale: if the written part is the one + // cached in converter.bibliographyPart, the next export would rebuild from + // the stale cache and overwrite the received content. Invalidate so the + // export reads the freshly received part instead. + if (converter.removedCustomXmlPaths instanceof Set) { + converter.removedCustomXmlPaths.delete(path); + } + invalidateConverterCachesForPath(converter, path); + } + + if (removedPaths.size === 0) return; + for (const path of removedPaths) { + recordCustomXmlTombstone(converter, path); + } +} + +/** + * Record a custom-XML tombstone on the converter and invalidate any converter + * cache keyed on the removed part. Shared by the live-delete observer and the + * late-joiner hydration prune so both paths tombstone identically and both + * clear `converter.bibliographyPart` when the deleted part is the bibliography + * storage part (otherwise `syncBibliographyPartToPackage` would resurrect it + * from the stale cache on the next export). + */ +export function recordCustomXmlTombstone(converter: ConverterWithCustomXmlTombstones, path: string): void { + if (!(converter.removedCustomXmlPaths instanceof Set)) { + converter.removedCustomXmlPaths = new Set(); + } + converter.removedCustomXmlPaths.add(path); + invalidateConverterCachesForPath(converter, path); +} + function trackFailure(failedParts: Map, key: string, partsMap: Y.Map): void { const envelope = decodeYjsToEnvelope(partsMap.get(key)); if (envelope) { diff --git a/packages/super-editor/src/editors/v1/extensions/collaboration/part-sync/custom-xml-part-sync.integration.test.ts b/packages/super-editor/src/editors/v1/extensions/collaboration/part-sync/custom-xml-part-sync.integration.test.ts new file mode 100644 index 0000000000..f8ca97ed22 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/collaboration/part-sync/custom-xml-part-sync.integration.test.ts @@ -0,0 +1,316 @@ +import { describe, expect, it, vi } from 'vitest'; +import * as Y from 'yjs'; +import { Awareness } from 'y-protocols/awareness.js'; +import { initTestEditor } from '../../../tests/helpers/helpers.js'; +import { PARTS_MAP_KEY } from './constants.js'; +import { decodeYjsToEnvelope } from './json-crdt.js'; +import type { Editor } from '../../../core/Editor.js'; + +const HOST_TEXT = 'Alpha metadata target.'; +const METADATA_ID = 'meta-sync-1'; +const METADATA_NAMESPACE = 'urn:test:metadata-sync'; +const CUSTOM_XML_PATHS = [ + 'customXml/item1.xml', + 'customXml/itemProps1.xml', + 'customXml/_rels/item1.xml.rels', + 'word/_rels/document.xml.rels', +] as const; + +type TestProvider = { + synced: boolean; + isSynced: boolean; + on: ReturnType; + off: ReturnType; + disconnect: ReturnType; + awareness: Awareness; +}; + +type EditorWithConverter = Editor & { + converter: { + convertedXml: Record; + }; +}; + +function createProviderStub(ydoc: Y.Doc): TestProvider { + return { + synced: true, + isSynced: true, + on: vi.fn(), + off: vi.fn(), + disconnect: vi.fn(), + awareness: new Awareness(ydoc), + }; +} + +function schemaDoc(text: string) { + return { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { paraId: 'p1' }, + content: [ + { + type: 'run', + attrs: {}, + content: [{ type: 'text', text }], + }, + ], + }, + ], + }; +} + +function createCollaborativeEditor(ydoc: Y.Doc): Editor { + return initTestEditor({ + loadFromSchema: true, + content: schemaDoc(HOST_TEXT), + isHeadless: true, + ydoc, + collaborationProvider: createProviderStub(ydoc), + useImmediateSetTimeout: false, + user: { name: 'Test', email: 'test@example.com' }, + }).editor as Editor; +} + +function getConvertedXml(editor: Editor): Record { + return (editor as EditorWithConverter).converter.convertedXml; +} + +function getPartsMap(ydoc: Y.Doc): Y.Map { + return ydoc.getMap(PARTS_MAP_KEY) as Y.Map; +} + +function syncYDocs(source: Y.Doc, target: Y.Doc): void { + Y.applyUpdate(target, Y.encodeStateAsUpdate(source)); +} + +function partData(ydoc: Y.Doc, path: string): unknown { + return decodeYjsToEnvelope(getPartsMap(ydoc).get(path))?.data; +} + +function trackPartsUpdateTransactions(ydoc: Y.Doc): { count: () => number; destroy: () => void } { + let count = 0; + const handler = (transaction: Y.Transaction) => { + const origin = transaction.origin as { event?: unknown } | null; + if (origin?.event === 'parts-update') count += 1; + }; + ydoc.on('afterTransaction', handler); + return { + count: () => count, + destroy: () => ydoc.off('afterTransaction', handler), + }; +} + +function resolveBlockId(insertReceipt: unknown): string { + const receipt = insertReceipt as { + target?: { blockId?: unknown }; + resolution?: { target?: { blockId?: unknown } }; + }; + const direct = receipt.target?.blockId; + if (typeof direct === 'string' && direct.length > 0) return direct; + const resolved = receipt.resolution?.target?.blockId; + if (typeof resolved === 'string' && resolved.length > 0) return resolved; + throw new Error('insert receipt did not include a blockId'); +} + +function relationshipTargets(relsPart: unknown): string[] { + const doc = relsPart as + | { + elements?: Array<{ + name?: string; + elements?: Array<{ attributes?: Record }>; + }>; + } + | undefined; + const root = doc?.elements?.find((element) => element.name === 'Relationships'); + return (root?.elements ?? []) + .map((relationship) => relationship.attributes?.Target) + .filter((target): target is string => typeof target === 'string'); +} + +async function waitForPart(editor: Editor, path: string): Promise { + await vi.waitFor(() => { + expect(getConvertedXml(editor)[path]).toBeDefined(); + }); +} + +describe('custom XML writes through Yjs part-sync', () => { + it('syncs anchored metadata payload parts after part-sync is active', async () => { + const ydocA = new Y.Doc(); + const ydocB = new Y.Doc(); + const editorA = createCollaborativeEditor(ydocA); + const editorB = createCollaborativeEditor(ydocB); + const partsUpdateTracker = trackPartsUpdateTransactions(ydocA); + + try { + await vi.waitFor(() => { + expect((editorA as unknown as { _partPublisher?: unknown })._partPublisher).toBeDefined(); + expect((editorB as unknown as { _partPublisher?: unknown })._partPublisher).toBeDefined(); + }); + + const inserted = await Promise.resolve(editorA.doc.insert({ value: HOST_TEXT })); + const blockId = resolveBlockId(inserted); + const start = HOST_TEXT.indexOf('metadata'); + const end = start + 'metadata'.length; + + const attached = editorA.doc.metadata.attach({ + id: METADATA_ID, + namespace: METADATA_NAMESPACE, + payload: { label: 'Alpha' }, + target: { + kind: 'selection', + start: { kind: 'text', blockId, offset: start }, + end: { kind: 'text', blockId, offset: end }, + }, + }); + expect(attached.success).toBe(true); + expect(partsUpdateTracker.count()).toBe(1); + + for (const path of CUSTOM_XML_PATHS) { + expect(getPartsMap(ydocA).has(path)).toBe(true); + } + expect(relationshipTargets(partData(ydocA, 'word/_rels/document.xml.rels'))).toContain('../customXml/item1.xml'); + + syncYDocs(ydocA, ydocB); + + for (const path of CUSTOM_XML_PATHS) { + await waitForPart(editorB, path); + } + expect(relationshipTargets(getConvertedXml(editorB)['word/_rels/document.xml.rels'])).toContain( + '../customXml/item1.xml', + ); + + await vi.waitFor(() => { + expect(editorB.doc.metadata.get({ id: METADATA_ID })?.payload).toEqual({ label: 'Alpha' }); + }); + expect(editorB.doc.metadata.list({ namespace: METADATA_NAMESPACE }).items).toEqual([ + expect.objectContaining({ + id: METADATA_ID, + namespace: METADATA_NAMESPACE, + partName: 'customXml/item1.xml', + }), + ]); + + const updated = editorA.doc.metadata.update({ + id: METADATA_ID, + payload: { label: 'Beta' }, + }); + expect(updated.success).toBe(true); + expect(partsUpdateTracker.count()).toBe(2); + + syncYDocs(ydocA, ydocB); + await vi.waitFor(() => { + expect(editorB.doc.metadata.get({ id: METADATA_ID })?.payload).toEqual({ label: 'Beta' }); + }); + + const removed = editorA.doc.metadata.remove({ id: METADATA_ID }); + expect(removed.success).toBe(true); + expect(partsUpdateTracker.count()).toBe(3); + + expect(getPartsMap(ydocA).has('customXml/item1.xml')).toBe(false); + expect(getPartsMap(ydocA).has('customXml/itemProps1.xml')).toBe(false); + expect(getPartsMap(ydocA).has('customXml/_rels/item1.xml.rels')).toBe(false); + expect(relationshipTargets(partData(ydocA, 'word/_rels/document.xml.rels'))).not.toContain( + '../customXml/item1.xml', + ); + + syncYDocs(ydocA, ydocB); + await vi.waitFor(() => { + expect(getConvertedXml(editorB)['customXml/item1.xml']).toBeUndefined(); + expect(getConvertedXml(editorB)['customXml/itemProps1.xml']).toBeUndefined(); + expect(getConvertedXml(editorB)['customXml/_rels/item1.xml.rels']).toBeUndefined(); + }); + expect(relationshipTargets(getConvertedXml(editorB)['word/_rels/document.xml.rels'])).not.toContain( + '../customXml/item1.xml', + ); + expect(editorB.doc.metadata.list({ namespace: METADATA_NAMESPACE }).items).toEqual([]); + } finally { + partsUpdateTracker.destroy(); + editorA.destroy(); + editorB.destroy(); + ydocA.destroy(); + ydocB.destroy(); + } + }); + + it('syncs customXml.parts create, patch, and remove as complete package deltas', async () => { + const ydocA = new Y.Doc(); + const ydocB = new Y.Doc(); + const editorA = createCollaborativeEditor(ydocA); + const editorB = createCollaborativeEditor(ydocB); + const partsUpdateTracker = trackPartsUpdateTransactions(ydocA); + + try { + await vi.waitFor(() => { + expect((editorA as unknown as { _partPublisher?: unknown })._partPublisher).toBeDefined(); + expect((editorB as unknown as { _partPublisher?: unknown })._partPublisher).toBeDefined(); + }); + + const created = editorA.doc.customXml.parts.create({ + content: 'one', + schemaRefs: ['urn:test:custom-sync'], + }); + expect(created.success).toBe(true); + if (!created.success) return; + expect(partsUpdateTracker.count()).toBe(1); + + for (const path of CUSTOM_XML_PATHS) { + expect(getPartsMap(ydocA).has(path)).toBe(true); + } + expect(relationshipTargets(partData(ydocA, 'word/_rels/document.xml.rels'))).toContain('../customXml/item1.xml'); + + syncYDocs(ydocA, ydocB); + for (const path of CUSTOM_XML_PATHS) { + await waitForPart(editorB, path); + } + + await vi.waitFor(() => { + expect(editorB.doc.customXml.parts.get({ target: { id: created.id } })?.content).toContain('>one<'); + }); + + const patched = editorA.doc.customXml.parts.patch({ + target: { id: created.id }, + content: 'two', + schemaRefs: ['urn:test:custom-sync', 'urn:test:custom-sync:patched'], + }); + expect(patched.success).toBe(true); + expect(partsUpdateTracker.count()).toBe(2); + + syncYDocs(ydocA, ydocB); + await vi.waitFor(() => { + const info = editorB.doc.customXml.parts.get({ target: { id: created.id } }); + expect(info?.content).toContain('>two<'); + expect(info?.schemaRefs).toEqual(['urn:test:custom-sync', 'urn:test:custom-sync:patched']); + }); + + const removed = editorA.doc.customXml.parts.remove({ target: { id: created.id } }); + expect(removed.success).toBe(true); + expect(partsUpdateTracker.count()).toBe(3); + + expect(getPartsMap(ydocA).has('customXml/item1.xml')).toBe(false); + expect(getPartsMap(ydocA).has('customXml/itemProps1.xml')).toBe(false); + expect(getPartsMap(ydocA).has('customXml/_rels/item1.xml.rels')).toBe(false); + expect(relationshipTargets(partData(ydocA, 'word/_rels/document.xml.rels'))).not.toContain( + '../customXml/item1.xml', + ); + + syncYDocs(ydocA, ydocB); + await vi.waitFor(() => { + expect(getConvertedXml(editorB)['customXml/item1.xml']).toBeUndefined(); + expect(getConvertedXml(editorB)['customXml/itemProps1.xml']).toBeUndefined(); + expect(getConvertedXml(editorB)['customXml/_rels/item1.xml.rels']).toBeUndefined(); + }); + expect(relationshipTargets(getConvertedXml(editorB)['word/_rels/document.xml.rels'])).not.toContain( + '../customXml/item1.xml', + ); + expect(editorB.doc.customXml.parts.list().items).toEqual([]); + } finally { + partsUpdateTracker.destroy(); + editorA.destroy(); + editorB.destroy(); + ydocA.destroy(); + ydocB.destroy(); + } + }); +});