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,76 @@
import { describe, it, expect, afterEach } from 'vitest';
const { clickOutside } = await import('./clickOutside');
describe('clickOutside action', () => {
const nodes: HTMLElement[] = [];
function makeNode(): HTMLElement {
const node = document.createElement('div');
document.body.appendChild(node);
nodes.push(node);
return node;
}
afterEach(() => {
nodes.forEach((n) => n.remove());
nodes.length = 0;
});
it('registers a capture-phase click listener on mount', () => {
const node = makeNode();
const original = document.addEventListener.bind(document);
let registered = false;
document.addEventListener = (type: string, _fn: unknown, opts: unknown) => {
if (type === 'click' && opts === true) registered = true;
original(type as string, _fn as EventListener, opts as boolean);
};
clickOutside(node);
expect(registered).toBe(true);
document.addEventListener = original;
});
it('dispatches clickoutside when clicking outside the node', () => {
const node = makeNode();
const outside = makeNode();
let fired = false;
node.addEventListener('clickoutside', () => (fired = true));
clickOutside(node);
outside.click();
expect(fired).toBe(true);
});
it('does not dispatch clickoutside when clicking inside the node', () => {
const node = makeNode();
const child = document.createElement('span');
node.appendChild(child);
let fired = false;
node.addEventListener('clickoutside', () => (fired = true));
clickOutside(node);
child.click();
expect(fired).toBe(false);
});
it('does not dispatch clickoutside when event.defaultPrevented is true', () => {
const node = makeNode();
const outside = makeNode();
let fired = false;
node.addEventListener('clickoutside', () => (fired = true));
clickOutside(node);
const event = new MouseEvent('click', { bubbles: true, cancelable: true });
event.preventDefault();
outside.dispatchEvent(event);
expect(fired).toBe(false);
});
it('removes the listener on destroy', () => {
const node = makeNode();
const outside = makeNode();
let count = 0;
node.addEventListener('clickoutside', () => count++);
const { destroy } = clickOutside(node);
destroy();
outside.click();
expect(count).toBe(0);
});
});

View File

@@ -0,0 +1,16 @@
export function clickOutside(node: HTMLElement): { destroy: () => void } {
function handleClick(event: MouseEvent) {
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
node.dispatchEvent(new CustomEvent('clickoutside'));
}
}
// Capture phase (true) ensures this fires before any child stopPropagation() calls.
document.addEventListener('click', handleClick, true);
return {
destroy() {
document.removeEventListener('click', handleClick, true);
}
};
}

View File

@@ -0,0 +1,87 @@
import { describe, it, expect, afterEach } from 'vitest';
const { radioGroupNav } = await import('./radioGroupNav');
describe('radioGroupNav action', () => {
const nodes: HTMLElement[] = [];
function makeGroup(count: number): { container: HTMLElement; buttons: HTMLElement[] } {
const container = document.createElement('div');
container.setAttribute('role', 'radiogroup');
const buttons: HTMLElement[] = [];
for (let i = 0; i < count; i++) {
const btn = document.createElement('button');
btn.setAttribute('role', 'radio');
btn.setAttribute('aria-checked', i === 0 ? 'true' : 'false');
btn.setAttribute('tabindex', i === 0 ? '0' : '-1');
container.appendChild(btn);
buttons.push(btn);
}
document.body.appendChild(container);
nodes.push(container);
return { container, buttons };
}
afterEach(() => {
nodes.forEach((n) => n.remove());
nodes.length = 0;
});
it('ArrowRight moves focus to next button', () => {
const { container, buttons } = makeGroup(4);
radioGroupNav(container);
buttons[0].focus();
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
expect(document.activeElement).toBe(buttons[1]);
});
it('ArrowRight wraps from last to first', () => {
const { container, buttons } = makeGroup(4);
radioGroupNav(container);
buttons[3].focus();
buttons[3].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
expect(document.activeElement).toBe(buttons[0]);
});
it('ArrowLeft moves focus to previous button', () => {
const { container, buttons } = makeGroup(4);
radioGroupNav(container);
buttons[2].focus();
buttons[2].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
expect(document.activeElement).toBe(buttons[1]);
});
it('ArrowLeft wraps from first to last', () => {
const { container, buttons } = makeGroup(4);
radioGroupNav(container);
buttons[0].focus();
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
expect(document.activeElement).toBe(buttons[3]);
});
it('ArrowRight updates aria-checked on new button and removes it from old', () => {
const { container, buttons } = makeGroup(4);
radioGroupNav(container);
buttons[0].focus();
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
expect(buttons[1].getAttribute('aria-checked')).toBe('true');
expect(buttons[0].getAttribute('aria-checked')).toBe('false');
});
it('destroy removes keydown listener', () => {
const { container, buttons } = makeGroup(4);
const { destroy } = radioGroupNav(container);
destroy();
buttons[0].focus();
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
expect(document.activeElement).toBe(buttons[0]);
});
it('ignores non-arrow keys', () => {
const { container, buttons } = makeGroup(4);
radioGroupNav(container);
buttons[0].focus();
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(document.activeElement).toBe(buttons[0]);
});
});

View File

@@ -0,0 +1,37 @@
export function radioGroupNav(
node: HTMLElement,
onChange?: (value: string) => void
): { destroy: () => void; update: (onChange?: (value: string) => void) => void } {
let onChangeFn = onChange;
function getRadios(): HTMLElement[] {
return Array.from(node.querySelectorAll<HTMLElement>('[role="radio"]'));
}
function handleKeydown(event: KeyboardEvent) {
if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return;
const radios = getRadios();
const current = radios.indexOf(document.activeElement as HTMLElement);
if (current === -1) return;
const delta = event.key === 'ArrowRight' ? 1 : -1;
const next = (current + delta + radios.length) % radios.length;
radios[current].setAttribute('aria-checked', 'false');
radios[next].setAttribute('aria-checked', 'true');
radios[next].focus();
onChangeFn?.(radios[next].getAttribute('value') ?? '');
}
node.addEventListener('keydown', handleKeydown);
return {
update(newOnChange) {
onChangeFn = newOnChange;
},
destroy() {
node.removeEventListener('keydown', handleKeydown);
}
};
}

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

View File

