refactor: move shared utilities to lib/shared/ sub-packages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-05 14:35:15 +02:00
parent 7cb922e90f
commit d6db7a07bd
117 changed files with 97 additions and 97 deletions

View File

@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { extractQuote } from './comment';
describe('extractQuote', () => {
it('returns null quote and full body for plain text', () => {
const result = extractQuote('Hello world');
expect(result.quote).toBeNull();
expect(result.body).toBe('Hello world');
});
it('extracts quote and body with double newline separator', () => {
const result = extractQuote('> "Some quoted text"\n\nReply body');
expect(result.quote).toBe('Some quoted text');
expect(result.body).toBe('Reply body');
});
it('extracts quote and body with single newline separator', () => {
const result = extractQuote('> "Quote"\nBody');
expect(result.quote).toBe('Quote');
expect(result.body).toBe('Body');
});
it('returns null quote when format does not match', () => {
const result = extractQuote('> Not a quote format');
expect(result.quote).toBeNull();
expect(result.body).toBe('> Not a quote format');
});
it('handles empty string', () => {
const result = extractQuote('');
expect(result.quote).toBeNull();
expect(result.body).toBe('');
});
it('does not match when quotes are missing', () => {
const result = extractQuote('> just a blockquote\n\nbody');
expect(result.quote).toBeNull();
expect(result.body).toBe('> just a blockquote\n\nbody');
});
});

View File

@@ -0,0 +1,5 @@
export function extractQuote(content: string): { quote: string | null; body: string } {
const match = content.match(/^>\s*"(.+?)"\s*\n\n?([\s\S]*)$/);
if (match) return { quote: match[1], body: match[2] };
return { quote: null, body: content };
}

View File

@@ -0,0 +1,14 @@
import { describe, it, expect } from 'vitest';
import { buildCommentHref } from './commentDeepLink';
describe('buildCommentHref', () => {
it('includes both commentId and annotationId when annotationId is present', () => {
const href = buildCommentHref('doc-1', 'comment-2', 'annot-3');
expect(href).toBe('/documents/doc-1?commentId=comment-2&annotationId=annot-3');
});
it('omits annotationId when null', () => {
const href = buildCommentHref('doc-1', 'comment-2', null);
expect(href).toBe('/documents/doc-1?commentId=comment-2');
});
});

View File

@@ -0,0 +1,8 @@
export function buildCommentHref(
documentId: string,
commentId: string,
annotationId: string | null
): string {
const base = `/documents/${documentId}?commentId=${commentId}`;
return annotationId ? `${base}&annotationId=${annotationId}` : base;
}

View File

