diff --git a/frontend/src/lib/utils/mentionSerializer.spec.ts b/frontend/src/lib/utils/mentionSerializer.spec.ts new file mode 100644 index 00000000..19b4c4a2 --- /dev/null +++ b/frontend/src/lib/utils/mentionSerializer.spec.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from 'vitest'; +import { deserialize, serialize } from './mentionSerializer'; +import type { PersonMention } from '$lib/types'; + +// ─── deserialize ───────────────────────────────────────────────────────────── + +describe('deserialize', () => { + it('returns a valid Tiptap doc for an empty string', () => { + const doc = deserialize('', []); + expect(doc.type).toBe('doc'); + expect(doc.content).toHaveLength(1); + expect(doc.content![0].type).toBe('paragraph'); + }); + + it('returns plain text node for text with no mentions', () => { + const doc = deserialize('Hallo Welt', []); + const para = doc.content![0]; + expect(para.content).toHaveLength(1); + expect(para.content![0]).toEqual({ type: 'text', text: 'Hallo Welt' }); + }); + + it('places a mention node at the correct position in the paragraph', () => { + const sidecar: PersonMention[] = [{ personId: 'uuid-x', displayName: 'Clara' }]; + const doc = deserialize('Hallo @Clara Welt', sidecar); + const nodes = doc.content![0].content!; + expect(nodes).toHaveLength(3); + expect(nodes[0]).toEqual({ type: 'text', text: 'Hallo ' }); + expect(nodes[1]).toMatchObject({ + type: 'mention', + attrs: { displayName: 'Clara', personId: 'uuid-x' } + }); + expect(nodes[2]).toEqual({ type: 'text', text: ' Welt' }); + }); + + it('prefers the longer displayName when two sidecar entries share a prefix', () => { + const sidecar: PersonMention[] = [ + { personId: 'uuid-short', displayName: 'Auguste' }, + { personId: 'uuid-long', displayName: 'Auguste Raddatz' } + ]; + const doc = deserialize('@Auguste Raddatz', sidecar); + const nodes = doc.content![0].content!; + expect(nodes).toHaveLength(1); + expect(nodes[0]).toMatchObject({ + type: 'mention', + attrs: { personId: 'uuid-long', displayName: 'Auguste Raddatz' } + }); + }); + + it('splits multiple paragraphs on newline', () => { + const doc = deserialize('Zeile 1\nZeile 2', []); + expect(doc.content).toHaveLength(2); + expect(doc.content![0].content![0]).toEqual({ type: 'text', text: 'Zeile 1' }); + expect(doc.content![1].content![0]).toEqual({ type: 'text', text: 'Zeile 2' }); + }); +}); + +// ─── serialize ─────────────────────────────────────────────────────────────── + +describe('serialize', () => { + it('serializes plain text unchanged', () => { + const doc = deserialize('Hallo Welt', []); + const { text, mentionedPersons } = serialize(doc); + expect(text).toBe('Hallo Welt'); + expect(mentionedPersons).toEqual([]); + }); + + it('serializes mention node back to @displayName', () => { + const sidecar: PersonMention[] = [{ personId: 'uuid-x', displayName: 'Clara' }]; + const doc = deserialize('Hallo @Clara Welt', sidecar); + const { text, mentionedPersons } = serialize(doc); + expect(text).toBe('Hallo @Clara Welt'); + expect(mentionedPersons).toEqual([{ personId: 'uuid-x', displayName: 'Clara' }]); + }); + + it('joins multi-paragraph doc back with newlines', () => { + const doc = deserialize('Zeile 1\nZeile 2', []); + const { text } = serialize(doc); + expect(text).toBe('Zeile 1\nZeile 2'); + }); + + it('de-duplicates repeated mentions in the sidecar', () => { + const sidecar: PersonMention[] = [{ personId: 'uuid-x', displayName: 'Clara' }]; + const doc = deserialize('@Clara und @Clara', sidecar); + const { mentionedPersons } = serialize(doc); + expect(mentionedPersons).toHaveLength(1); + expect(mentionedPersons[0].personId).toBe('uuid-x'); + }); +}); + +// ─── Round-trip invariant ───────────────────────────────────────────────────── + +describe('round-trip invariant', () => { + it('text is preserved exactly through deserialize → serialize', () => { + const cases = [ + ['', []], + ['Hallo Welt', []], + ['@Clara schreibt', [{ personId: 'uuid-x', displayName: 'Clara' }]], + ['Zeile 1\nZeile 2', []], + ['Sehr geehrte @Auguste Raddatz,', [{ personId: 'uuid-aug', displayName: 'Auguste Raddatz' }]] + ] as const; + + for (const [text, sidecar] of cases) { + const doc = deserialize(text, sidecar as PersonMention[]); + const { text: out } = serialize(doc); + expect(out, `round-trip failed for: "${text}"`).toBe(text); + } + }); +}); + +// ─── Backward compatibility (AC-6) ──────────────────────────────────────────── + +describe('backward compatibility', () => { + it('old-format full-name sidecar entry still round-trips correctly', () => { + // Before this issue, displayName stored the person's full DB name. + // renderTranscriptionBody already handles this — so does the serializer. + const oldSidecar: PersonMention[] = [{ personId: 'uuid-aug', displayName: 'Auguste Raddatz' }]; + const text = 'Brief von @Auguste Raddatz'; + const doc = deserialize(text, oldSidecar); + const { text: out, mentionedPersons } = serialize(doc); + expect(out).toBe(text); + expect(mentionedPersons).toEqual(oldSidecar); + }); +}); + +// ─── Security ───────────────────────────────────────────────────────────────── + +describe('security', () => { + it('displayName containing HTML special chars is preserved as a string, not injected', () => { + const sidecar: PersonMention[] = [ + { personId: 'uuid-x', displayName: '' } + ]; + const text = '@'; + const doc = deserialize(text, sidecar); + const { mentionedPersons } = serialize(doc); + // The displayName is stored verbatim — HTML escaping is the renderer's job + expect(mentionedPersons[0].displayName).toBe(''); + }); +}); diff --git a/frontend/src/lib/utils/mentionSerializer.ts b/frontend/src/lib/utils/mentionSerializer.ts new file mode 100644 index 00000000..b6213f25 --- /dev/null +++ b/frontend/src/lib/utils/mentionSerializer.ts @@ -0,0 +1,113 @@ +import type { JSONContent } from '@tiptap/core'; +import type { PersonMention } from '$lib/types'; + +/** + * Converts stored block text + sidecar into a Tiptap ProseMirror document. + * + * The text is split by "\n" into paragraphs. Within each paragraph, sidecar + * entries are matched against "@displayName" tokens (longest first) and + * converted to mention nodes. Unmatched text becomes plain text nodes. + * + * Round-trip invariant: serialize(deserialize(text, sidecar)).text === text + */ +export function deserialize(text: string, sidecar: PersonMention[]): JSONContent { + const lines = text === '' ? [''] : text.split('\n'); + + // Sort sidecar by displayName length descending so longer names shadow + // shorter prefix matches (same heuristic as renderTranscriptionBody). + const sorted = [...sidecar].sort((a, b) => b.displayName.length - a.displayName.length); + + return { + type: 'doc', + content: lines.map((line) => ({ + type: 'paragraph', + content: parseLine(line, sorted) + })) + }; +} + +function parseLine(text: string, sidecar: PersonMention[]): JSONContent[] { + if (text === '') return []; + + if (sidecar.length === 0) { + return [{ type: 'text', text }]; + } + + // Build a list of mention ranges: { start, end, mention } + const ranges: Array<{ start: number; end: number; mention: PersonMention }> = []; + + for (const mention of sidecar) { + const needle = `@${mention.displayName}`; + let idx = 0; + while (idx < text.length) { + const pos = text.indexOf(needle, idx); + if (pos === -1) break; + // Check that the range doesn't overlap an already-found range + const end = pos + needle.length; + const overlaps = ranges.some((r) => r.start < end && r.end > pos); + if (!overlaps) { + ranges.push({ start: pos, end, mention }); + } + idx = pos + 1; + } + } + + if (ranges.length === 0) { + return [{ type: 'text', text }]; + } + + // Sort by position + ranges.sort((a, b) => a.start - b.start); + + const nodes: JSONContent[] = []; + let cursor = 0; + + for (const { start, end, mention } of ranges) { + if (start > cursor) { + nodes.push({ type: 'text', text: text.slice(cursor, start) }); + } + nodes.push({ + type: 'mention', + attrs: { displayName: mention.displayName, personId: mention.personId } + }); + cursor = end; + } + + if (cursor < text.length) { + nodes.push({ type: 'text', text: text.slice(cursor) }); + } + + return nodes; +} + +/** + * Converts a Tiptap ProseMirror document back to stored block text + sidecar. + * + * Paragraphs are joined with "\n". Mention nodes are emitted as "@displayName" + * and collected into mentionedPersons (de-duplicated by personId). + */ +export function serialize(doc: JSONContent): { text: string; mentionedPersons: PersonMention[] } { + const paragraphs = doc.content ?? []; + const mentionedPersons: PersonMention[] = []; + const seenIds = new Set(); + const lines: string[] = []; + + for (const para of paragraphs) { + let line = ''; + for (const node of para.content ?? []) { + if (node.type === 'text') { + line += node.text ?? ''; + } else if (node.type === 'mention') { + const { displayName, personId } = node.attrs ?? {}; + line += `@${displayName}`; + if (!seenIds.has(personId)) { + seenIds.add(personId); + mentionedPersons.push({ personId, displayName }); + } + } + } + lines.push(line); + } + + return { text: lines.join('\n'), mentionedPersons }; +}