@@ -0,0 +1,87 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const { createTypeahead } = await import('../useTypeahead.svelte');
describe('createTypeahead', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('starts with empty query and closed dropdown', () => {
const ta = createTypeahead({ fetchUrl: vi.fn().mockResolvedValue([]) });
expect(ta.query).toBe('');
expect(ta.isOpen).toBe(false);
expect(ta.results).toEqual([]);
expect(ta.loading).toBe(false);
});
it('setQuery updates query and opens dropdown', async () => {
const fetchUrl = vi.fn().mockResolvedValue([{ id: '1', name: 'Foo' }]);
const ta = createTypeahead({ fetchUrl });
ta.setQuery('foo');
expect(ta.query).toBe('foo');
expect(ta.isOpen).toBe(true);
});
it('setQuery triggers debounced fetch and populates results', async () => {
const fetchUrl = vi.fn().mockResolvedValue([{ id: '1', name: 'Foo' }]);
const ta = createTypeahead({ fetchUrl, debounceMs: 300 });
ta.setQuery('foo');
expect(fetchUrl).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(300);
expect(fetchUrl).toHaveBeenCalledWith('foo');
expect(ta.results).toEqual([{ id: '1', name: 'Foo' }]);
});
it('close() resets isOpen', () => {
const ta = createTypeahead({ fetchUrl: vi.fn().mockResolvedValue([]) });
ta.setQuery('foo');
expect(ta.isOpen).toBe(true);
ta.close();
expect(ta.isOpen).toBe(false);
});
it('select(item) calls onSelect and closes dropdown', () => {
const onSelect = vi.fn();
const ta = createTypeahead({
fetchUrl: vi.fn().mockResolvedValue([]),
onSelect
});
ta.setQuery('foo');
ta.select({ id: '1', name: 'Foo' });
expect(onSelect).toHaveBeenCalledWith({ id: '1', name: 'Foo' });
expect(ta.isOpen).toBe(false);
});
it('debounce coalesces rapid setQuery calls', async () => {
const fetchUrl = vi.fn().mockResolvedValue([]);
const ta = createTypeahead({ fetchUrl, debounceMs: 300 });
ta.setQuery('f');
ta.setQuery('fo');
ta.setQuery('foo');
await vi.advanceTimersByTimeAsync(300);
expect(fetchUrl).toHaveBeenCalledTimes(1);
expect(fetchUrl).toHaveBeenCalledWith('foo');
});
it('fetch error logs to console.error and sets results to empty', async () => {
const fetchUrl = vi.fn().mockRejectedValue(new Error('network error'));
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const ta = createTypeahead({ fetchUrl, debounceMs: 0 });
ta.setQuery('foo');
await vi.advanceTimersByTimeAsync(0);
expect(errorSpy).toHaveBeenCalled();
expect(ta.results).toEqual([]);
errorSpy.mockRestore();
});
it('setActiveIndex updates activeIndex', () => {
const ta = createTypeahead({ fetchUrl: vi.fn().mockResolvedValue([]) });
expect(ta.activeIndex).toBe(-1);
ta.setActiveIndex(2);
expect(ta.activeIndex).toBe(2);
});
});

View File

@@ -0,0 +1,77 @@
type Options<T> = {
fetchUrl: (query: string) => Promise<T[]>;
onSelect?: (item: T) => void;
debounceMs?: number;
};
export function createTypeahead<T>(options: Options<T>) {
const { fetchUrl, onSelect, debounceMs = 300 } = options;
let query = $state('');
let results: T[] = $state([]);
let isOpen = $state(false);
let loading = $state(false);
let activeIndex = $state(-1);
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
function setQuery(q: string) {
query = q;
isOpen = true;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
loading = true;
try {
results = await fetchUrl(q);
} catch (e) {
console.error('typeahead fetch error', e);
results = [];
} finally {
loading = false;
}
}, debounceMs);
}
function close() {
isOpen = false;
activeIndex = -1;
}
function setActiveIndex(idx: number) {
activeIndex = idx;
}
function select(item: T) {
onSelect?.(item);
close();
}
/** Directly populate results without going through the debounce (e.g. on-focus preload). */
function openWith(items: T[]) {
results = items;
isOpen = true;
}
return {
get query() {
return query;
},
get results() {
return results;
},
get isOpen() {
return isOpen;
},
get loading() {
return loading;
},
get activeIndex() {
return activeIndex;
},
setQuery,
setActiveIndex,
close,
select,
openWith
};
}

View File

@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Capture the beforeNavigate callback so tests can simulate navigation events
let registeredBeforeNavigate:
| ((nav: { cancel: () => void; to: { url: { href: string } } | null }) => void)
| null = null;
const mockGoto = vi.fn();
vi.mock('$app/navigation', () => ({
beforeNavigate: vi.fn((fn: typeof registeredBeforeNavigate) => {
registeredBeforeNavigate = fn;
}),
goto: mockGoto
}));
const { createUnsavedWarning } = await import('../useUnsavedWarning.svelte');
function simulateNavigate(href: string | null = '/somewhere') {
const cancel = vi.fn();
registeredBeforeNavigate?.({
cancel,
to: href ? { url: { href } } : null
});
return cancel;
}
beforeEach(() => {
registeredBeforeNavigate = null;
mockGoto.mockClear();
});
describe('createUnsavedWarning', () => {
it('isDirty starts false', () => {
const w = createUnsavedWarning();
expect(w.isDirty).toBe(false);
});
it('markDirty sets isDirty to true', () => {
const w = createUnsavedWarning();
w.markDirty();
expect(w.isDirty).toBe(true);
});
it('markDirty hides any existing warning banner', () => {
const w = createUnsavedWarning();
// Simulate a navigation event that showed the banner
w.markDirty();
simulateNavigate();
expect(w.showUnsavedWarning).toBe(true);
// Typing again should hide the banner (form input re-triggers markDirty)
w.markDirty();
expect(w.showUnsavedWarning).toBe(false);
});
it('beforeNavigate cancels and shows banner when dirty', () => {
const w = createUnsavedWarning();
w.markDirty();
const cancel = simulateNavigate('/admin/users');
expect(cancel).toHaveBeenCalled();
expect(w.showUnsavedWarning).toBe(true);
});
it('beforeNavigate stores the target URL', () => {
const w = createUnsavedWarning();
w.markDirty();
simulateNavigate('/admin/users');
expect(w.discardTarget).toBe('/admin/users');
});
it('beforeNavigate does not cancel when not dirty', () => {
createUnsavedWarning();
const cancel = simulateNavigate('/admin/users');
expect(cancel).not.toHaveBeenCalled();
});
it('discard resets state and navigates to target', () => {
const w = createUnsavedWarning();
w.markDirty();
simulateNavigate('/admin/tags');
w.discard();
expect(w.isDirty).toBe(false);
expect(w.showUnsavedWarning).toBe(false);
expect(mockGoto).toHaveBeenCalledWith('/admin/tags');
});
it('clearOnSuccess resets isDirty and warning', () => {
const w = createUnsavedWarning();
w.markDirty();
simulateNavigate('/somewhere');
w.clearOnSuccess();
expect(w.isDirty).toBe(false);
expect(w.showUnsavedWarning).toBe(false);
});
});

View File

@@ -0,0 +1,46 @@
import { beforeNavigate, goto } from '$app/navigation';
export function createUnsavedWarning() {
let isDirty = $state(false);
let showUnsavedWarning = $state(false);
let discardTarget: string | null = $state(null);
beforeNavigate(({ cancel, to }) => {
if (isDirty) {
cancel();
showUnsavedWarning = true;
discardTarget = to?.url.href ?? null;
}
});
function markDirty() {
isDirty = true;
showUnsavedWarning = false;
}
function discard() {
isDirty = false;
showUnsavedWarning = false;
if (discardTarget) goto(discardTarget);
}
function clearOnSuccess() {
isDirty = false;
showUnsavedWarning = false;
}
return {
get isDirty() {
return isDirty;
},
get showUnsavedWarning() {
return showUnsavedWarning;
},
get discardTarget() {
return discardTarget;
},
markDirty,
discard,
clearOnSuccess
};
}

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { detectLocale } from './locale';
describe('detectLocale', () => {
it('returns de for a German browser', () => {
expect(detectLocale('de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7')).toBe('de');
});
it('returns en for an English browser', () => {
expect(detectLocale('en-US,en;q=0.9')).toBe('en');
});
it('returns es for a Spanish browser', () => {
expect(detectLocale('es-MX,es;q=0.9,en-US;q=0.8')).toBe('es');
});
it('falls back to a supported language when the primary is unsupported', () => {
expect(detectLocale('fr-FR,fr;q=0.9,en;q=0.8')).toBe('en');
});
it('respects quality values — picks the highest-priority supported locale', () => {
expect(detectLocale('en-US;q=0.7,de-DE;q=0.9')).toBe('de');
});
it('returns null for a completely unsupported language', () => {
expect(detectLocale('ja-JP,ja;q=0.9,zh-CN;q=0.8')).toBeNull();
});
it('returns null for an empty header', () => {
expect(detectLocale('')).toBeNull();
});
});

View File

