feat(lesereisen): implement lesereisen
All checks were successful
CI / Unit & Component Tests (push) Successful in 4m34s
CI / OCR Service Tests (push) Successful in 27s
CI / Backend Unit Tests (push) Successful in 5m1s
CI / fail2ban Regex (push) Successful in 47s
CI / Semgrep Security Scan (push) Successful in 23s
CI / Compose Bucket Idempotency (push) Successful in 1m11s

This commit was merged in pull request #787.
This commit is contained in:
2026-06-12 14:04:02 +02:00
parent 4bcf568ed4
commit b33d0eb850
142 changed files with 11643 additions and 917 deletions

View File

@@ -18,9 +18,8 @@ export function radioGroupNav(
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();
radios.forEach((r, i) => r.setAttribute('aria-checked', i === next ? 'true' : 'false'));
onChangeFn?.(radios[next].getAttribute('value') ?? '');
}

View File

@@ -3,10 +3,10 @@ import * as m from '$lib/paraglide/messages.js';
import { relativeTimeDe } from '$lib/shared/relativeTime';
import type { components } from '$lib/generated/api';
type Geschichte = components['schemas']['Geschichte'];
type GeschichteSummary = components['schemas']['GeschichteSummary'];
interface Props {
drafts: Geschichte[];
drafts: GeschichteSummary[];
}
const { drafts }: Props = $props();

View File