@@ -0,0 +1,350 @@
import { describe, it, expect } from 'vitest';
import {
detectMention,
escapeHtml,
extractContent,
renderBody,
renderTranscriptionBody
} from './mention';
import type { MentionDTO, PersonMention } from '$lib/types';
// ─── escapeHtml ───────────────────────────────────────────────────────────────
describe('escapeHtml', () => {
it('escapes ampersand', () => {
expect(escapeHtml('AT&T')).toBe('AT&amp;T');
});
it('escapes less-than and greater-than', () => {
expect(escapeHtml('<script>')).toBe('&lt;script&gt;');
});
it('escapes double quote', () => {
expect(escapeHtml('say "hi"')).toBe('say &quot;hi&quot;');
});
it('returns empty string unchanged', () => {
expect(escapeHtml('')).toBe('');
});
it('escapes ampersand before other entities to avoid double-encoding', () => {
expect(escapeHtml('a&<b')).toBe('a&amp;&lt;b');
});
it('escapes apostrophe to &#39;', () => {
expect(escapeHtml("d'Artagnan")).toBe('d&#39;Artagnan');
});
it('does not collapse already-encoded entities (re-escapes the &)', () => {
// escapeHtml is idempotent by composition: the second pass re-escapes
// the & that was added by the first. Pin the property so the helper
// can't be "cleverly" optimised to skip it.
expect(escapeHtml('&amp;')).toBe('&amp;amp;');
});
});
// ─── detectMention ────────────────────────────────────────────────────────────
describe('detectMention', () => {
it('returns null when text has no @', () => {
expect(detectMention('hello world', 11)).toBeNull();
});
it('returns null when @ is not the most recent trigger word', () => {
// cursor is past a completed mention (next word started)
expect(detectMention('hello @Hans Müller more', 22)).toBeNull();
});
it('returns empty string immediately after @', () => {
expect(detectMention('hello @', 7)).toBe('');
});
it('returns query text after @', () => {
expect(detectMention('hello @Han', 10)).toBe('Han');
});
it('returns null when @ is preceded by a letter (email address pattern)', () => {
expect(detectMention('user@example', 12)).toBeNull();
});
it('returns query for @ at the very start of string', () => {
expect(detectMention('@Hans', 5)).toBe('Hans');
});
it('returns null when cursor is before the @', () => {
expect(detectMention('@Hans', 0)).toBeNull();
});
});
// ─── extractContent ───────────────────────────────────────────────────────────
describe('extractContent', () => {
it('returns empty arrays for empty string', () => {
const result = extractContent('', []);
expect(result.content).toBe('');
expect(result.mentionedUserIds).toEqual([]);
});
it('returns plain content unchanged when no candidates', () => {
const result = extractContent('Hello world', []);
expect(result.content).toBe('Hello world');
expect(result.mentionedUserIds).toEqual([]);
});
it('extracts user id when @FirstName LastName is in content', () => {
const candidates: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
const result = extractContent('Hey @Hans Müller how are you?', candidates);
expect(result.mentionedUserIds).toContain('uuid-1');
});
it('deduplicates user ids when same user mentioned twice', () => {
const candidates: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
const result = extractContent('@Hans Müller and @Hans Müller again', candidates);
expect(result.mentionedUserIds).toHaveLength(1);
expect(result.mentionedUserIds).toContain('uuid-1');
});
it('collects multiple distinct users', () => {
const candidates: MentionDTO[] = [
{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' },
{ id: 'uuid-2', firstName: 'Anna', lastName: 'Schmidt' }
];
const result = extractContent('@Hans Müller and @Anna Schmidt', candidates);
expect(result.mentionedUserIds).toContain('uuid-1');
expect(result.mentionedUserIds).toContain('uuid-2');
});
});
// ─── renderBody ───────────────────────────────────────────────────────────────
describe('renderBody', () => {
it('returns escaped plain text when no mentions', () => {
expect(renderBody('Hello world', [])).toBe('Hello world');
});
it('escapes < and > in content', () => {
const result = renderBody('<script>alert(1)</script>', []);
expect(result).toContain('&lt;script&gt;');
expect(result).not.toContain('<script>');
});
it('escapes & in content', () => {
const result = renderBody('AT&T', []);
expect(result).toContain('AT&amp;T');
});
it('wraps @mention in a mention span', () => {
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
const result = renderBody('Hey @Hans Müller!', mentions);
expect(result).toContain('<span');
expect(result).toContain('class="mention"');
expect(result).toContain('Hans Müller');
});
it('does not double-encode already escaped text', () => {
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
const result = renderBody('Check @Hans Müller', mentions);
expect(result).not.toContain('&amp;');
});
it('replaces all occurrences of the same mention', () => {
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
const result = renderBody('@Hans Müller and @Hans Müller', mentions);
const spanCount = (result.match(/<span /g) ?? []).length;
expect(spanCount).toBe(2);
});
it('escapes HTML special chars in mention display names', () => {
const mentions: MentionDTO[] = [{ id: 'u1', firstName: '<script>', lastName: 'alert(1)' }];
const result = renderBody('@<script> alert(1)', mentions);
expect(result).not.toContain('<script>');
expect(result).toContain('&lt;script&gt;');
});
it('converts newlines to <br>', () => {
const result = renderBody('line1\nline2', []);
expect(result).toContain('<br>');
expect(result).not.toContain('\n');
});
});
// ─── renderTranscriptionBody ──────────────────────────────────────────────────
describe('renderTranscriptionBody', () => {
const auguste: PersonMention = {
personId: '550e8400-e29b-41d4-a716-446655440000',
displayName: 'Auguste Raddatz'
};
const hans: PersonMention = {
personId: '550e8400-e29b-41d4-a716-446655440001',
displayName: 'Hans'
};
it('returns empty string for empty input', () => {
expect(renderTranscriptionBody('', [])).toBe('');
});
it('returns escaped plain text when no mentions', () => {
expect(renderTranscriptionBody('Hello world', [])).toBe('Hello world');
});
it('escapes < and > in plain block text', () => {
const result = renderTranscriptionBody('<script>alert(1)</script>', []);
expect(result).toBe('&lt;script&gt;alert(1)&lt;/script&gt;');
expect(result).not.toContain('<script>');
});
it('escapes & in plain block text', () => {
expect(renderTranscriptionBody('AT&T', [])).toBe('AT&amp;T');
});
it('replaces @DisplayName with anchor link to /persons/{personId}', () => {
const result = renderTranscriptionBody('Brief an @Auguste Raddatz vom Mai', [auguste]);
expect(result).toContain(`<a href="/persons/${auguste.personId}"`);
expect(result).toContain('class="person-mention"');
expect(result).toContain(`data-person-id="${auguste.personId}"`);
expect(result).toContain('>Auguste Raddatz</a>');
});
it('strips the @ prefix from rendered link text (read mode)', () => {
const result = renderTranscriptionBody('Hallo @Auguste Raddatz!', [auguste]);
// The anchor body is the bare display name — no leading @
expect(result).not.toMatch(/>@Auguste Raddatz</);
expect(result).toMatch(/>Auguste Raddatz</);
});
it('removes the trigger @ from the surrounding text (no orphan @ before the link)', () => {
const result = renderTranscriptionBody('Brief an @Auguste Raddatz vom Mai', [auguste]);
// No bare @ remains where the mention was
expect(result).not.toMatch(/@<a/);
});
it('replaces all occurrences of the same mention', () => {
const result = renderTranscriptionBody('@Auguste Raddatz und @Auguste Raddatz', [auguste]);
const anchorCount = (result.match(/<a /g) ?? []).length;
expect(anchorCount).toBe(2);
});
it('does not replace plain-text occurrences without the @ trigger', () => {
const result = renderTranscriptionBody('Auguste Raddatz war hier', [auguste]);
expect(result).not.toContain('<a ');
expect(result).toBe('Auguste Raddatz war hier');
});
it('processes longer displayNames first to avoid prefix shadowing', () => {
const SHORT_ID = '11111111-1111-4111-8111-111111111111';
const LONG_ID = '22222222-2222-4222-8222-222222222222';
const augusteShort: PersonMention = { personId: SHORT_ID, displayName: 'Auguste' };
const augusteLong: PersonMention = {
personId: LONG_ID,
displayName: 'Auguste Raddatz'
};
// Sidecar order is short-first; longer match must still win for the long text
const result = renderTranscriptionBody('@Auguste Raddatz schreibt @Auguste', [
augusteShort,
augusteLong
]);
expect(result).toContain(`href="/persons/${LONG_ID}"`);
expect(result).toContain(`href="/persons/${SHORT_ID}"`);
// The "Raddatz" suffix must not leak inside the short-name anchor
expect(result).not.toMatch(/>Auguste<\/a> Raddatz/);
});
it('does not match @ followed by extra word characters (word boundary)', () => {
// Sidecar contains "Hans"; text contains "@HansMüller" — no link.
const result = renderTranscriptionBody('Brief an @HansMüller', [hans]);
expect(result).not.toContain('<a ');
expect(result).toContain('@HansM');
});
it('first-sidecar-wins when two entries share the same displayName', () => {
// Two persons named "Hans" — first sidecar entry wins for all occurrences.
const FIRST_ID = '33333333-3333-4333-8333-333333333333';
const SECOND_ID = '44444444-4444-4444-8444-444444444444';
const hansFirst: PersonMention = { personId: FIRST_ID, displayName: 'Hans' };
const hansSecond: PersonMention = { personId: SECOND_ID, displayName: 'Hans' };
const result = renderTranscriptionBody('@Hans und @Hans', [hansFirst, hansSecond]);
expect(result).toContain(`href="/persons/${FIRST_ID}"`);
expect(result).not.toContain(`href="/persons/${SECOND_ID}"`);
const anchorCount = (result.match(/<a /g) ?? []).length;
expect(anchorCount).toBe(2);
});
it('escapes HTML in displayName to prevent stored XSS', () => {
const xss: PersonMention = {
personId: '55555555-5555-4555-8555-555555555555',
displayName: '<script>alert(1)</script>'
};
const result = renderTranscriptionBody('Hi @<script>alert(1)</script> there', [xss]);
expect(result).not.toContain('<script>');
expect(result).toContain('&lt;script&gt;');
});
it('escapes <img onerror=...> payloads in surrounding block text', () => {
const result = renderTranscriptionBody('<img src=x onerror=alert(1)> hello', []);
expect(result).not.toContain('<img');
expect(result).toContain('&lt;img');
});
it('does not double-encode HTML-entity-already-encoded payloads', () => {
// `&amp;lt;script&amp;gt;` is already-escaped HTML in the source text.
// renderTranscriptionBody must escape the literal & once → `&amp;amp;lt;...`
// — never silently decode pre-escaped entities.
const result = renderTranscriptionBody('text &amp;lt;script&amp;gt;', []);
expect(result).toBe('text &amp;amp;lt;script&amp;amp;gt;');
});
it('escapes quotes in displayName so they cannot break the href attribute', () => {
const tricky: PersonMention = {
personId: '66666666-6666-4666-8666-666666666666',
displayName: 'O"Brien'
};
const result = renderTranscriptionBody('@O"Brien', [tricky]);
// The raw `"` from the displayName must never appear inside the rendered link
// — it would terminate the attribute value early and let an attacker craft
// arbitrary attributes on the anchor. It must arrive at the browser as &quot;.
expect(result).toMatch(/>O&quot;Brien<\/a>/);
expect(result).not.toMatch(/>O"Brien<\/a>/);
});
it('renders nothing when mentionedPersons is undefined-empty and no @ triggers', () => {
const result = renderTranscriptionBody('Plain old transcription text.', []);
expect(result).toBe('Plain old transcription text.');
});
it('skips substitution when personId is not a UUID (defense in depth)', () => {
// Nora #5551: if personId ever flowed in from a less-sanitised source
// (a future "external person" or a bad sidecar), the renderer must not
// emit a clickable link. The escaped text remains as plain content.
const evil: PersonMention = {
personId: 'javascript:alert(1)',
displayName: 'Evil Link'
};
const result = renderTranscriptionBody('Hi @Evil Link!', [evil]);
expect(result).not.toContain('<a ');
expect(result).not.toContain('javascript:');
// The @-trigger and displayName are preserved as plain text
expect(result).toContain('@Evil Link');
});
it('skips substitution when personId is an absolute URL', () => {
const evil: PersonMention = {
personId: 'https://evil.example/persons/abc',
displayName: 'Phisher'
};
const result = renderTranscriptionBody('Hi @Phisher', [evil]);
expect(result).not.toContain('<a ');
expect(result).not.toContain('https://evil.example');
});
it('still substitutes when personId is a well-formed UUID', () => {
// Sanity check that the validation does not over-reject valid IDs.
const valid: PersonMention = {
personId: '550e8400-e29b-41d4-a716-446655440000',
displayName: 'Auguste Raddatz'
};
const result = renderTranscriptionBody('Brief an @Auguste Raddatz', [valid]);
expect(result).toContain('<a ');
expect(result).toContain('href="/persons/550e8400-e29b-41d4-a716-446655440000"');
});
});

View File

@@ -0,0 +1,163 @@
import type { MentionDTO, PersonMention } from '$lib/types';
/**
* Single-source CSS selector for rendered person-mention anchors. Used by:
* - layout.css (.person-mention rule, focus ring, underline)
* - TranscriptionReadView (delegated mouseenter/leave/click handlers)
* - unit + e2e tests
*
* Keep these in sync — the renderer template below emits exactly this class.
*/
export const PERSON_MENTION_SELECTOR = 'a.person-mention';
/**
* Branded string type for HTML that has been pre-escaped and assembled by
* one of the trusted renderers in this module. The brand exists so that
* `{@html …}` consumers can require a SafeHtml input at compile time —
* `{@html block.text}` won't typecheck unless the string came through
* a renderer that escapes its inputs.
*
* Defense in depth against stored XSS (Sina #5505 / Nora PR-B2 review).
*/
export type SafeHtml = string & { readonly __brand: 'SafeHtml' };
/**
* Given the current textarea value and cursor position, returns the
* @-mention query being typed (the text after the last triggering @),
* or null if no mention is active.
*
* Rules:
* - @ must be preceded by whitespace or be at the start of the string
* - The text between @ and the cursor must not contain a space (a
* completed mention word already has a space)
*/
export function detectMention(text: string, cursorPos: number): string | null {
const before = text.slice(0, cursorPos);
const atIndex = before.lastIndexOf('@');
if (atIndex === -1) return null;
// @ must be at start or preceded by whitespace
if (atIndex > 0 && !/\s/.test(before[atIndex - 1])) return null;
const query = before.slice(atIndex + 1);
// If the query contains a space the user has moved past the trigger word
if (query.includes(' ')) return null;
return query;
}
/**
* Given the raw textarea value and a list of candidate users (from the
* mention popup selections), returns the plain content string and the
* de-duplicated list of mentioned user IDs.
*/
export function extractContent(
text: string,
candidates: MentionDTO[]
): { content: string; mentionedUserIds: string[] } {
const seen = new Set<string>();
for (const user of candidates) {
const displayName = `${user.firstName} ${user.lastName}`.trim();
if (text.includes(`@${displayName}`)) {
seen.add(user.id);
}
}
return { content: text, mentionedUserIds: [...seen] };
}
/**
* Escapes the five HTML-special characters that can break out of text content
* or attribute values. & must be escaped first to avoid double-encoding.
*
* Includes the apostrophe so the helper is safe in single-quoted attribute
* values too — the renderTranscriptionBody anchor template in PR-B2 uses
* double quotes today, but a future template change shouldn't open a
* stored-XSS hole (Sina #5505 action item).
*/
export function escapeHtml(str: string): string {
return str
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Strict UUID v1v5 check. Used as a defensive boundary on PersonMention.personId
* before substituting it into an `href` — even though the backend currently only
* emits UUIDs, a future "external person" feature must not accidentally turn this
* helper into an open-redirect surface (CWE-601).
*/
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function isUuid(value: string): boolean {
return UUID_RE.test(value);
}
/**
* Renders a transcription block's text segment as safe HTML for read mode.
*
* Rules:
* 1. The full text is HTML-escaped first (defense against stored XSS).
* 2. For each entry in `mentionedPersons`, every `@DisplayName` occurrence is
* replaced with `<a href="/persons/{personId}" class="person-mention" …>DisplayName</a>`.
* The `@` prefix is stripped from the rendered link text — it is an editor
* affordance, not part of the historical text (issue #362).
* 3. Longest displayNames are processed first so a short prefix in the sidecar
* cannot shadow a longer match in the text (e.g. `@Auguste` vs `@Auguste Raddatz`).
* 4. Word-boundary lookahead prevents `@Hans` from matching `@HansMüller`.
* 5. First-sidecar-wins for entries that share a displayName (deterministic
* rule per Felix decision OQ-1, comment #5339).
*/
export function renderTranscriptionBody(text: string, mentionedPersons: PersonMention[]): SafeHtml {
if (!text) return '' as SafeHtml;
let escaped = escapeHtml(text);
const seen = new Set<string>();
const unique: PersonMention[] = [];
for (const mention of mentionedPersons) {
if (seen.has(mention.displayName)) continue;
// Defense in depth: refuse to render an anchor for a non-UUID personId.
// The escaped block text falls through unchanged, so the @-trigger is
// preserved as plain content — no silent data loss, no clickable link.
if (!isUuid(mention.personId)) continue;
seen.add(mention.displayName);
unique.push(mention);
}
const sorted = [...unique].sort((a, b) => b.displayName.length - a.displayName.length);
for (const mention of sorted) {
const escapedDisplayName = escapeHtml(mention.displayName);
const escapedPersonId = escapeHtml(mention.personId);
const pattern = new RegExp(`@${escapeRegExp(escapedDisplayName)}(?![\\p{L}\\p{N}])`, 'gu');
const link = `<a href="/persons/${escapedPersonId}" class="person-mention" data-person-id="${escapedPersonId}">${escapedDisplayName}</a>`;
escaped = escaped.replace(pattern, link);
}
return escaped as SafeHtml;
}
/**
* Renders a comment body as safe HTML:
* 1. Escapes all HTML-special characters in the raw content
* 2. Replaces every @FirstName LastName occurrence with an anchor link
* 3. Converts newlines to <br>
*/
export function renderBody(content: string, mentions: MentionDTO[]): SafeHtml {
let escaped = escapeHtml(content);
for (const mention of mentions) {
const displayName = `${mention.firstName} ${mention.lastName}`.trim();
const escapedDisplayName = escapeHtml(displayName);
const span = `<span class="mention" data-user-id="${mention.id}">@${escapedDisplayName}</span>`;
escaped = escaped.replaceAll(`@${escapedDisplayName}`, span);
}
return escaped.replaceAll('\n', '<br>') as SafeHtml;
}

View 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>');
});
});

View 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 };
}