Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/document-api/src/metadata/anchored-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
);
});
});

// ---------------------------------------------------------------------------
Expand Down
6 changes: 6 additions & 0 deletions packages/document-api/src/metadata/anchored-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 & {
Expand Down
8 changes: 8 additions & 0 deletions packages/super-editor/src/editors/v1/core/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -3396,6 +3397,13 @@ export class Editor extends EventEmitter<EditorEventMap> {
}
}

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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js';

function seedMetadataPart(
convertedXml: Record<string, unknown>,
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<string, unknown> } }).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();
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand All @@ -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<string, unknown> } }).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('<refs xmlns="urn:test:metadata"></refs>');
});

it('does not mutate storage during attach dry-run', () => {
const editor = makeEditor();

Expand Down Expand Up @@ -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();
});
});

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ type ConverterWithConvertedXml = {
promoteToGuid?: () => string;
};

type MetadataEntry = AnchoredMetadataInfo;
type MetadataEntry = Omit<AnchoredMetadataInfo, 'anchorStatus'>;

type MetadataPart = {
namespace: string;
Expand Down Expand Up @@ -167,6 +167,20 @@ function listMetadataParts(convertedXml: Record<string, unknown>): MetadataPart[
.filter((part): part is MetadataPart => part !== null);
}

export function buildFilteredMetadataXml(
editor: Editor,
convertedXml: Record<string, unknown>,
options?: { finalDoc?: boolean },
): Record<string, string> {
const filtered: Record<string, string> = {};
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<string, unknown>, namespace: string): MetadataPart | null {
return listMetadataParts(convertedXml).find((part) => part.namespace === namespace) ?? null;
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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',
Comment thread
artem-harbour marked this conversation as resolved.
};
}

Expand All @@ -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'),
Expand All @@ -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(
Expand Down
Loading
Loading