@@ -0,0 +1,20 @@
import { locales } from '$lib/paraglide/runtime';
/**
* Picks the best supported locale from an Accept-Language header value.
* Returns null when no supported locale is found.
*/
export function detectLocale(acceptLanguage: string): string | null {
const preferred = acceptLanguage
.split(',')
.map((part) => {
const [lang, q] = part.trim().split(';q=');
return { lang: lang.trim().split('-')[0].toLowerCase(), q: q ? parseFloat(q) : 1 };
})
.sort((a, b) => b.q - a.q);
for (const { lang } of preferred) {
if ((locales as readonly string[]).includes(lang)) return lang;
}
return null;
}

View File

@@ -0,0 +1,70 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import TestHost from './confirm.test-host.svelte';
import type { ConfirmService } from './confirm.svelte.js';
afterEach(cleanup);
function makeHost(): { service: ConfirmService } {
const result: { service: ConfirmService | null } = { service: null };
render(TestHost, {
onReady: (s: ConfirmService) => {
result.service = s;
}
});
return result as { service: ConfirmService };
}
describe('ConfirmService', () => {
it('resolves true when the user clicks Confirm', async () => {
const { service } = makeHost();
const resultPromise = service.confirm({ title: 'Test?' });
await expect.element(page.getByRole('dialog')).toBeInTheDocument();
await page.getByRole('button', { name: 'Bestätigen' }).click();
expect(await resultPromise).toBe(true);
});
it('resolves false when the user clicks Cancel', async () => {
const { service } = makeHost();
const resultPromise = service.confirm({ title: 'Test?' });
await expect.element(page.getByRole('dialog')).toBeInTheDocument();
await page.getByRole('button', { name: 'Abbrechen' }).click();
expect(await resultPromise).toBe(false);
});
it('resolves false when Escape is pressed', async () => {
const { service } = makeHost();
const resultPromise = service.confirm({ title: 'Test?' });
await expect.element(page.getByRole('dialog')).toBeInTheDocument();
await userEvent.keyboard('{Escape}');
expect(await resultPromise).toBe(false);
});
it('resolves false immediately on a concurrent call while dialog is open', async () => {
const { service } = makeHost();
const first = service.confirm({ title: 'First?' });
await expect.element(page.getByRole('dialog')).toBeInTheDocument();
const second = service.confirm({ title: 'Second?' });
expect(await second).toBe(false);
// clean up the first dialog
await page.getByRole('button', { name: 'Abbrechen' }).click();
expect(await first).toBe(false);
});
it('throws a descriptive error when called outside provider tree', async () => {
const { getConfirmService } = await import('./confirm.svelte.js');
// Outside component init, getContext throws or returns undefined — our guard
// converts either case to a descriptive developer error
expect(() => getConfirmService()).toThrow('mount <ConfirmDialog>');
});
});

View File

@@ -0,0 +1,100 @@
/**
* Context-based confirmation service. Provides an async `confirm()` function
* that any component can call without managing its own modal state.
*
* ## Setup
* Mount `<ConfirmDialog>` once in the root `+layout.svelte` — it sets up the context
* automatically. Then call `getConfirmService()` from any descendant component.
*
* ## Usage in event handlers
* ```typescript
* import { getConfirmService } from '$lib/shared/services/confirm.svelte.js';
* const { confirm } = getConfirmService();
*
* async function handleDelete() {
* const ok = await confirm({ title: m.confirm_delete_title(), destructive: true });
* if (ok) doDelete();
* }
* ```
*
* ## Usage with use:enhance
* ```svelte
* <form use:enhance={async ({ cancel }) => {
* const ok = await confirm({ title: m.confirm_delete_title(), destructive: true });
* if (!ok) cancel();
* }}>
* ```
*/
import { getContext, setContext } from 'svelte';
import { browser } from '$app/environment';
export const CONFIRM_KEY = Symbol('confirm');
export interface ConfirmOptions {
title: string;
body?: string;
/** Defaults to m.btn_confirm() ("Bestätigen") */
confirmLabel?: string;
/** Defaults to m.btn_cancel() ("Abbrechen") */
cancelLabel?: string;
/** Uses danger color for confirm button. Defaults to false. */
destructive?: boolean;
/** Close when clicking outside the dialog. Defaults to !destructive. */
closeOnBackdrop?: boolean;
}
export interface ConfirmService {
confirm(opts: ConfirmOptions): Promise<boolean>;
/** Read by ConfirmDialog to render the current dialog. Internal use only. */
readonly options: ConfirmOptions | null;
/** Called by ConfirmDialog when the user makes a choice. Internal use only. */
settle(value: boolean): void;
}
export function createConfirmService(): ConfirmService {
let resolveRef: ((value: boolean) => void) | null = null;
let options: ConfirmOptions | null = $state(null);
return {
confirm(opts: ConfirmOptions): Promise<boolean> {
if (!browser) return Promise.resolve(false);
// Concurrent call while dialog is already open — reject immediately.
if (resolveRef !== null) return Promise.resolve(false);
options = opts;
return new Promise((r) => {
resolveRef = r;
});
},
get options() {
return options;
},
settle(value: boolean): void {
options = null;
const r = resolveRef;
resolveRef = null;
r?.(value);
}
};
}
export function provideConfirmService(): ConfirmService {
const service = createConfirmService();
setContext(CONFIRM_KEY, service);
return service;
}
export function getConfirmService(): ConfirmService {
// Outside component init, getContext either returns undefined or throws a Svelte error.
// Either way, map it to our descriptive developer error.
let service: ConfirmService | undefined;
try {
service = getContext<ConfirmService>(CONFIRM_KEY);
} catch {
throw new Error('ConfirmService not found — mount <ConfirmDialog> in +layout.svelte');
}
if (!service)
throw new Error('ConfirmService not found — mount <ConfirmDialog> in +layout.svelte');
return service;
}

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import { provideConfirmService, type ConfirmService } from './confirm.svelte.js';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
let { onReady }: { onReady: (service: ConfirmService) => void } = $props();
const service = provideConfirmService();
onReady(service);
</script>
<ConfirmDialog />

View File

@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { bucketByDay } from './date-buckets';
function date(iso: string): Date {
return new Date(iso);
}
describe('bucketByDay', () => {
// Wednesday 2026-04-22 at 12:00 Berlin. Week start (Mon) = 2026-04-20.
const now = date('2026-04-22T12:00:00+02:00');
it('returns "today" for a time earlier today', () => {
expect(bucketByDay(date('2026-04-22T06:00:00+02:00'), now, 'de-DE')).toBe('today');
});
it('returns "today" at exact midnight start of today', () => {
expect(bucketByDay(date('2026-04-22T00:00:00+02:00'), now, 'de-DE')).toBe('today');
});
it('returns "yesterday" for any time on the previous day', () => {
expect(bucketByDay(date('2026-04-21T23:59:59+02:00'), now, 'de-DE')).toBe('yesterday');
expect(bucketByDay(date('2026-04-21T00:00:00+02:00'), now, 'de-DE')).toBe('yesterday');
});
it('returns "thisWeek" for the Monday that starts this week (Monday-anchored, de-DE)', () => {
expect(bucketByDay(date('2026-04-20T10:00:00+02:00'), now, 'de-DE')).toBe('thisWeek');
});
it('returns "older" for anything before the start of this week (de-DE)', () => {
expect(bucketByDay(date('2026-04-19T23:00:00+02:00'), now, 'de-DE')).toBe('older');
expect(bucketByDay(date('2026-04-13T10:00:00+02:00'), now, 'de-DE')).toBe('older');
});
it('uses Sunday-start week for en-US', () => {
const sundayRef = date('2026-04-19T12:00:00+02:00');
expect(bucketByDay(date('2026-04-19T06:00:00+02:00'), sundayRef, 'en-US')).toBe('today');
expect(
bucketByDay(date('2026-04-13T10:00:00+02:00'), date('2026-04-18T12:00:00+02:00'), 'en-US')
).toBe('thisWeek');
expect(
bucketByDay(date('2026-04-11T10:00:00+02:00'), date('2026-04-18T12:00:00+02:00'), 'en-US')
).toBe('older');
});
it('handles DST spring-forward correctly (Europe/Berlin 2026-03-29)', () => {
const justAfterDst = date('2026-03-29T03:15:00+02:00');
const sameDay = date('2026-03-29T10:00:00+02:00');
expect(bucketByDay(justAfterDst, sameDay, 'de-DE')).toBe('today');
});
});

