feat(frontend): add mentionSerializer — pure serialize/deserialize for Tiptap ↔ block storage
Converts between the stored format (text + PersonMention sidecar) and Tiptap ProseMirror JSONContent. Round-trip invariant: serialize(deserialize(t,s)).text === t. Handles multi-paragraph text (split/join on \n), sidecar deduplication, and backward compat with old-format full-name sidecar entries. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
138
frontend/src/lib/utils/mentionSerializer.spec.ts
Normal file
138
frontend/src/lib/utils/mentionSerializer.spec.ts
Normal file
@@ -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: '<script>alert(1)</script>' }
|
||||||
|
];
|
||||||
|
const text = '@<script>alert(1)</script>';
|
||||||
|
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('<script>alert(1)</script>');
|
||||||
|
});
|
||||||
|
});
|
||||||
113
frontend/src/lib/utils/mentionSerializer.ts
Normal file
113
frontend/src/lib/utils/mentionSerializer.ts
Normal file
@@ -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<string>();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user