@@ -5,24 +5,25 @@ import { page } from 'vitest/browser';
import ReaderDraftsModule from './ReaderDraftsModule.svelte';
import type { components } from '$lib/generated/api';
type Geschichte = components['schemas']['Geschichte'];
type GeschichteSummary = components['schemas']['GeschichteSummary'];
afterEach(() => {
cleanup();
});
const draft1: Geschichte = {
const draft1: GeschichteSummary = {
id: 'g1',
title: 'Mein erster Entwurf',
status: 'DRAFT',
createdAt: '2025-01-01T00:00:00Z',
type: 'STORY',
updatedAt: '2025-01-02T00:00:00Z'
};
const draft2: Geschichte = {
const draft2: GeschichteSummary = {
id: 'g2',
title: 'Zweiter Entwurf',
status: 'DRAFT',
type: 'STORY',
createdAt: '2025-02-01T00:00:00Z',
updatedAt: '2025-02-01T00:00:00Z'
};

View File

@@ -3,10 +3,10 @@ import * as m from '$lib/paraglide/messages.js';
import { relativeTimeDe } from '$lib/shared/relativeTime';
import type { components } from '$lib/generated/api';
type Geschichte = components['schemas']['Geschichte'];
type GeschichteSummary = components['schemas']['GeschichteSummary'];
interface Props {
stories: Geschichte[];
stories: GeschichteSummary[];
}
const { stories }: Props = $props();

View File

@@ -5,27 +5,28 @@ import { page } from 'vitest/browser';
import ReaderRecentStories from './ReaderRecentStories.svelte';
import type { components } from '$lib/generated/api';
type Geschichte = components['schemas']['Geschichte'];
type GeschichteSummary = components['schemas']['GeschichteSummary'];
afterEach(() => {
cleanup();
});
const story1: Geschichte = {
const story1: GeschichteSummary = {
id: 'g1',
title: 'Die Familie Müller',
body: '<p>Dies ist eine sehr lange Geschichte über die Familie Müller. Sie lebten in Bayern und hatten viele Kinder. Das war früher so üblich in diesen Gebieten.</p>',
status: 'PUBLISHED',
createdAt: '2025-01-01T00:00:00Z',
type: 'STORY',
updatedAt: '2025-01-01T00:00:00Z',
publishedAt: '2025-01-01T00:00:00Z'
};
const longBodyStory: Geschichte = {
const longBodyStory: GeschichteSummary = {
id: 'g2',
title: 'Sehr lange Geschichte',
body: '<p>' + 'A'.repeat(200) + '</p>',
status: 'PUBLISHED',
type: 'STORY',
createdAt: '2025-02-01T00:00:00Z',
updatedAt: '2025-02-01T00:00:00Z',
publishedAt: '2025-02-01T00:00:00Z'

View File

@@ -46,6 +46,14 @@ export type ErrorCode =
| 'CIRCULAR_RELATIONSHIP'
| 'DUPLICATE_RELATIONSHIP'
| 'GESCHICHTE_NOT_FOUND'
| 'JOURNEY_ITEM_NOT_FOUND'
| 'JOURNEY_ITEM_POSITION_CONFLICT'
| 'JOURNEY_AT_CAPACITY'
| 'JOURNEY_NOTE_TOO_LONG'
| 'JOURNEY_DOCUMENT_ALREADY_ADDED'
| 'GESCHICHTE_TYPE_IMMUTABLE'
| 'GESCHICHTE_TITLE_TOO_LONG'
| 'GESCHICHTE_INTRO_TOO_LONG'
| 'INVALID_CREDENTIALS'
| 'SESSION_EXPIRED'
| 'MISSING_CREDENTIALS'
@@ -164,6 +172,22 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_duplicate_relationship();
case 'GESCHICHTE_NOT_FOUND':
return m.error_geschichte_not_found();
case 'JOURNEY_ITEM_NOT_FOUND':
return m.error_journey_item_not_found();
case 'JOURNEY_ITEM_POSITION_CONFLICT':
return m.error_journey_item_position_conflict();
case 'JOURNEY_AT_CAPACITY':
return m.error_journey_at_capacity();
case 'JOURNEY_NOTE_TOO_LONG':
return m.error_journey_note_too_long();
case 'JOURNEY_DOCUMENT_ALREADY_ADDED':
return m.error_journey_document_already_added();
case 'GESCHICHTE_TYPE_IMMUTABLE':
return m.error_geschichte_type_immutable();
case 'GESCHICHTE_TITLE_TOO_LONG':
return m.error_geschichte_title_too_long();
case 'GESCHICHTE_INTRO_TOO_LONG':
return m.error_geschichte_intro_too_long();
case 'INVALID_CREDENTIALS':
return m.error_invalid_credentials();
case 'SESSION_EXPIRED':

View File

@@ -0,0 +1,197 @@
import { describe, it, expect, vi, expectTypeOf } from 'vitest';
import { createBlockDragDrop } from './useBlockDragDrop.svelte';
import type { TranscriptionBlockData } from '$lib/shared/types';
// ---------------------------------------------------------------------------
// Type-regression guard: createBlockDragDrop must accept any T extends {id: string}
// so JourneyEditor can reuse it without importing TranscriptionBlockData.
// This test fails with "Expected 0 type arguments, but got 1" via tsc --noEmit
// until the function is made generic.
// ---------------------------------------------------------------------------
describe('createBlockDragDrop — generic type guard', () => {
it('accepts items shaped as { id: string; position: number } — not only TranscriptionBlockData', () => {
type SimpleItem = { id: string; position: number };
const items: SimpleItem[] = [
{ id: 'item-1', position: 0 },
{ id: 'item-2', position: 1 }
];
const onReorder = vi.fn();
const dd = createBlockDragDrop<SimpleItem>({ getSortedBlocks: () => items, onReorder });
// Verify the hook is functional with the new type — state reads must work
expect(dd.draggedBlockId).toBeNull();
expect(dd.dragOffsetY).toBe(0);
});
it('TranscriptionBlockData caller still compiles — regression guard for existing transcription editor', () => {
// If the generic constraint is wrong this line fails tsc --noEmit
expectTypeOf(createBlockDragDrop<TranscriptionBlockData>).toBeFunction();
// Runtime assertion so browser-mode doesn't report "no assertions"
expect(typeof createBlockDragDrop).toBe('function');
});
});
function makeBlock(id: string, sortOrder: number): TranscriptionBlockData {
return {
id,
annotationId: `ann-${id}`,
documentId: 'doc-1',
text: '',
label: null,
sortOrder,
version: 1,
source: 'MANUAL',
reviewed: false,
mentionedPersons: []
};
}
/**
* Builds a DOM list, mocks getBoundingClientRect (60px per wrapper),
* drags `dragId` and drops it so dropTargetIdx === targetIdx, then
* triggers handlePointerUp. Returns the onReorder spy.
*/
function simulateDragDrop(
dragId: string,
targetIdx: number,
blocks: TranscriptionBlockData[]
): ReturnType<typeof vi.fn> {
const onReorder = vi.fn();
const dd = createBlockDragDrop({ getSortedBlocks: () => blocks, onReorder });
// Build DOM
const listEl = document.createElement('div');
const wrappers = blocks.map(() => {
const grip = document.createElement('div');
grip.setAttribute('data-drag-handle', '');
const wrapper = document.createElement('div');
wrapper.setAttribute('data-block-wrapper', '');
wrapper.appendChild(grip);
listEl.appendChild(wrapper);
return { grip, wrapper };
});
document.body.appendChild(listEl);
dd.setListElement(listEl);
// Mock bounding rects: each wrapper is 60px tall starting at y=0
wrappers.forEach(({ wrapper }, i) => {
vi.spyOn(wrapper, 'getBoundingClientRect').mockReturnValue({
top: i * 60,
height: 60,
bottom: (i + 1) * 60,
left: 0,
right: 100,
width: 100,
x: 0,
y: i * 60,
toJSON: () => ({})
} as DOMRect);
});
const dragIdx = blocks.findIndex((b) => b.id === dragId);
const { grip, wrapper: dragWrapper } = wrappers[dragIdx];
dragWrapper.setPointerCapture = vi.fn();
// Start drag
const downEvent = new PointerEvent('pointerdown', { clientY: dragIdx * 60, cancelable: true });
Object.defineProperty(downEvent, 'target', { value: grip });
dd.handleGripDown(downEvent as PointerEvent, dragId);
// Move pointer to achieve the desired targetIdx
// midpoint of wrapper[i] = i*60 + 30
// clientY just before midpoint[i] → target = i
// clientY past last midpoint → target = wrappers.length
let clientY: number;
if (targetIdx <= 0) {
clientY = 5; // before first midpoint (30)
} else if (targetIdx >= wrappers.length) {
clientY = wrappers.length * 60 + 10; // past all midpoints
} else {
clientY = targetIdx * 60 + 5; // just past top of wrapper[targetIdx], before its midpoint
}
const moveEvent = new PointerEvent('pointermove', { clientY });
dd.handlePointerMove(moveEvent as PointerEvent);
dd.handlePointerUp();
document.body.removeChild(listEl);
return onReorder;
}
describe('createBlockDragDrop', () => {
it('initial state — no drag in progress', () => {
const dd = createBlockDragDrop({ getSortedBlocks: () => [], onReorder: vi.fn() });
expect(dd.draggedBlockId).toBeNull();
expect(dd.dropTargetIdx).toBeNull();
expect(dd.dragOffsetY).toBe(0);
});
it('handleGripDown sets draggedBlockId when grip is hit', () => {
const dd = createBlockDragDrop({ getSortedBlocks: () => [], onReorder: vi.fn() });
const grip = document.createElement('div');
grip.setAttribute('data-drag-handle', '');
const wrapper = document.createElement('div');
wrapper.setAttribute('data-block-wrapper', '');
wrapper.appendChild(grip);
document.body.appendChild(wrapper);
const e = new PointerEvent('pointerdown', { clientY: 100, cancelable: true, bubbles: true });
Object.defineProperty(e, 'target', { value: grip });
wrapper.setPointerCapture = vi.fn();
dd.handleGripDown(e as PointerEvent, 'block-1');
expect(dd.draggedBlockId).toBe('block-1');
document.body.removeChild(wrapper);
});
it('handlePointerUp without active drag is a no-op', () => {
const onReorder = vi.fn();
const dd = createBlockDragDrop({ getSortedBlocks: () => [], onReorder });
dd.handlePointerUp();
expect(onReorder).not.toHaveBeenCalled();
});
it('handlePointerUp with null dropTargetIdx does not call onReorder', () => {
const onReorder = vi.fn();
const blocks = [makeBlock('b1', 0), makeBlock('b2', 1)];
const dd = createBlockDragDrop({ getSortedBlocks: () => blocks, onReorder });
const grip = document.createElement('div');
grip.setAttribute('data-drag-handle', '');
const wrapper = document.createElement('div');
wrapper.setAttribute('data-block-wrapper', '');
wrapper.appendChild(grip);
document.body.appendChild(wrapper);
wrapper.setPointerCapture = vi.fn();
const downEvent = new PointerEvent('pointerdown', { clientY: 50, cancelable: true });
Object.defineProperty(downEvent, 'target', { value: grip });
dd.handleGripDown(downEvent as PointerEvent, 'b1');
// dropTargetIdx is still null (no pointer move happened)
dd.handlePointerUp();
expect(onReorder).not.toHaveBeenCalled();
expect(dd.draggedBlockId).toBeNull();
document.body.removeChild(wrapper);
});
it('reorder: moves block from index 0 to end', () => {
const blocks = [makeBlock('b1', 0), makeBlock('b2', 1), makeBlock('b3', 2)];
const onReorder = simulateDragDrop('b1', 3, blocks);
expect(onReorder).toHaveBeenCalledWith(['b2', 'b3', 'b1']);
});
it('reorder: moves block from end to index 0', () => {
const blocks = [makeBlock('b1', 0), makeBlock('b2', 1), makeBlock('b3', 2)];
const onReorder = simulateDragDrop('b3', 0, blocks);
expect(onReorder).toHaveBeenCalledWith(['b3', 'b1', 'b2']);
});
it('reorder: moves block down by one position (tests insertAt = dropTargetIdx - 1)', () => {
const blocks = [makeBlock('b1', 0), makeBlock('b2', 1), makeBlock('b3', 2)];
// dragId=b1 (idx=0), targetIdx=2 → insertAt = 2-1 = 1 → [b2, b1, b3]
const onReorder = simulateDragDrop('b1', 2, blocks);
expect(onReorder).toHaveBeenCalledWith(['b2', 'b1', 'b3']);
});
});

View File

@@ -0,0 +1,89 @@
type Options<T extends { id: string }> = {
getSortedBlocks: () => T[];
onReorder: (blockIds: string[]) => void;
};
export function createBlockDragDrop<T extends { id: string }>({
getSortedBlocks,
onReorder
}: Options<T>) {
let draggedBlockId = $state<string | null>(null);
let dropTargetIdx = $state<number | null>(null);
let dragOffsetY = $state(0);
// Internal mutable refs — not reactive
let dragStartY = 0;
let capturedEl: HTMLElement | null = null;
let listEl: HTMLElement | null = null;
function setListElement(el: HTMLElement | null): void {
listEl = el;
}
function handleGripDown(e: PointerEvent, blockId: string): void {
if (!(e.target as HTMLElement).closest('[data-drag-handle]')) return;
e.preventDefault();
draggedBlockId = blockId;
dragStartY = e.clientY;
dragOffsetY = 0;
capturedEl = (e.target as HTMLElement).closest('[data-block-wrapper]') as HTMLElement;
capturedEl?.setPointerCapture(e.pointerId);
}
function handlePointerMove(e: PointerEvent): void {
if (!draggedBlockId || !listEl) return;
dragOffsetY = e.clientY - dragStartY;
const sortedBlocks = getSortedBlocks();
const wrappers = Array.from(listEl.querySelectorAll('[data-block-wrapper]'));
const dragIdx = sortedBlocks.findIndex((b) => b.id === draggedBlockId);
let target: number | null = null;
for (let i = 0; i < wrappers.length; i++) {
const rect = wrappers[i].getBoundingClientRect();
if (e.clientY < rect.top + rect.height / 2) {
target = i;
break;
}
}
if (target === null) target = wrappers.length;
if (target === dragIdx || target === dragIdx + 1) target = null;
dropTargetIdx = target;
}
function handlePointerUp(): void {
if (!draggedBlockId) return;
if (dropTargetIdx !== null) {
const sorted = [...getSortedBlocks()];
const fromIdx = sorted.findIndex((b) => b.id === draggedBlockId);
if (fromIdx >= 0) {
const [moved] = sorted.splice(fromIdx, 1);
const insertAt = dropTargetIdx > fromIdx ? dropTargetIdx - 1 : dropTargetIdx;
sorted.splice(insertAt, 0, moved);
onReorder(sorted.map((b) => b.id));
}
}
draggedBlockId = null;
dropTargetIdx = null;
dragOffsetY = 0;
capturedEl = null;
}
return {
get draggedBlockId() {
return draggedBlockId;
},
get dropTargetIdx() {
return dropTargetIdx;
},
get dragOffsetY() {
return dragOffsetY;
},
setListElement,
handleGripDown,
handlePointerMove,
handlePointerUp
};
}

View File

@@ -78,6 +78,56 @@ describe('createTypeahead', () => {
errorSpy.mockRestore();
});
it('fetch error sets the error flag so callers can render a failure state', async () => {
const fetchUrl = vi.fn().mockRejectedValue(new Error('500'));
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const ta = createTypeahead({ fetchUrl, debounceMs: 0 });
expect(ta.error).toBe(false);
ta.setQuery('foo');
await vi.advanceTimersByTimeAsync(0);
expect(ta.error).toBe(true);
errorSpy.mockRestore();
});
it('error flag clears on the next successful fetch', async () => {
const fetchUrl = vi
.fn()
.mockRejectedValueOnce(new Error('500'))
.mockResolvedValueOnce([{ id: '1' }]);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const ta = createTypeahead({ fetchUrl, debounceMs: 0 });
ta.setQuery('foo');
await vi.advanceTimersByTimeAsync(0);
expect(ta.error).toBe(true);
ta.setQuery('foob');
await vi.advanceTimersByTimeAsync(0);
expect(ta.error).toBe(false);
expect(ta.results).toEqual([{ id: '1' }]);
errorSpy.mockRestore();
});
it('sets loading immediately on setQuery so empty results read as pending, not "no results"', async () => {
const fetchUrl = vi.fn().mockResolvedValue([]);
const ta = createTypeahead({ fetchUrl, debounceMs: 300 });
ta.setQuery('foo');
// During the debounce window no fetch has run yet — callers must be able to
// distinguish "still searching" from "searched, zero hits".
expect(ta.loading).toBe(true);
await vi.advanceTimersByTimeAsync(300);
expect(ta.loading).toBe(false);
});
it('close() cancels the pending debounce — no stale fetch fires, loading resets', async () => {
const fetchUrl = vi.fn().mockResolvedValue([{ id: '1' }]);
const ta = createTypeahead({ fetchUrl, debounceMs: 300 });
ta.setQuery('foo');
expect(ta.loading).toBe(true);
ta.close();
expect(ta.loading).toBe(false);
await vi.advanceTimersByTimeAsync(300);
expect(fetchUrl).not.toHaveBeenCalled();
});
it('setActiveIndex updates activeIndex', () => {
const ta = createTypeahead({ fetchUrl: vi.fn().mockResolvedValue([]) });
expect(ta.activeIndex).toBe(-1);

View File

@@ -11,6 +11,7 @@ export function createTypeahead<T>(options: Options<T>) {
let results: T[] = $state([]);
let isOpen = $state(false);
let loading = $state(false);
let error = $state(false);
let activeIndex = $state(-1);
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
@@ -18,14 +19,18 @@ export function createTypeahead<T>(options: Options<T>) {
function setQuery(q: string) {
query = q;
isOpen = true;
// Set loading before the debounce fires so callers can distinguish
// "still searching" from "searched, zero hits" during the debounce window.
loading = true;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
loading = true;
error = false;
try {
results = await fetchUrl(q);
} catch (e) {
console.error('typeahead fetch error', e);
results = [];
error = true;
} finally {
loading = false;
}
@@ -35,6 +40,10 @@ export function createTypeahead<T>(options: Options<T>) {
function close() {
isOpen = false;
activeIndex = -1;
// Cancel the pending debounce — an abandoned query must not fire a stale
// fetch after the dropdown closed, nor leave loading stuck on true.
clearTimeout(debounceTimer);
loading = false;
}
function setActiveIndex(idx: number) {
@@ -65,6 +74,9 @@ export function createTypeahead<T>(options: Options<T>) {
get loading() {
return loading;
},
get error() {
return error;
},
get activeIndex() {
return activeIndex;
},

View File

@@ -48,6 +48,18 @@ describe('extractText', () => {
});
});
// SSR regex-fallback XSS gate — must stay in the Node (.test.ts / .spec.ts) project.
// The browser project's DOMParser would silently take the safe branch → false green.
// This test fires the regex fallback specifically (Node has no DOMParser).
describe('plainExcerpt — SSR regex-fallback XSS gate (Node tier)', () => {
it('does not emit onerror= in output when given an <img onerror> payload (security regression)', () => {
// plainExcerpt calls extractText which regex-strips tags in Node (no DOMParser).
// SvelteKit SSR auto-escapes the result, so onerror= in output is the first-paint risk.
const out = plainExcerpt('<img src=x onerror="window.__xss=1">');
expect(out).not.toContain('onerror=');
});
});
describe('plainExcerpt', () => {
it('returns full text when under the limit', () => {
expect(plainExcerpt('<p>short</p>', 80)).toBe('short');

View File

@@ -1,13 +1,18 @@
/**
* **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.
* **Not a sanitizer.** This module extracts visible text from an HTML (or
* plain-text) string for excerpt rendering. The safety invariant is: the
* OUTPUT must only ever be rendered via Svelte text interpolation — never
* `{@html}`. The DOMParser document is inert (scripts don't execute), but
* the returned string is whatever text the input carried.
*
* Note on inputs: STORY bodies are additionally sanitised against the OWASP
* allow-list on the server; JOURNEY intros are stored VERBATIM (unsanitised
* by design — see GeschichteService.bodyForType) and arrive here untrusted.
*
* 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.
* untrusted input does not protect against `javascript:` URLs,
* event-handler attributes, or `<svg/onload>` payloads.
*/
/**