View File

@@ -0,0 +1,35 @@
export type DayBucket = 'today' | 'yesterday' | 'thisWeek' | 'older';
const DAY_MS = 24 * 60 * 60 * 1000;
const SUNDAY_START_LOCALES = new Set(['en-us', 'en-ca', 'en-ph', 'ja-jp', 'he-il', 'pt-br']);
function weekStartDay(locale?: string): 0 | 1 {
if (!locale) return 1;
return SUNDAY_START_LOCALES.has(locale.toLowerCase()) ? 0 : 1;
}
function startOfDay(d: Date): Date {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}
function startOfWeek(d: Date, firstDay: 0 | 1): Date {
const x = startOfDay(d);
const diff = (x.getDay() - firstDay + 7) % 7;
x.setDate(x.getDate() - diff);
return x;
}
export function bucketByDay(date: Date, now: Date = new Date(), locale?: string): DayBucket {
const today = startOfDay(now);
const target = startOfDay(date);
if (target.getTime() === today.getTime()) return 'today';
if (today.getTime() - target.getTime() <= DAY_MS) return 'yesterday';
const weekStart = startOfWeek(today, weekStartDay(locale));
if (target.getTime() >= weekStart.getTime()) return 'thisWeek';
return 'older';
}

View File

@@ -0,0 +1,129 @@
import { describe, expect, it } from 'vitest';
import { formatDate, formatGermanDateInput, isoToGerman, germanToIso } from './date';
// ─── formatDate ──────────────────────────────────────────────────────────────
describe('formatDate', () => {
it('defaults to long format when no format arg is passed', () => {
expect(formatDate('1943-12-24')).toBe('24. Dezember 1943');
});
it('formats long date with German month name', () => {
expect(formatDate('1943-12-24', 'long')).toBe('24. Dezember 1943');
});
it('formats short date as dd.mm.yyyy', () => {
expect(formatDate('1943-12-24', 'short')).toBe('24.12.1943');
});
it('does not shift Dec 31 to Jan 1 (T12:00:00 UTC guard)', () => {
expect(formatDate('1943-12-31', 'short')).toBe('31.12.1943');
});
});
// ─── isoToGerman ─────────────────────────────────────────────────────────────
describe('isoToGerman', () => {
it('converts a valid ISO date to DD.MM.YYYY', () => {
expect(isoToGerman('2024-12-20')).toBe('20.12.2024');
});
it('returns empty string for empty input', () => {
expect(isoToGerman('')).toBe('');
});
it('returns empty string for invalid format', () => {
expect(isoToGerman('not-a-date')).toBe('');
});
});
// ─── germanToIso ─────────────────────────────────────────────────────────────
describe('germanToIso', () => {
it('converts DD.MM.YYYY to ISO', () => {
expect(germanToIso('20.12.2024')).toBe('2024-12-20');
});
it('returns empty string for partial input', () => {
expect(germanToIso('20.12')).toBe('');
});
it('returns empty string for empty input', () => {
expect(germanToIso('')).toBe('');
});
});
// ─── formatGermanDateInput ────────────────────────────────────────────────────
describe('formatGermanDateInput digit stream (no dots typed)', () => {
it('leaves 12 digits as-is', () => {
expect(formatGermanDateInput('2')).toBe('2');
expect(formatGermanDateInput('20')).toBe('20');
});
it('auto-inserts dot after 2 digits for 34 digit input', () => {
expect(formatGermanDateInput('201')).toBe('20.1');
expect(formatGermanDateInput('2012')).toBe('20.12');
});
it('auto-inserts two dots for 58 digit input', () => {
expect(formatGermanDateInput('20121')).toBe('20.12.1');
expect(formatGermanDateInput('20122024')).toBe('20.12.2024');
});
it('ignores digits beyond 8', () => {
expect(formatGermanDateInput('201220249')).toBe('20.12.2024');
});
});
describe('formatGermanDateInput manual dot entry with padding', () => {
it('pads single-digit day to 2 digits when dot is typed after it', () => {
expect(formatGermanDateInput('3.')).toBe('03.');
});
it('does not pad a 2-digit day', () => {
expect(formatGermanDateInput('03.')).toBe('03.');
expect(formatGermanDateInput('20.')).toBe('20.');
});
it('pads single-digit month to 2 digits when dot is typed after it', () => {
expect(formatGermanDateInput('03.3.')).toBe('03.03.');
});
it('does not pad a 2-digit month', () => {
expect(formatGermanDateInput('03.12.')).toBe('03.12.');
});
it('pads both day and month in a fully typed date', () => {
expect(formatGermanDateInput('3.3.2012')).toBe('03.03.2012');
});
it('pads only day when month is already 2 digits', () => {
expect(formatGermanDateInput('3.12.2024')).toBe('03.12.2024');
});
it('pads only month when day is already 2 digits', () => {
expect(formatGermanDateInput('20.3.2024')).toBe('20.03.2024');
});
it('handles a complete date entered with manual dots and no padding needed', () => {
expect(formatGermanDateInput('20.12.2024')).toBe('20.12.2024');
});
it('overflows excess day digits into month when dot follows', () => {
expect(formatGermanDateInput('123.')).toBe('12.3');
});
it('caps year digits at 4', () => {
expect(formatGermanDateInput('03.03.20249')).toBe('03.03.2024');
});
it('overflows excess month digits into year (digit stream then continue typing)', () => {
// User typed digits → auto-dot gave "20.12", then types "2" → raw becomes "20.122"
expect(formatGermanDateInput('20.122')).toBe('20.12.2');
});
it('continues building year after overflow', () => {
expect(formatGermanDateInput('20.12.2024')).toBe('20.12.2024');
});
});

View File

