refactor: move shared utilities to lib/shared/ sub-packages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
76
frontend/src/lib/shared/actions/clickOutside.svelte.spec.ts
Normal file
76
frontend/src/lib/shared/actions/clickOutside.svelte.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
16
frontend/src/lib/shared/actions/clickOutside.ts
Normal file
16
frontend/src/lib/shared/actions/clickOutside.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
87
frontend/src/lib/shared/actions/radioGroupNav.svelte.spec.ts
Normal file
87
frontend/src/lib/shared/actions/radioGroupNav.svelte.spec.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
37
frontend/src/lib/shared/actions/radioGroupNav.ts
Normal file
37
frontend/src/lib/shared/actions/radioGroupNav.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
40
frontend/src/lib/shared/discussion/comment.spec.ts
Normal file
40
frontend/src/lib/shared/discussion/comment.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
5
frontend/src/lib/shared/discussion/comment.ts
Normal file
5
frontend/src/lib/shared/discussion/comment.ts
Normal 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 };
|
||||
}
|
||||
14
frontend/src/lib/shared/discussion/commentDeepLink.spec.ts
Normal file
14
frontend/src/lib/shared/discussion/commentDeepLink.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
8
frontend/src/lib/shared/discussion/commentDeepLink.ts
Normal file
8
frontend/src/lib/shared/discussion/commentDeepLink.ts
Normal 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;
|
||||
}
|
||||
350
frontend/src/lib/shared/discussion/mention.spec.ts
Normal file
350
frontend/src/lib/shared/discussion/mention.spec.ts
Normal 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&T');
|
||||
});
|
||||
|
||||
it('escapes less-than and greater-than', () => {
|
||||
expect(escapeHtml('<script>')).toBe('<script>');
|
||||
});
|
||||
|
||||
it('escapes double quote', () => {
|
||||
expect(escapeHtml('say "hi"')).toBe('say "hi"');
|
||||
});
|
||||
|
||||
it('returns empty string unchanged', () => {
|
||||
expect(escapeHtml('')).toBe('');
|
||||
});
|
||||
|
||||
it('escapes ampersand before other entities to avoid double-encoding', () => {
|
||||
expect(escapeHtml('a&<b')).toBe('a&<b');
|
||||
});
|
||||
|
||||
it('escapes apostrophe to '', () => {
|
||||
expect(escapeHtml("d'Artagnan")).toBe('d'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('&')).toBe('&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('<script>');
|
||||
expect(result).not.toContain('<script>');
|
||||
});
|
||||
|
||||
it('escapes & in content', () => {
|
||||
const result = renderBody('AT&T', []);
|
||||
expect(result).toContain('AT&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('&');
|
||||
});
|
||||
|
||||
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('<script>');
|
||||
});
|
||||
|
||||
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('<script>alert(1)</script>');
|
||||
expect(result).not.toContain('<script>');
|
||||
});
|
||||
|
||||
it('escapes & in plain block text', () => {
|
||||
expect(renderTranscriptionBody('AT&T', [])).toBe('AT&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('<script>');
|
||||
});
|
||||
|
||||
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('<img');
|
||||
});
|
||||
|
||||
it('does not double-encode HTML-entity-already-encoded payloads', () => {
|
||||
// `&lt;script&gt;` is already-escaped HTML in the source text.
|
||||
// renderTranscriptionBody must escape the literal & once → `&amp;lt;...`
|
||||
// — never silently decode pre-escaped entities.
|
||||
const result = renderTranscriptionBody('text &lt;script&gt;', []);
|
||||
expect(result).toBe('text &amp;lt;script&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 ".
|
||||
expect(result).toMatch(/>O"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"');
|
||||
});
|
||||
});
|
||||
163
frontend/src/lib/shared/discussion/mention.ts
Normal file
163
frontend/src/lib/shared/discussion/mention.ts
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function escapeRegExp(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict UUID v1–v5 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;
|
||||
}
|
||||
138
frontend/src/lib/shared/discussion/mentionSerializer.spec.ts
Normal file
138
frontend/src/lib/shared/discussion/mentionSerializer.spec.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { deserialize, serialize } from './mentionSerializer';
|
||||
import type { PersonMention } from '$lib/types';
|
||||
|
||||
// ─── deserialize ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('deserialize', () => {
|
||||
it('returns a valid Tiptap doc for an empty string', () => {
|
||||
const doc = deserialize('', []);
|
||||
expect(doc.type).toBe('doc');
|
||||
expect(doc.content).toHaveLength(1);
|
||||
expect(doc.content![0].type).toBe('paragraph');
|
||||
});
|
||||
|
||||
it('returns plain text node for text with no mentions', () => {
|
||||
const doc = deserialize('Hallo Welt', []);
|
||||
const para = doc.content![0];
|
||||
expect(para.content).toHaveLength(1);
|
||||
expect(para.content![0]).toEqual({ type: 'text', text: 'Hallo Welt' });
|
||||
});
|
||||
|
||||
it('places a mention node at the correct position in the paragraph', () => {
|
||||
const sidecar: PersonMention[] = [{ personId: 'uuid-x', displayName: 'Clara' }];
|
||||
const doc = deserialize('Hallo @Clara Welt', sidecar);
|
||||
const nodes = doc.content![0].content!;
|
||||
expect(nodes).toHaveLength(3);
|
||||
expect(nodes[0]).toEqual({ type: 'text', text: 'Hallo ' });
|
||||
expect(nodes[1]).toMatchObject({
|
||||
type: 'mention',
|
||||
attrs: { displayName: 'Clara', personId: 'uuid-x' }
|
||||
});
|
||||
expect(nodes[2]).toEqual({ type: 'text', text: ' Welt' });
|
||||
});
|
||||
|
||||
it('prefers the longer displayName when two sidecar entries share a prefix', () => {
|
||||
const sidecar: PersonMention[] = [
|
||||
{ personId: 'uuid-short', displayName: 'Auguste' },
|
||||
{ personId: 'uuid-long', displayName: 'Auguste Raddatz' }
|
||||
];
|
||||
const doc = deserialize('@Auguste Raddatz', sidecar);
|
||||
const nodes = doc.content![0].content!;
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect(nodes[0]).toMatchObject({
|
||||
type: 'mention',
|
||||
attrs: { personId: 'uuid-long', displayName: 'Auguste Raddatz' }
|
||||
});
|
||||
});
|
||||
|
||||
it('splits multiple paragraphs on newline', () => {
|
||||
const doc = deserialize('Zeile 1\nZeile 2', []);
|
||||
expect(doc.content).toHaveLength(2);
|
||||
expect(doc.content![0].content![0]).toEqual({ type: 'text', text: 'Zeile 1' });
|
||||
expect(doc.content![1].content![0]).toEqual({ type: 'text', text: 'Zeile 2' });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── serialize ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('serialize', () => {
|
||||
it('serializes plain text unchanged', () => {
|
||||
const doc = deserialize('Hallo Welt', []);
|
||||
const { text, mentionedPersons } = serialize(doc);
|
||||
expect(text).toBe('Hallo Welt');
|
||||
expect(mentionedPersons).toEqual([]);
|
||||
});
|
||||
|
||||
it('serializes mention node back to @displayName', () => {
|
||||
const sidecar: PersonMention[] = [{ personId: 'uuid-x', displayName: 'Clara' }];
|
||||
const doc = deserialize('Hallo @Clara Welt', sidecar);
|
||||
const { text, mentionedPersons } = serialize(doc);
|
||||
expect(text).toBe('Hallo @Clara Welt');
|
||||
expect(mentionedPersons).toEqual([{ personId: 'uuid-x', displayName: 'Clara' }]);
|
||||
});
|
||||
|
||||
it('joins multi-paragraph doc back with newlines', () => {
|
||||
const doc = deserialize('Zeile 1\nZeile 2', []);
|
||||
const { text } = serialize(doc);
|
||||
expect(text).toBe('Zeile 1\nZeile 2');
|
||||
});
|
||||
|
||||
it('de-duplicates repeated mentions in the sidecar', () => {
|
||||
const sidecar: PersonMention[] = [{ personId: 'uuid-x', displayName: 'Clara' }];
|
||||
const doc = deserialize('@Clara und @Clara', sidecar);
|
||||
const { mentionedPersons } = serialize(doc);
|
||||
expect(mentionedPersons).toHaveLength(1);
|
||||
expect(mentionedPersons[0].personId).toBe('uuid-x');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Round-trip invariant ─────────────────────────────────────────────────────
|
||||
|
||||
describe('round-trip invariant', () => {
|
||||
it('text is preserved exactly through deserialize → serialize', () => {
|
||||
const cases = [
|
||||
['', []],
|
||||
['Hallo Welt', []],
|
||||
['@Clara schreibt', [{ personId: 'uuid-x', displayName: 'Clara' }]],
|
||||
['Zeile 1\nZeile 2', []],
|
||||
['Sehr geehrte @Auguste Raddatz,', [{ personId: 'uuid-aug', displayName: 'Auguste Raddatz' }]]
|
||||
] as const;
|
||||
|
||||
for (const [text, sidecar] of cases) {
|
||||
const doc = deserialize(text, sidecar as PersonMention[]);
|
||||
const { text: out } = serialize(doc);
|
||||
expect(out, `round-trip failed for: "${text}"`).toBe(text);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Backward compatibility (AC-6) ────────────────────────────────────────────
|
||||
|
||||
describe('backward compatibility', () => {
|
||||
it('old-format full-name sidecar entry still round-trips correctly', () => {
|
||||
// Before this issue, displayName stored the person's full DB name.
|
||||
// renderTranscriptionBody already handles this — so does the serializer.
|
||||
const oldSidecar: PersonMention[] = [{ personId: 'uuid-aug', displayName: 'Auguste Raddatz' }];
|
||||
const text = 'Brief von @Auguste Raddatz';
|
||||
const doc = deserialize(text, oldSidecar);
|
||||
const { text: out, mentionedPersons } = serialize(doc);
|
||||
expect(out).toBe(text);
|
||||
expect(mentionedPersons).toEqual(oldSidecar);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Security ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('security', () => {
|
||||
it('displayName containing HTML special chars is preserved as a string, not injected', () => {
|
||||
const sidecar: PersonMention[] = [
|
||||
{ personId: 'uuid-x', displayName: '<script>alert(1)</script>' }
|
||||
];
|
||||
const text = '@<script>alert(1)</script>';
|
||||
const doc = deserialize(text, sidecar);
|
||||
const { mentionedPersons } = serialize(doc);
|
||||
// The displayName is stored verbatim — HTML escaping is the renderer's job
|
||||
expect(mentionedPersons[0].displayName).toBe('<script>alert(1)</script>');
|
||||
});
|
||||
});
|
||||
113
frontend/src/lib/shared/discussion/mentionSerializer.ts
Normal file
113
frontend/src/lib/shared/discussion/mentionSerializer.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { JSONContent } from '@tiptap/core';
|
||||
import type { PersonMention } from '$lib/types';
|
||||
|
||||
/**
|
||||
* Converts stored block text + sidecar into a Tiptap ProseMirror document.
|
||||
*
|
||||
* The text is split by "\n" into paragraphs. Within each paragraph, sidecar
|
||||
* entries are matched against "@displayName" tokens (longest first) and
|
||||
* converted to mention nodes. Unmatched text becomes plain text nodes.
|
||||
*
|
||||
* Round-trip invariant: serialize(deserialize(text, sidecar)).text === text
|
||||
*/
|
||||
export function deserialize(text: string, sidecar: PersonMention[]): JSONContent {
|
||||
const lines = text === '' ? [''] : text.split('\n');
|
||||
|
||||
// Sort sidecar by displayName length descending so longer names shadow
|
||||
// shorter prefix matches (same heuristic as renderTranscriptionBody).
|
||||
const sorted = [...sidecar].sort((a, b) => b.displayName.length - a.displayName.length);
|
||||
|
||||
return {
|
||||
type: 'doc',
|
||||
content: lines.map((line) => ({
|
||||
type: 'paragraph',
|
||||
content: parseLine(line, sorted)
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function parseLine(text: string, sidecar: PersonMention[]): JSONContent[] {
|
||||
if (text === '') return [];
|
||||
|
||||
if (sidecar.length === 0) {
|
||||
return [{ type: 'text', text }];
|
||||
}
|
||||
|
||||
// Build a list of mention ranges: { start, end, mention }
|
||||
const ranges: Array<{ start: number; end: number; mention: PersonMention }> = [];
|
||||
|
||||
for (const mention of sidecar) {
|
||||
const needle = `@${mention.displayName}`;
|
||||
let idx = 0;
|
||||
while (idx < text.length) {
|
||||
const pos = text.indexOf(needle, idx);
|
||||
if (pos === -1) break;
|
||||
// Check that the range doesn't overlap an already-found range
|
||||
const end = pos + needle.length;
|
||||
const overlaps = ranges.some((r) => r.start < end && r.end > pos);
|
||||
if (!overlaps) {
|
||||
ranges.push({ start: pos, end, mention });
|
||||
}
|
||||
idx = pos + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (ranges.length === 0) {
|
||||
return [{ type: 'text', text }];
|
||||
}
|
||||
|
||||
// Sort by position
|
||||
ranges.sort((a, b) => a.start - b.start);
|
||||
|
||||
const nodes: JSONContent[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
for (const { start, end, mention } of ranges) {
|
||||
if (start > cursor) {
|
||||
nodes.push({ type: 'text', text: text.slice(cursor, start) });
|
||||
}
|
||||
nodes.push({
|
||||
type: 'mention',
|
||||
attrs: { displayName: mention.displayName, personId: mention.personId }
|
||||
});
|
||||
cursor = end;
|
||||
}
|
||||
|
||||
if (cursor < text.length) {
|
||||
nodes.push({ type: 'text', text: text.slice(cursor) });
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Tiptap ProseMirror document back to stored block text + sidecar.
|
||||
*
|
||||
* Paragraphs are joined with "\n". Mention nodes are emitted as "@displayName"
|
||||
* and collected into mentionedPersons (de-duplicated by personId).
|
||||
*/
|
||||
export function serialize(doc: JSONContent): { text: string; mentionedPersons: PersonMention[] } {
|
||||
const paragraphs = doc.content ?? [];
|
||||
const mentionedPersons: PersonMention[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const para of paragraphs) {
|
||||
let line = '';
|
||||
for (const node of para.content ?? []) {
|
||||
if (node.type === 'text') {
|
||||
line += node.text ?? '';
|
||||
} else if (node.type === 'mention') {
|
||||
const { displayName, personId } = node.attrs ?? {};
|
||||
line += `@${displayName}`;
|
||||
if (!seenIds.has(personId)) {
|
||||
seenIds.add(personId);
|
||||
mentionedPersons.push({ personId, displayName });
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
|
||||
return { text: lines.join('\n'), mentionedPersons };
|
||||
}
|
||||
87
frontend/src/lib/shared/hooks/useTypeahead.svelte.test.ts
Normal file
87
frontend/src/lib/shared/hooks/useTypeahead.svelte.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
77
frontend/src/lib/shared/hooks/useTypeahead.svelte.ts
Normal file
77
frontend/src/lib/shared/hooks/useTypeahead.svelte.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
46
frontend/src/lib/shared/hooks/useUnsavedWarning.svelte.ts
Normal file
46
frontend/src/lib/shared/hooks/useUnsavedWarning.svelte.ts
Normal 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
|
||||
};
|
||||
}
|
||||
32
frontend/src/lib/shared/server/locale.spec.ts
Normal file
32
frontend/src/lib/shared/server/locale.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
20
frontend/src/lib/shared/server/locale.ts
Normal file
20
frontend/src/lib/shared/server/locale.ts
Normal 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;
|
||||
}
|
||||
70
frontend/src/lib/shared/services/confirm.svelte.test.ts
Normal file
70
frontend/src/lib/shared/services/confirm.svelte.test.ts
Normal 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>');
|
||||
});
|
||||
});
|
||||
100
frontend/src/lib/shared/services/confirm.svelte.ts
Normal file
100
frontend/src/lib/shared/services/confirm.svelte.ts
Normal 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;
|
||||
}
|
||||
11
frontend/src/lib/shared/services/confirm.test-host.svelte
Normal file
11
frontend/src/lib/shared/services/confirm.test-host.svelte
Normal 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 />
|
||||
50
frontend/src/lib/shared/utils/date-buckets.spec.ts
Normal file
50
frontend/src/lib/shared/utils/date-buckets.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
35
frontend/src/lib/shared/utils/date-buckets.ts
Normal file
35
frontend/src/lib/shared/utils/date-buckets.ts
Normal 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';
|
||||
}
|
||||
129
frontend/src/lib/shared/utils/date.spec.ts
Normal file
129
frontend/src/lib/shared/utils/date.spec.ts
Normal 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 1–2 digits as-is', () => {
|
||||
expect(formatGermanDateInput('2')).toBe('2');
|
||||
expect(formatGermanDateInput('20')).toBe('20');
|
||||
});
|
||||
|
||||
it('auto-inserts dot after 2 digits for 3–4 digit input', () => {
|
||||
expect(formatGermanDateInput('201')).toBe('20.1');
|
||||
expect(formatGermanDateInput('2012')).toBe('20.12');
|
||||
});
|
||||
|
||||
it('auto-inserts two dots for 5–8 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');
|
||||
});
|
||||
});
|
||||
123
frontend/src/lib/shared/utils/date.ts
Normal file
123
frontend/src/lib/shared/utils/date.ts
Normal 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) };
|
||||
}
|
||||
69
frontend/src/lib/shared/utils/debounce.spec.ts
Normal file
69
frontend/src/lib/shared/utils/debounce.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
12
frontend/src/lib/shared/utils/debounce.ts
Normal file
12
frontend/src/lib/shared/utils/debounce.ts
Normal 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;
|
||||
}
|
||||
152
frontend/src/lib/shared/utils/deepLinkScroll.spec.ts
Normal file
152
frontend/src/lib/shared/utils/deepLinkScroll.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
46
frontend/src/lib/shared/utils/deepLinkScroll.ts
Normal file
46
frontend/src/lib/shared/utils/deepLinkScroll.ts
Normal 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();
|
||||
}
|
||||
67
frontend/src/lib/shared/utils/extractText.spec.ts
Normal file
67
frontend/src/lib/shared/utils/extractText.spec.ts
Normal 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…');
|
||||
});
|
||||
});
|
||||
38
frontend/src/lib/shared/utils/extractText.ts
Normal file
38
frontend/src/lib/shared/utils/extractText.ts
Normal 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*$/, '') + '…';
|
||||
}
|
||||
111
frontend/src/lib/shared/utils/hoverCardPosition.spec.ts
Normal file
111
frontend/src/lib/shared/utils/hoverCardPosition.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
67
frontend/src/lib/shared/utils/hoverCardPosition.ts
Normal file
67
frontend/src/lib/shared/utils/hoverCardPosition.ts
Normal 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)
|
||||
};
|
||||
}
|
||||
36
frontend/src/lib/shared/utils/requiredFields.test.ts
Normal file
36
frontend/src/lib/shared/utils/requiredFields.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
3
frontend/src/lib/shared/utils/requiredFields.ts
Normal file
3
frontend/src/lib/shared/utils/requiredFields.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function countRequiredFilled(title: string, dateIso: string, senderId: string): number {
|
||||
return [title, dateIso, senderId].filter(Boolean).length;
|
||||
}
|
||||
47
frontend/src/lib/shared/utils/sanitize.spec.ts
Normal file
47
frontend/src/lib/shared/utils/sanitize.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
17
frontend/src/lib/shared/utils/sanitize.ts
Normal file
17
frontend/src/lib/shared/utils/sanitize.ts
Normal 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: []
|
||||
});
|
||||
}
|
||||
44
frontend/src/lib/shared/utils/sort.spec.ts
Normal file
44
frontend/src/lib/shared/utils/sort.spec.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
19
frontend/src/lib/shared/utils/sort.ts
Normal file
19
frontend/src/lib/shared/utils/sort.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
52
frontend/src/lib/shared/utils/time.spec.ts
Normal file
52
frontend/src/lib/shared/utils/time.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
12
frontend/src/lib/shared/utils/time.ts
Normal file
12
frontend/src/lib/shared/utils/time.ts
Normal 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user