diff --git a/packages/document-api/src/metadata/anchored-metadata.test.ts b/packages/document-api/src/metadata/anchored-metadata.test.ts index 2271ca30e7..30c57bad33 100644 --- a/packages/document-api/src/metadata/anchored-metadata.test.ts +++ b/packages/document-api/src/metadata/anchored-metadata.test.ts @@ -211,6 +211,11 @@ describe('metadata.list validation', () => { ).not.toThrow(); }); + it('accepts resolvedOnly filter', () => { + const adapter = makeAdapter(); + expect(() => executeAnchoredMetadataList(adapter, { resolvedOnly: true })).not.toThrow(); + }); + it('rejects non-string namespace', () => { const adapter = makeAdapter(); expect(() => executeAnchoredMetadataList(adapter, { namespace: 42 as unknown as string })).toThrow( @@ -238,6 +243,13 @@ describe('metadata.list validation', () => { executeAnchoredMetadataList(adapter, { within: { foo: 'bar' } as unknown as SelectionTarget }), ).toThrow(DocumentApiValidationError); }); + + it('rejects non-boolean resolvedOnly', () => { + const adapter = makeAdapter(); + expect(() => executeAnchoredMetadataList(adapter, { resolvedOnly: 'yes' as unknown as boolean })).toThrow( + DocumentApiValidationError, + ); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/document-api/src/metadata/anchored-metadata.ts b/packages/document-api/src/metadata/anchored-metadata.ts index 0ef851d5e7..e1a792e586 100644 --- a/packages/document-api/src/metadata/anchored-metadata.ts +++ b/packages/document-api/src/metadata/anchored-metadata.ts @@ -182,6 +182,12 @@ export function executeAnchoredMetadataList( if (query?.within !== undefined) { validateWithin(query.within, 'metadata.list'); } + if (query?.resolvedOnly !== undefined && typeof query.resolvedOnly !== 'boolean') { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `metadata.list 'resolvedOnly' must be a boolean when provided.`, + ); + } return adapter.list(query); } diff --git a/packages/document-api/src/metadata/anchored-metadata.types.ts b/packages/document-api/src/metadata/anchored-metadata.types.ts index 7f96610f95..30bdf015bc 100644 --- a/packages/document-api/src/metadata/anchored-metadata.types.ts +++ b/packages/document-api/src/metadata/anchored-metadata.types.ts @@ -126,6 +126,11 @@ export interface AnchoredMetadataListInput { * constraints as {@link MetadataTarget}: text-range only. */ within?: SelectionTarget; + /** + * When true, include only entries whose SDT anchor currently resolves + * in the document body. + */ + resolvedOnly?: boolean; limit?: number; offset?: number; } @@ -165,6 +170,8 @@ export interface AnchoredMetadataSummary { namespace: string; /** Package-relative path of the backing Storage Part. */ partName: string; + /** Whether the corresponding SDT anchor currently exists in the document. */ + anchorStatus: 'resolved' | 'orphan'; } export type AnchoredMetadataInfo = AnchoredMetadataSummary & { diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index 399074ab15..34c417a6de 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -92,6 +92,7 @@ import { applyEffectiveEditability, getProtectionStorage } from '../extensions/p import { getViewModeSelectionWithoutStructuredContent } from './helpers/getViewModeSelectionWithoutStructuredContent.js'; import { resolveMainBodyEditor } from '../document-api-adapters/helpers/word-statistics.js'; import { commitLiveStorySessionRuntimes } from '../document-api-adapters/story-runtime/live-story-session-runtime-registry.js'; +import { buildFilteredMetadataXml } from '../document-api-adapters/plan-engine/anchored-metadata-wrappers.js'; declare const __APP_VERSION__: string | undefined; declare const version: string | undefined; @@ -3396,6 +3397,13 @@ export class Editor extends EventEmitter { } } + if (isFinalDoc) { + const filteredMetadataParts = buildFilteredMetadataXml(this, this.converter.convertedXml, { finalDoc: true }); + for (const [path, xml] of Object.entries(filteredMetadataParts)) { + updatedDocs[path] = xml; + } + } + for (const path of Object.keys(this.converter.convertedXml)) { if (!path.startsWith('customXml/')) continue; if (!path.endsWith('.xml') && !path.endsWith('.rels')) continue; 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 new file mode 100644 index 0000000000..203cf3c7f8 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/anchored-metadata-export.integration.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; + +function seedMetadataPart( + convertedXml: Record, + partName: string, + namespace: string, + entries: Array<{ id: string; json: string }>, +): void { + convertedXml[partName] = { + elements: [ + { + type: 'element', + name: 'refs', + attributes: { xmlns: namespace }, + elements: entries.map((entry) => ({ + type: 'element', + name: 'ref', + attributes: { id: entry.id, encoding: 'json' }, + elements: [{ type: 'text', text: entry.json }], + })), + }, + ], + }; +} + +async function createEditorWithEmptyPackage() { + const docData = await loadTestDataForEditorTests('blank-doc.docx'); + const { editor } = initTestEditor({ + content: docData.docx, + media: docData.media, + mediaFiles: docData.mediaFiles, + fonts: docData.fonts, + useImmediateSetTimeout: false, + isHeadless: true, + user: { name: 'Test', email: 'test@example.com' }, + }); + return editor; +} + +describe('anchored metadata export filtering', () => { + it('removes anchored-metadata entries from customXml when exporting final doc', async () => { + const editor = await createEditorWithEmptyPackage(); + + try { + editor.commands.insertContent('Hello'); + editor.commands.insertStructuredContentInline({ + attrs: { + id: '101', + tag: 'meta-resolved', + alias: 'Anchored metadata', + }, + text: 'Anchor', + }); + + const convertedXml = (editor as unknown as { converter: { convertedXml: Record } }).converter + .convertedXml; + seedMetadataPart(convertedXml, 'customXml/item1.xml', 'urn:test:metadata', [ + { id: 'meta-resolved', json: '{"v":1}' }, + { id: 'meta-orphan', json: '{"v":2}' }, + ]); + + const updatedDocs = (await editor.exportDocx({ isFinalDoc: true, getUpdatedDocs: true })) as Record< + string, + string | null + >; + + const metadataXml = updatedDocs['customXml/item1.xml']; + expect(typeof metadataXml).toBe('string'); + expect(metadataXml).not.toContain('meta-resolved'); + expect(metadataXml).not.toContain('meta-orphan'); + } finally { + editor.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 1c529528df..e469d0203b 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 @@ -2,6 +2,7 @@ import type { Node as ProseMirrorNode } from 'prosemirror-model'; import { describe, expect, it, vi } from 'vitest'; import type { Editor } from '../../core/Editor.js'; import { + buildFilteredMetadataXml, metadataAttachWrapper, metadataGetWrapper, metadataListWrapper, @@ -195,8 +196,20 @@ describe('anchored metadata wrappers', () => { expect(attached).toMatchObject({ success: true, id: 'meta-1', namespace: 'urn:test:metadata' }); expect( - metadataListWrapper(editor).items.map(({ id, namespace, partName }) => ({ id, namespace, partName })), - ).toEqual([{ id: 'meta-1', namespace: 'urn:test:metadata', partName: 'customXml/item1.xml' }]); + metadataListWrapper(editor).items.map(({ id, namespace, partName, anchorStatus }) => ({ + id, + namespace, + partName, + anchorStatus, + })), + ).toEqual([ + { + id: 'meta-1', + namespace: 'urn:test:metadata', + partName: 'customXml/item1.xml', + anchorStatus: 'orphan', + }, + ]); expect(metadataGetWrapper(editor, { id: 'meta-1' })?.payload).toEqual({ label: 'Alpha' }); expect(metadataUpdateWrapper(editor, { id: 'meta-1', payload: { label: 'Beta' } })).toEqual({ @@ -219,6 +232,49 @@ describe('anchored metadata wrappers', () => { expect(metadataListWrapper(editor, { namespace: 'urn:b' }).items.map((item) => item.id)).toEqual(['b']); }); + it('marks entries as orphan and supports resolvedOnly filtering', () => { + const editor = makeEditor(); + seedPayload(editor, 'customXml/item1.xml', 'urn:test:metadata', [{ id: 'meta-orphan', json: '{"v":1}' }]); + + const listed = metadataListWrapper(editor); + expect(listed.items).toHaveLength(1); + expect(listed.items[0]).toMatchObject({ id: 'meta-orphan', anchorStatus: 'orphan' }); + + const resolvedOnly = metadataListWrapper(editor, { resolvedOnly: true }); + expect(resolvedOnly.total).toBe(0); + expect(resolvedOnly.items).toEqual([]); + }); + + it('buildFilteredMetadataXml in final-doc mode removes all anchored-metadata entries', () => { + const sdt = createNode('structuredContent', [createNode('text', [], { text: 'Hello' })], { + attrs: { id: '100', tag: 'meta-resolved' }, + isInline: true, + isBlock: false, + inlineContent: true, + }); + const paragraph = createNode('paragraph', [sdt], { + attrs: { sdBlockId: 'p1' }, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [paragraph], { isBlock: false }); + const editor = makeEditor(doc); + + seedPayload(editor, 'customXml/item1.xml', 'urn:test:metadata', [ + { id: 'meta-resolved', json: '{"v":1}' }, + { id: 'meta-orphan', json: '{"v":2}' }, + ]); + + const filtered = buildFilteredMetadataXml( + editor, + (editor as unknown as { converter: { convertedXml: Record } }).converter.convertedXml, + { finalDoc: true }, + ); + expect(filtered['customXml/item1.xml']).not.toContain('meta-resolved'); + expect(filtered['customXml/item1.xml']).not.toContain('meta-orphan'); + expect(filtered['customXml/item1.xml']).toContain(''); + }); + it('does not mutate storage during attach dry-run', () => { const editor = makeEditor(); @@ -368,6 +424,28 @@ describe('anchored metadata wrappers', () => { expect(metadataResolveWrapper(editor, { id: 'meta-1' })).toBeNull(); }); + + it('treats block SDT with matching tag as orphan for list/resolve semantics', () => { + const blockSdt = createNode('structuredContentBlock', [createNode('text', [], { text: 'Hello' })], { + attrs: { id: '200', tag: 'meta-1' }, + isInline: false, + isBlock: true, + inlineContent: true, + }); + const doc = createNode('doc', [blockSdt], { isBlock: false }); + const editor = makeEditor(doc); + seedPayload(editor, 'customXml/item1.xml', 'urn:test:metadata', [{ id: 'meta-1', json: '{"label":"Alpha"}' }]); + + const listed = metadataListWrapper(editor); + expect(listed.items).toHaveLength(1); + expect(listed.items[0]).toMatchObject({ id: 'meta-1', anchorStatus: 'orphan' }); + + const resolvedOnly = metadataListWrapper(editor, { resolvedOnly: true }); + expect(resolvedOnly.total).toBe(0); + expect(resolvedOnly.items).toEqual([]); + + expect(metadataResolveWrapper(editor, { id: 'meta-1' })).toBeNull(); + }); }); /** 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 25ac89125d..ae016513b1 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 @@ -61,7 +61,7 @@ type ConverterWithConvertedXml = { promoteToGuid?: () => string; }; -type MetadataEntry = AnchoredMetadataInfo; +type MetadataEntry = Omit; type MetadataPart = { namespace: string; @@ -167,6 +167,20 @@ function listMetadataParts(convertedXml: Record): MetadataPart[ .filter((part): part is MetadataPart => part !== null); } +export function buildFilteredMetadataXml( + editor: Editor, + convertedXml: Record, + options?: { finalDoc?: boolean }, +): Record { + const filtered: Record = {}; + const finalDoc = options?.finalDoc === true; + for (const part of listMetadataParts(convertedXml)) { + const resolvedEntries = finalDoc ? [] : part.entries.filter((entry) => hasAnchor(editor, entry.id)); + filtered[part.partName] = buildEnvelopeXml(part.namespace, resolvedEntries); + } + return filtered; +} + function findPartByNamespace(convertedXml: Record, namespace: string): MetadataPart | null { return listMetadataParts(convertedXml).find((part) => part.namespace === namespace) ?? null; } @@ -225,7 +239,7 @@ function findAnchorsById(editor: Editor, id: string) { } function hasAnchor(editor: Editor, id: string): boolean { - return findAllSdtNodes(editor.state.doc).some((sdt) => sdt.node.attrs?.tag === id); + return findAnchorsById(editor, id).length > 0; } function wrapRangeInAnchor(editor: Editor, target: SelectionTarget, id: string): boolean { @@ -387,11 +401,12 @@ function removeEntry(editor: Editor, id: string, dryRun: boolean): boolean { return true; } -function toSummary(entry: MetadataEntry): AnchoredMetadataSummary { +function toSummary(entry: MetadataEntry, editor: Editor): AnchoredMetadataSummary { return { id: entry.id, namespace: entry.namespace, partName: entry.partName, + anchorStatus: hasAnchor(editor, entry.id) ? 'resolved' : 'orphan', }; } @@ -403,12 +418,15 @@ function listEntries(editor: Editor, query?: AnchoredMetadataListInput): Metadat if (query?.within !== undefined) { entries = entries.filter((entry) => anchorOverlaps(editor, entry.id, query.within as SelectionTarget)); } + if (query?.resolvedOnly) { + entries = entries.filter((entry) => hasAnchor(editor, entry.id)); + } return entries; } export function metadataListWrapper(editor: Editor, query?: AnchoredMetadataListInput): AnchoredMetadataListResult { const allItems = listEntries(editor, query).map((entry) => { - const summary = toSummary(entry); + const summary = toSummary(entry, editor); return buildDiscoveryItem( summary.id, buildResolvedHandle(`metadata:${summary.id}`, 'ephemeral', 'ext:anchoredMetadata'), @@ -430,7 +448,12 @@ export function metadataListWrapper(editor: Editor, query?: AnchoredMetadataList } export function metadataGetWrapper(editor: Editor, input: AnchoredMetadataGetInput): AnchoredMetadataInfo | null { - return findEntry(getConvertedXml(editor), input.id); + const entry = findEntry(getConvertedXml(editor), input.id); + if (!entry) return null; + return { + ...entry, + anchorStatus: hasAnchor(editor, entry.id) ? 'resolved' : 'orphan', + }; } export function metadataResolveWrapper( diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js index 581296ba52..653e3efda5 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js @@ -1044,6 +1044,50 @@ describe('TrackChanges extension commands', () => { } }); + it('interaction(docx): rejectTrackedChangesBetween keeps inserted inline structured content wrapper', () => { + const { editor: interactionEditor } = initTestEditor({ + mode: 'docx', + content: '

', + user: { name: 'Track Tester', email: 'track@example.com' }, + }); + + try { + interactionEditor.setDocumentMode('suggesting'); + interactionEditor.commands.insertStructuredContentInline({ + attrs: { + id: '101', + tag: 'meta-track-1', + alias: 'Anchored metadata', + }, + text: 'Tracked SDT', + }); + + let hasStructuredContent = false; + interactionEditor.state.doc.descendants((node) => { + if (node.type.name === 'structuredContent' && node.attrs?.tag === 'meta-track-1') { + hasStructuredContent = true; + return false; + } + }); + expect(hasStructuredContent).toBe(true); + + interactionEditor.commands.rejectTrackedChangesBetween(0, interactionEditor.state.doc.content.size); + + hasStructuredContent = false; + interactionEditor.state.doc.descendants((node) => { + if (node.type.name === 'structuredContent' && node.attrs?.tag === 'meta-track-1') { + hasStructuredContent = true; + return false; + } + }); + // Current behavior: rejecting tracked insertion clears inserted text, + // but does not remove the inline SDT wrapper node itself. + expect(hasStructuredContent).toBe(true); + } finally { + interactionEditor.destroy(); + } + }); + it('interaction: rejectTrackedChangeOnSelection reverts mixed marks + textStyle in suggesting mode', () => { const { editor: interactionEditor } = initTestEditor({ mode: 'text',