@@ -0,0 +1,123 @@
/**
* Format an ISO date string (YYYY-MM-DD) for display.
* Uses T12:00:00 to avoid UTC timezone off-by-one when converting to local time.
* Defaults to 'long' (e.g. "24. Dezember 1943"); pass 'short' for DD.MM.YYYY.
*/
export function formatDate(isoDate: string, format: 'short' | 'long' = 'long'): string {
const date = new Date(isoDate + 'T12:00:00');
if (format === 'short') {
return new Intl.DateTimeFormat('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
}).format(date);
}
return new Intl.DateTimeFormat('de-DE', {
day: 'numeric',
month: 'long',
year: 'numeric'
}).format(date);
}
/**
* Format an ISO date string for medium-length display (e.g. "15. Jun. 1920").
* Uses T12:00:00 to avoid UTC timezone off-by-one.
* Pass an explicit BCP 47 locale tag to respect the app locale; defaults to 'de-DE'.
*/
export function formatMCDate(isoDate: string, locale: string = 'de-DE'): string {
return new Intl.DateTimeFormat(locale, {
day: 'numeric',
month: 'short',
year: 'numeric'
}).format(new Date(isoDate + 'T12:00:00'));
}
/**
* Converts an ISO date string (YYYY-MM-DD) to German display format (DD.MM.YYYY).
* Returns an empty string for invalid or empty input.
*/
export function isoToGerman(iso: string): string {
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return '';
const [y, m, d] = iso.split('-');
return `${d}.${m}.${y}`;
}
/**
* Converts a German date string (DD.MM.YYYY) to ISO format (YYYY-MM-DD).
* Returns an empty string for invalid or empty input.
*/
export function germanToIso(german: string): string {
const match = german.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (!match) return '';
const [, d, m, y] = match;
return `${y}-${m}-${d}`;
}
/**
* Formats a raw date string into German DD.MM.YYYY format.
*
* Handles two modes:
* - Pure digit stream (no dots): auto-inserts dots after position 2 and 4
* - Manual dot entry: preserves user-typed dots, pads single-digit day/month,
* and overflows extra digits from day→month and month→year
*/
export function formatGermanDateInput(raw: string): string {
if (!raw.includes('.')) {
const digits = raw.replace(/\D/g, '').slice(0, 8);
if (digits.length <= 2) return digits;
if (digits.length <= 4) return `${digits.slice(0, 2)}.${digits.slice(2)}`;
return `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
}
const trailingDot = raw.endsWith('.');
const parts = raw.split('.').map((p) => p.replace(/\D/g, ''));
let day = parts[0] ?? '';
let month = parts[1] ?? '';
let year = parts[2] ?? '';
let dayOverflowed = false;
if (day.length > 2) {
month = day.slice(2) + month;
day = day.slice(0, 2);
dayOverflowed = true;
}
let monthOverflowed = false;
if (month.length > 2) {
year = month.slice(2) + year;
month = month.slice(0, 2);
monthOverflowed = true;
}
year = year.slice(0, 4);
const afterDay = !dayOverflowed && parts.length >= 2;
if (day.length === 1 && (month || (trailingDot && !dayOverflowed))) {
day = '0' + day;
}
if (month.length === 1 && (year || (trailingDot && afterDay && !monthOverflowed))) {
month = '0' + month;
}
if (year) return `${day}.${month}.${year}`;
if (month) {
const dot2 = trailingDot && afterDay && !monthOverflowed ? '.' : '';
return `${day}.${month}${dot2}`;
}
const dot1 = trailingDot && !dayOverflowed ? '.' : '';
return `${day}${dot1}`;
}
/**
* Handles a date input event for German-format date fields (DD.MM.YYYY).
* Strips non-digits, formats with dots, mutates the input's displayed value,
* and returns the display string and its ISO equivalent.
*/
export function handleGermanDateInput(e: Event): { display: string; iso: string } {
const input = e.target as HTMLInputElement;
const display = formatGermanDateInput(input.value);
input.value = display;
return { display, iso: germanToIso(display) };
}

View File

@@ -0,0 +1,69 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { debounce } from './debounce';
describe('debounce', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('does not fire before the delay has elapsed', () => {
const fn = vi.fn();
const debounced = debounce(fn, 200);
debounced();
vi.advanceTimersByTime(199);
expect(fn).not.toHaveBeenCalled();
});
it('fires exactly once after the delay', () => {
const fn = vi.fn();
const debounced = debounce(fn, 200);
debounced();
vi.advanceTimersByTime(200);
expect(fn).toHaveBeenCalledTimes(1);
});
it('resets the timer on each call — fires only once after inactivity', () => {
const fn = vi.fn();
const debounced = debounce(fn, 200);
debounced();
vi.advanceTimersByTime(100);
debounced();
vi.advanceTimersByTime(100);
debounced();
vi.advanceTimersByTime(200);
expect(fn).toHaveBeenCalledTimes(1);
});
it('passes the latest arguments to the callback', () => {
const fn = vi.fn();
const debounced = debounce(fn, 200);
debounced('first');
debounced('second');
vi.advanceTimersByTime(200);
expect(fn).toHaveBeenCalledWith('second');
});
it('can fire again after the first invocation settles', () => {
const fn = vi.fn();
const debounced = debounce(fn, 200);
debounced();
vi.advanceTimersByTime(200);
debounced();
vi.advanceTimersByTime(200);
expect(fn).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,12 @@
/**
* Returns a debounced version of fn that delays invocation until after
* `delay` ms have elapsed since the last call.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function debounce<T extends (...args: any[]) => void>(fn: T, delay: number): T {
let timer: ReturnType<typeof setTimeout>;
return ((...args: Parameters<T>) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
}) as T;
}

View File

@@ -0,0 +1,152 @@
import { describe, expect, it, vi } from 'vitest';
import { scrollToCommentFromQuery, type DeepLinkScrollOptions } from './deepLinkScroll';
const COMMENT_ID = 'cccc1111-1111-1111-1111-111111111111';
const ANNOTATION_ID = 'aaaa2222-2222-2222-2222-222222222222';
function fakeElement() {
return {
scrollIntoView: vi.fn(),
focus: vi.fn()
} as unknown as HTMLElement;
}
type Overrides = Partial<DeepLinkScrollOptions>;
function buildOpts(overrides: Overrides = {}): DeepLinkScrollOptions {
const el = overrides.getElement ? null : fakeElement();
return {
transcribeMode: true,
setTranscribeMode: vi.fn(),
setPanelMode: vi.fn(),
loadBlocks: vi.fn().mockResolvedValue(undefined),
setActiveAnnotationId: vi.fn(),
flashAnnotation: vi.fn(),
prefersReducedMotion: false,
afterTick: vi.fn().mockResolvedValue(undefined),
getElement: vi.fn().mockReturnValue(el),
onStripUrl: vi.fn(),
...overrides
};
}
describe('scrollToCommentFromQuery', () => {
it('is a no-op when commentId query param is absent', async () => {
const url = new URL('https://app/documents/doc-1');
const opts = buildOpts();
await scrollToCommentFromQuery(url, opts);
expect(opts.setActiveAnnotationId).not.toHaveBeenCalled();
expect(opts.getElement).not.toHaveBeenCalled();
expect(opts.onStripUrl).not.toHaveBeenCalled();
});
it('is a no-op when annotationId query param is absent even if commentId is present', async () => {
const url = new URL(`https://app/documents/doc-1?commentId=${COMMENT_ID}`);
const opts = buildOpts();
await scrollToCommentFromQuery(url, opts);
expect(opts.setActiveAnnotationId).not.toHaveBeenCalled();
expect(opts.getElement).not.toHaveBeenCalled();
});
it('scrolls to the comment element and focuses it when both params are present', async () => {
const url = new URL(
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
);
const el = fakeElement();
const opts = buildOpts({ getElement: vi.fn().mockReturnValue(el) });
await scrollToCommentFromQuery(url, opts);
expect(opts.getElement).toHaveBeenCalledWith(`comment-${COMMENT_ID}`);
expect(el.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' });
expect(el.focus).toHaveBeenCalledWith({ preventScroll: true });
});
it('triggers the annotation flash after scrolling', async () => {
const url = new URL(
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
);
const el = fakeElement();
const opts = buildOpts({ getElement: vi.fn().mockReturnValue(el) });
await scrollToCommentFromQuery(url, opts);
expect(opts.flashAnnotation).toHaveBeenCalledWith(ANNOTATION_ID);
});
it('enters transcribe mode and awaits loadBlocks when transcribe mode is off', async () => {
const url = new URL(
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
);
const opts = buildOpts({ transcribeMode: false });
await scrollToCommentFromQuery(url, opts);
expect(opts.setTranscribeMode).toHaveBeenCalledWith(true);
expect(opts.loadBlocks).toHaveBeenCalled();
});
it('is a graceful no-op when the target element is not in the DOM', async () => {
const url = new URL(
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
);
const opts = buildOpts({ getElement: vi.fn().mockReturnValue(null) });
// Must not throw. Flash should not fire — nothing to highlight.
await expect(scrollToCommentFromQuery(url, opts)).resolves.toBeUndefined();
expect(opts.flashAnnotation).not.toHaveBeenCalled();
});
it('uses behavior "instant" when prefers-reduced-motion is set', async () => {
const url = new URL(
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
);
const el = fakeElement();
const opts = buildOpts({
prefersReducedMotion: true,
getElement: vi.fn().mockReturnValue(el)
});
await scrollToCommentFromQuery(url, opts);
expect(el.scrollIntoView).toHaveBeenCalledWith({ behavior: 'instant', block: 'center' });
});
it('strips both commentId and annotationId from the URL after handling', async () => {
const url = new URL(
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
);
const opts = buildOpts();
await scrollToCommentFromQuery(url, opts);
expect(opts.onStripUrl).toHaveBeenCalled();
});
it('forces panel mode to "edit" so the comment DOM exists on reviewed documents', async () => {
const url = new URL(
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
);
const opts = buildOpts();
await scrollToCommentFromQuery(url, opts);
expect(opts.setPanelMode).toHaveBeenCalledWith('edit');
});
it('forces panel mode to "edit" even when transcribe mode is already on', async () => {
const url = new URL(
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
);
const opts = buildOpts({ transcribeMode: true });
await scrollToCommentFromQuery(url, opts);
expect(opts.setPanelMode).toHaveBeenCalledWith('edit');
});
});

View File

@@ -0,0 +1,46 @@
export type DeepLinkScrollOptions = {
transcribeMode: boolean;
setTranscribeMode: (value: boolean) => void;
setPanelMode: (mode: 'read' | 'edit') => void;
loadBlocks: () => Promise<void>;
setActiveAnnotationId: (id: string) => void;
flashAnnotation: (annotationId: string) => void;
prefersReducedMotion: boolean;
afterTick: () => Promise<void>;
getElement: (id: string) => HTMLElement | null;
onStripUrl: () => void;
};
export async function scrollToCommentFromQuery(
url: URL,
opts: DeepLinkScrollOptions
): Promise<void> {
const commentId = url.searchParams.get('commentId');
if (!commentId) return;
const annotationId = url.searchParams.get('annotationId');
if (!annotationId) return;
if (!opts.transcribeMode) {
opts.setTranscribeMode(true);
await opts.loadBlocks();
}
// Comments only render in edit mode — force it so the deep-link target
// exists in the DOM even if the document already has reviewed transcriptions
// (which default the panel to read mode).
opts.setPanelMode('edit');
opts.setActiveAnnotationId(annotationId);
await opts.afterTick();
const el = opts.getElement(`comment-${commentId}`);
if (el) {
const behavior: ScrollBehavior = opts.prefersReducedMotion ? 'instant' : 'smooth';
el.scrollIntoView({ behavior, block: 'center' });
el.focus({ preventScroll: true });
opts.flashAnnotation(annotationId);
}
opts.onStripUrl();
}

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import { extractText, plainExcerpt } from './extractText';
describe('extractText', () => {
it('returns empty string for null/undefined/empty', () => {
expect(extractText(null)).toBe('');
expect(extractText(undefined)).toBe('');
expect(extractText('')).toBe('');
});
it('strips tags and preserves visible text', () => {
expect(extractText('<p>Hello <strong>world</strong></p>')).toBe('Hello world');
});
it('collapses whitespace within and between blocks', () => {
expect(extractText('<p>One</p><p>Two</p>')).toBe('OneTwo');
expect(extractText('<p>foo bar</p>')).toBe('foo bar');
});
// XSS-shaped inputs: extractText must NOT execute, render, or expose the
// payload as HTML. It is only required to return *some* string. The fact
// that it exists is documented as a non-sanitiser; these tests prevent
// silent regressions where the function might somehow leak a tag.
describe('XSS-shaped input — never re-emits markup, even though this is not a sanitiser', () => {
it('drops <script> and surfaces only its text content', () => {
const out = extractText('<p>ok</p><script>alert(1)</script>');
expect(out).not.toContain('<script>');
expect(out).not.toContain('</script>');
});
it('drops <svg/onload> markup', () => {
const out = extractText('<svg/onload=alert(1)>');
expect(out).not.toContain('<svg');
expect(out).not.toContain('onload');
});
it('drops <iframe srcdoc=…> markup', () => {
const out = extractText('<iframe srcdoc="<script>alert(1)</script>">');
expect(out).not.toContain('<iframe');
expect(out).not.toContain('srcdoc');
});
it('drops <a href="javascript:…"> tag (text content may remain)', () => {
const out = extractText('<a href="javascript:alert(1)">click</a>');
expect(out).not.toContain('<a ');
expect(out).not.toContain('javascript:');
});
});
});
describe('plainExcerpt', () => {
it('returns full text when under the limit', () => {
expect(plainExcerpt('<p>short</p>', 80)).toBe('short');
});
it('truncates at the boundary with an ellipsis', () => {
const html = '<p>' + 'a'.repeat(100) + '</p>';
const out = plainExcerpt(html, 20);
expect(out.length).toBeLessThanOrEqual(21);
expect(out.endsWith('…')).toBe(true);
});
it('breaks at a word boundary when possible', () => {
const out = plainExcerpt('<p>The quick brown fox jumps over</p>', 18);
expect(out).toBe('The quick brown…');
});
});

View File

@@ -0,0 +1,38 @@
/**
* **Not a sanitizer.** This module extracts visible text from a (presumed
* already-sanitised) HTML string for excerpt rendering. It is safe ONLY
* because the Geschichte body is sanitised against the OWASP allow-list
* on the server before persistence, and via DOMPurify on render.
*
* Do not use these helpers to defend against XSS — `safeHtml()` in
* `./sanitize.ts` is the only sanitiser. Calling `extractText()` on
* untrusted input that has not been sanitised does not protect against
* `javascript:` URLs, event-handler attributes, or `<svg/onload>` payloads.
*/
/**
* Strip tags and return plain text. Uses DOMParser in the browser; on the
* server it falls back to a regex that drops angle-bracket sequences.
* The fallback is **not** a sanitiser — see module docstring.
*/
export function extractText(html: string | null | undefined): string {
if (!html) return '';
if (typeof DOMParser === 'function') {
const doc = new DOMParser().parseFromString(html, 'text/html');
return (doc.body.textContent ?? '').replace(/\s+/g, ' ').trim();
}
return html
.replace(/<[^>]*>/g, '')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Strip tags then truncate to `max` chars on a word boundary, appending an
* ellipsis when truncated. Used for editorial story excerpts.
*/
export function plainExcerpt(html: string | null | undefined, max = 80): string {
const text = extractText(html);
if (text.length <= max) return text;
return text.slice(0, max).replace(/\s+\S*$/, '') + '…';
}

View File

@@ -0,0 +1,111 @@
import { describe, it, expect } from 'vitest';
import {
computeHoverCardPosition,
CARD_WIDTH_PX,
CARD_HEIGHT_PX,
CARD_GAP_PX,
BOTTOM_BAND_RATIO,
RIGHT_FLIP_THRESHOLD_PX
} from './hoverCardPosition';
const makeRect = (overrides: Partial<DOMRect> = {}): DOMRect => {
const base = { top: 100, left: 200, bottom: 120, right: 300, width: 100, height: 20 };
const merged = { ...base, ...overrides };
return {
...merged,
x: merged.left,
y: merged.top,
toJSON: () => merged
} as DOMRect;
};
const vp = { viewportWidth: 1440, viewportHeight: 900 };
describe('computeHoverCardPosition', () => {
it('exports the spec constants used by the spec/CSS layer', () => {
// Pin the values the design spec calls out — if these drift, the design spec
// in #5329 needs to drift with them. Felix's PR review #2 (named constants).
expect(CARD_WIDTH_PX).toBe(320);
expect(CARD_HEIGHT_PX).toBe(180);
expect(CARD_GAP_PX).toBe(6);
expect(BOTTOM_BAND_RATIO).toBe(0.7);
expect(RIGHT_FLIP_THRESHOLD_PX).toBe(300);
});
describe('default placement (below-right)', () => {
it('positions the card below the rect with a small gap', () => {
const rect = makeRect({ top: 100, bottom: 120, left: 200 });
const result = computeHoverCardPosition(rect, vp);
expect(result.top).toBe(120 + CARD_GAP_PX);
expect(result.left).toBe(200);
});
});
describe('flip-up rule (Leonie #5329)', () => {
it('flips up when the card would overflow the bottom edge', () => {
// Mention sits 50px above the viewport bottom — card is 180px tall, can't fit below
const rect = makeRect({ top: 800, bottom: 850 });
const result = computeHoverCardPosition(rect, vp);
expect(result.top).toBe(800 - CARD_HEIGHT_PX - CARD_GAP_PX);
});
it('flips up when the mention sits in the bottom 30% of the viewport (BOTTOM_BAND_RATIO)', () => {
// rect.top is at 80% of viewport — fits below numerically, but poor UX
const rect = makeRect({ top: 720, bottom: 740 });
const result = computeHoverCardPosition(rect, vp);
expect(result.top).toBe(720 - CARD_HEIGHT_PX - CARD_GAP_PX);
});
});
describe('flip-left rule', () => {
it('flips left when the rect is within RIGHT_FLIP_THRESHOLD_PX of the right edge', () => {
// vw - rect.left = 1440 - 1200 = 240 < 300, so flip
const rect = makeRect({ left: 1200, right: 1300, top: 100, bottom: 120 });
const result = computeHoverCardPosition(rect, { viewportWidth: 1440, viewportHeight: 900 });
// left = right - CARD_WIDTH = 1300 - 320 = 980
expect(result.left).toBe(980);
});
it('does not flip left when the rect has plenty of right-side room', () => {
// vw - rect.left = 1440 - 200 = 1240 >> 300 → no flip
const rect = makeRect({ left: 200, right: 300 });
const result = computeHoverCardPosition(rect, vp);
expect(result.left).toBe(200);
});
});
describe('viewport clamping (Leonie FINDING-05)', () => {
it('clamps left so the card never overflows the right edge', () => {
// On a 320px viewport, even with flip the card width equals the viewport.
// Without clamping the card would be at left=0 but extend to 320 — fine.
// At viewport=400px with rect.left=200, flip puts left=300-320=-20, clamped to 0.
const rect = makeRect({ left: 200, right: 300, top: 100, bottom: 120 });
const result = computeHoverCardPosition(rect, { viewportWidth: 400, viewportHeight: 900 });
expect(result.left).toBeGreaterThanOrEqual(0);
expect(result.left + CARD_WIDTH_PX).toBeLessThanOrEqual(400);
});
it('never returns a negative top or left', () => {
const rect = makeRect({ top: -50, left: -100, bottom: -30, right: 0 });
const result = computeHoverCardPosition(rect, vp);
expect(result.top).toBeGreaterThanOrEqual(0);
expect(result.left).toBeGreaterThanOrEqual(0);
});
});
describe('position: fixed (viewport-relative coordinates)', () => {
it('returns viewport-relative top — does not add scroll offset', () => {
// getBoundingClientRect values are already viewport-relative; with position:fixed
// we use them directly without adding scrollY.
const rect = makeRect({ top: 100, bottom: 120 });
const result = computeHoverCardPosition(rect, vp);
expect(result.top).toBe(120 + CARD_GAP_PX);
});
it('returns viewport-relative left — does not add scroll offset', () => {
const rect = makeRect({ top: 100, bottom: 120, left: 200, right: 300 });
const result = computeHoverCardPosition(rect, vp);
expect(result.left).toBe(200);
});
});
});

View File

@@ -0,0 +1,67 @@
/**
* Pure positioning logic for the person-mention hover card.
*
* Pulled out of TranscriptionReadView so the four placement branches
* (default, flip-up, flip-left, both) plus the viewport clamp are unit-testable
* without DOM. Sara's PR-B2 review #6 (no test for computeCardPosition) and
* Leonie's FINDING-05 (320px overflow) both land here.
*/
/** Width of the rendered hover card. Mirrored in PersonHoverCard.svelte's CSS. */
export const CARD_WIDTH_PX = 320;
/** Min-height of the rendered hover card. Mirrored in PersonHoverCard.svelte's CSS. */
export const CARD_HEIGHT_PX = 180;
/** Gap between the mention rect and the card so they do not touch. */
export const CARD_GAP_PX = 6;
/**
* Mentions in the bottom 30% of the viewport flip the card up by default,
* even if it would numerically fit below — keeping the eye-line stable
* is more important than minimal travel (Leonie #5329).
*/
export const BOTTOM_BAND_RATIO = 0.7;
/**
* Mentions within this distance of the right viewport edge flip the card
* left so it stays fully visible.
*/
export const RIGHT_FLIP_THRESHOLD_PX = 300;
export type Viewport = {
viewportWidth: number;
viewportHeight: number;
};
export type CardPosition = { top: number; left: number };
/**
* Compute absolute-positioned top/left for the hover card, given a rect for
* the mention anchor and the current viewport. Output is in document
* coordinates (already includes scroll offsets).
*/
export function computeHoverCardPosition(rect: DOMRect, vp: Viewport): CardPosition {
let top = rect.bottom + CARD_GAP_PX;
let left = rect.left;
const overflowsBottom = vp.viewportHeight - rect.bottom < CARD_HEIGHT_PX + CARD_GAP_PX;
const inBottomBand = rect.top > vp.viewportHeight * BOTTOM_BAND_RATIO;
if (overflowsBottom || inBottomBand) {
top = rect.top - CARD_HEIGHT_PX - CARD_GAP_PX;
}
if (vp.viewportWidth - rect.left < RIGHT_FLIP_THRESHOLD_PX) {
left = rect.right - CARD_WIDTH_PX;
}
// Clamp left so the card never extends past the right viewport edge
// (FINDING-05: at 320px viewport the flip would otherwise produce a
// negative left or right-side overflow).
left = Math.min(left, vp.viewportWidth - CARD_WIDTH_PX - CARD_GAP_PX);
return {
top: Math.max(0, top),
left: Math.max(0, left)
};
}

View File

@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { countRequiredFilled } from './requiredFields';
describe('countRequiredFilled', () => {
it('returns 0 when all three fields are empty', () => {
expect(countRequiredFilled('', '', '')).toBe(0);
});
it('returns 1 when only title is set', () => {
expect(countRequiredFilled('Ein Brief', '', '')).toBe(1);
});
it('returns 1 when only dateIso is set', () => {
expect(countRequiredFilled('', '1920-05-01', '')).toBe(1);
});
it('returns 1 when only senderId is set', () => {
expect(countRequiredFilled('', '', 'person-uuid')).toBe(1);
});
it('returns 2 when title and dateIso are set', () => {
expect(countRequiredFilled('Ein Brief', '1920-05-01', '')).toBe(2);
});
it('returns 2 when title and senderId are set', () => {
expect(countRequiredFilled('Ein Brief', '', 'person-uuid')).toBe(2);
});
it('returns 2 when dateIso and senderId are set', () => {
expect(countRequiredFilled('', '1920-05-01', 'person-uuid')).toBe(2);
});
it('returns 3 when all three fields are set', () => {
expect(countRequiredFilled('Ein Brief', '1920-05-01', 'person-uuid')).toBe(3);
});
});

View File

@@ -0,0 +1,3 @@
export function countRequiredFilled(title: string, dateIso: string, senderId: string): number {
return [title, dateIso, senderId].filter(Boolean).length;
}

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { safeHtml } from './sanitize';
describe('safeHtml', () => {
it('returns empty string for null/undefined/empty input', () => {
expect(safeHtml(null)).toBe('');
expect(safeHtml(undefined)).toBe('');
expect(safeHtml('')).toBe('');
});
it('keeps allowed tags: p, strong, em, br, h2, h3, ul, ol, li', () => {
const html =
'<p><strong>bold</strong> <em>italic</em><br>x</p>' +
'<h2>H2</h2><h3>H3</h3>' +
'<ul><li>a</li></ul><ol><li>b</li></ol>';
const result = safeHtml(html);
expect(result).toContain('<strong>bold</strong>');
expect(result).toContain('<em>italic</em>');
expect(result).toContain('<br>');
expect(result).toContain('<h2>H2</h2>');
expect(result).toContain('<h3>H3</h3>');
expect(result).toContain('<ul>');
expect(result).toContain('<ol>');
expect(result).toContain('<li>a</li>');
});
it('strips <script> tags entirely', () => {
const result = safeHtml('<p>ok</p><script>alert(1)</script>');
expect(result).not.toContain('<script>');
expect(result).not.toContain('alert');
expect(result).toContain('<p>ok</p>');
});
it('strips on* event-handler attributes', () => {
const result = safeHtml('<p onclick="evil()">x</p>');
expect(result).not.toContain('onclick');
});
it('strips disallowed elements like <img>, <a>, <iframe>', () => {
const result = safeHtml(
'<p>x</p><img src="x" onerror="alert(1)"><a href="javascript:alert(1)">link</a><iframe></iframe>'
);
expect(result).not.toContain('<img');
expect(result).not.toContain('<a ');
expect(result).not.toContain('<iframe');
});
});

View File

@@ -0,0 +1,17 @@
import DOMPurify from 'isomorphic-dompurify';
const ALLOWED_TAGS = ['p', 'br', 'strong', 'em', 'h2', 'h3', 'ul', 'ol', 'li'];
/**
* Render-side sanitiser for Geschichte body HTML. The backend already
* sanitises with the OWASP allow-list on save, but we re-run on render
* because the API can be called directly and stored content can pre-date
* a tightening of the allow-list.
*/
export function safeHtml(raw: string | null | undefined): string {
if (!raw) return '';
return DOMPurify.sanitize(raw, {
ALLOWED_TAGS,
ALLOWED_ATTR: []
});
}

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { sortDocumentsByDate } from './sort';
const doc = (id: string, documentDate: string | null) =>
({ id, documentDate }) as { id: string; documentDate: string | null };
describe('sortDocumentsByDate', () => {
it('sorts DESC by default — newest first', () => {
const docs = [doc('a', '1920-01-01'), doc('b', '1950-06-15'), doc('c', '1935-03-10')];
const result = sortDocumentsByDate(docs, 'DESC');
expect(result.map((d) => d.id)).toEqual(['b', 'c', 'a']);
});
it('sorts ASC — oldest first', () => {
const docs = [doc('a', '1920-01-01'), doc('b', '1950-06-15'), doc('c', '1935-03-10')];
const result = sortDocumentsByDate(docs, 'ASC');
expect(result.map((d) => d.id)).toEqual(['a', 'c', 'b']);
});
it('places documents without a date last in DESC', () => {
const docs = [doc('a', null), doc('b', '1940-01-01'), doc('c', null)];
const result = sortDocumentsByDate(docs, 'DESC');
expect(result[0].id).toBe('b');
expect(result.slice(1).map((d) => d.id)).toContain('a');
expect(result.slice(1).map((d) => d.id)).toContain('c');
});
it('places documents without a date last in ASC', () => {
const docs = [doc('a', null), doc('b', '1940-01-01'), doc('c', null)];
const result = sortDocumentsByDate(docs, 'ASC');
expect(result[0].id).toBe('b');
});
it('does not mutate the original array', () => {
const docs = [doc('a', '1950-01-01'), doc('b', '1920-01-01')];
const original = [...docs];
sortDocumentsByDate(docs, 'ASC');
expect(docs).toEqual(original);
});
it('returns an empty array unchanged', () => {
expect(sortDocumentsByDate([], 'DESC')).toEqual([]);
});
});

View File

@@ -0,0 +1,19 @@
export type SortDir = 'ASC' | 'DESC';
/**
* Returns a new array of documents sorted by documentDate.
* Documents without a date are always placed last, regardless of direction.
*/
export function sortDocumentsByDate<T extends { documentDate?: string | null }>(
docs: T[],
dir: SortDir
): T[] {
return [...docs].sort((a, b) => {
const da = a.documentDate ?? '';
const db = b.documentDate ?? '';
if (!da && !db) return 0;
if (!da) return 1;
if (!db) return -1;
return dir === 'DESC' ? db.localeCompare(da) : da.localeCompare(db);
});
}

View File

@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import { m } from '$lib/paraglide/messages.js';
const { relativeTime } = await import('./time');
function msAgo(ms: number, now: Date): string {
return new Date(now.getTime() - ms).toISOString();
}
describe('relativeTime', () => {
const now = new Date('2024-06-15T12:00:00.000Z');
it('returns "just now" for timestamps under 60 seconds ago', () => {
const ts = msAgo(30_000, now);
expect(relativeTime(ts, now)).toBe(m.comment_time_just_now());
});
it('returns 1-minute label for exactly 1 minute ago', () => {
const ts = msAgo(60_000, now);
expect(relativeTime(ts, now)).toBe(m.comment_time_minutes({ count: 1 }));
});
it('returns 59-minute label for exactly 59 minutes ago', () => {
const ts = msAgo(59 * 60_000, now);
expect(relativeTime(ts, now)).toBe(m.comment_time_minutes({ count: 59 }));
});
it('returns 1-hour label for exactly 1 hour ago', () => {
const ts = msAgo(60 * 60_000, now);
expect(relativeTime(ts, now)).toBe(m.comment_time_hours({ count: 1 }));
});
it('returns 23-hour label for 23 hours ago', () => {
const ts = msAgo(23 * 60 * 60_000, now);
expect(relativeTime(ts, now)).toBe(m.comment_time_hours({ count: 23 }));
});
it('returns 1-day label for exactly 24 hours ago', () => {
const ts = msAgo(24 * 60 * 60_000, now);
expect(relativeTime(ts, now)).toBe(m.comment_time_days({ count: 1 }));
});
it('returns 6-day label for 6 days ago', () => {
const ts = msAgo(6 * 24 * 60 * 60_000, now);
expect(relativeTime(ts, now)).toBe(m.comment_time_days({ count: 6 }));
});
it('defaults now to current time when omitted', () => {
const ts = new Date(Date.now() - 5 * 60_000).toISOString();
expect(relativeTime(ts)).toBeTruthy();
});
});

View File

@@ -0,0 +1,12 @@
import { m } from '$lib/paraglide/messages.js';
export function relativeTime(isoString: string, now: Date = new Date()): string {
const diff = now.getTime() - new Date(isoString).getTime();
const minutes = Math.floor(diff / 60_000);
if (minutes < 1) return m.comment_time_just_now();
if (minutes < 60) return m.comment_time_minutes({ count: minutes });
const hours = Math.floor(minutes / 60);
if (hours < 24) return m.comment_time_hours({ count: hours });
const days = Math.floor(hours / 24);
return m.comment_time_days({ count: days });
}