diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 5d17e947..f1b189ce 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -420,6 +420,12 @@ "notification_unread": "ungelesen", "mention_btn_label": "Person erwähnen", "mention_popup_empty": "Keine Nutzer gefunden", + "person_mention_open_link": "Zur Person", + "person_mention_hover_hint": "Klick öffnet Seite", + "person_mention_load_error": "Person konnte nicht geladen werden.", + "person_mention_popup_empty": "Keine Personen gefunden", + "person_mention_btn_label": "Person verlinken", + "person_mention_create_new": "Neue Person anlegen", "page_title_home": "Archiv", "page_title_persons": "Personen", "page_title_admin": "Administration", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 604aa617..02111802 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -420,6 +420,12 @@ "notification_unread": "unread", "mention_btn_label": "Mention person", "mention_popup_empty": "No users found", + "person_mention_open_link": "Open person", + "person_mention_hover_hint": "Click opens the page", + "person_mention_load_error": "Could not load person.", + "person_mention_popup_empty": "No persons found", + "person_mention_btn_label": "Link person", + "person_mention_create_new": "Create new person", "page_title_home": "Archive", "page_title_persons": "Persons", "page_title_admin": "Administration", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 9e769d44..5d416017 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -420,6 +420,12 @@ "notification_unread": "no leído", "mention_btn_label": "Mencionar persona", "mention_popup_empty": "No se encontraron usuarios", + "person_mention_open_link": "Ir a la persona", + "person_mention_hover_hint": "Clic abre la página", + "person_mention_load_error": "No se pudo cargar la persona.", + "person_mention_popup_empty": "No se encontraron personas", + "person_mention_btn_label": "Vincular persona", + "person_mention_create_new": "Crear nueva persona", "page_title_home": "Archivo", "page_title_persons": "Personas", "page_title_admin": "Administración", diff --git a/frontend/src/lib/components/PersonMentionEditor.svelte b/frontend/src/lib/components/PersonMentionEditor.svelte new file mode 100644 index 00000000..df686334 --- /dev/null +++ b/frontend/src/lib/components/PersonMentionEditor.svelte @@ -0,0 +1,263 @@ + + +
+ + + {#if popupOpen} +
+ {#if loading} +

{m.comp_typeahead_loading()}

+ {:else if results.length === 0} +
+

{m.person_mention_popup_empty()}

+ + {m.person_mention_create_new()} → + +
+ {:else} + {#each results as person, i (person.id)} +
{ + e.preventDefault(); + selectPerson(person); + }} + > + {person.displayName} + {#if formatLifeDateRange(person.birthYear, person.deathYear)} + + {formatLifeDateRange(person.birthYear, person.deathYear)} + + {/if} +
+ {/each} + {/if} +
+ {/if} +
diff --git a/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts new file mode 100644 index 00000000..ea8df7fe --- /dev/null +++ b/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts @@ -0,0 +1,385 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import PersonMentionEditorHost from './PersonMentionEditor.test-host.svelte'; +import type { components } from '$lib/generated/api'; + +type Person = components['schemas']['Person']; +type PersonMention = components['schemas']['PersonMention']; + +// Editor's internal search debounce is 200ms — drive it via fake timers +// so tests are deterministic and fast (Tester #5506 §1). +const DEBOUNCE_MS = 200; + +async function flushDebounce() { + await vi.advanceTimersByTimeAsync(DEBOUNCE_MS); + // Let the awaited fetch resolve and the resulting state assignments flush. + await vi.runAllTimersAsync(); +} + +async function tick() { + await vi.advanceTimersByTimeAsync(0); +} + +const AUGUSTE: Person = { + id: 'p-aug', + firstName: 'Auguste', + lastName: 'Raddatz', + displayName: 'Auguste Raddatz', + birthYear: 1882, + deathYear: 1944 +} as unknown as Person; + +const ANNA: Person = { + id: 'p-anna', + firstName: 'Anna', + lastName: 'Schmidt', + displayName: 'Anna Schmidt', + birthYear: 1860 +} as unknown as Person; + +function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) { + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) }); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +function mockFetchEmpty() { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) }); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +function mockFetchRejects() { + const fetchMock = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +function getTextarea(): HTMLTextAreaElement { + return document.querySelector('textarea')!; +} + +function clickOption(personId: string) { + const opt = document.querySelector( + `[role="option"][data-test-person-id="${personId}"]` + ) as HTMLElement; + opt.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); +} + +type Snapshot = { value: string; mentionedPersons: PersonMention[] }; + +function renderHost(initial: { value?: string; mentionedPersons?: PersonMention[] } = {}) { + let snapshot: Snapshot = { + value: initial.value ?? '', + mentionedPersons: initial.mentionedPersons ?? [] + }; + render(PersonMentionEditorHost, { + initialValue: initial.value ?? '', + initialMentions: initial.mentionedPersons ?? [], + onChange: (snap: Snapshot) => { + snapshot = snap; + } + }); + return { + get snapshot() { + return snapshot; + } + }; +} + +beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +// ─── Rendering ──────────────────────────────────────────────────────────────── + +describe('PersonMentionEditor — rendering', () => { + it('renders the textarea with placeholder', async () => { + render(PersonMentionEditorHost, { + initialValue: '', + initialMentions: [], + placeholder: 'Transkription…', + onChange: () => {} + }); + await expect.element(page.getByPlaceholder('Transkription…')).toBeInTheDocument(); + }); + + it('reflects bound initial value', async () => { + render(PersonMentionEditorHost, { + initialValue: 'Hallo Welt', + initialMentions: [], + onChange: () => {} + }); + await expect.element(page.getByRole('textbox')).toHaveValue('Hallo Welt'); + }); +}); + +// ─── Typeahead opens on @ ───────────────────────────────────────────────────── + +describe('PersonMentionEditor — typeahead', () => { + it('opens the popup when typing @ + query and shows results', async () => { + mockFetchWithPersons(); + renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@Aug'; + ta.selectionStart = 4; + ta.selectionEnd = 4; + ta.dispatchEvent(new Event('input', { bubbles: true })); + + await flushDebounce(); + + await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument(); + }); + + it('hits /api/persons?q= with the typed query', async () => { + const fetchMock = mockFetchWithPersons(); + renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@Aug'; + ta.selectionStart = 4; + ta.selectionEnd = 4; + ta.dispatchEvent(new Event('input', { bubbles: true })); + + await flushDebounce(); + + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug')); + }); + + it('shows life dates next to the name in the dropdown', async () => { + mockFetchWithPersons(); + renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@Aug'; + ta.selectionStart = 4; + ta.selectionEnd = 4; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await flushDebounce(); + + await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument(); + }); + + it('shows empty state when no persons match', async () => { + mockFetchEmpty(); + renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@xyz'; + ta.selectionStart = 4; + ta.selectionEnd = 4; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await flushDebounce(); + + await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument(); + }); + + it('falls back to the empty state when the typeahead fetch rejects (network error)', async () => { + mockFetchRejects(); + renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@Aug'; + ta.selectionStart = 4; + ta.selectionEnd = 4; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await flushDebounce(); + + await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument(); + }); + + it('keeps the popup open when the query has a trailing space (multi-word names)', async () => { + mockFetchWithPersons(); + renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@Auguste '; + ta.selectionStart = 9; + ta.selectionEnd = 9; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await flushDebounce(); + + await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument(); + }); +}); + +// ─── Selection writes text + sidecar ───────────────────────────────────────── + +describe('PersonMentionEditor — selecting a person', () => { + it('inserts @DisplayName followed by a trailing space into the textarea', async () => { + mockFetchWithPersons(); + const host = renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@Aug'; + ta.selectionStart = 4; + ta.selectionEnd = 4; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await flushDebounce(); + + clickOption('p-aug'); + await tick(); + + expect(host.snapshot.value).toBe('@Auguste Raddatz '); + }); + + it('pushes {personId, displayName} into the bound mentionedPersons array', async () => { + mockFetchWithPersons(); + const host = renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@Aug'; + ta.selectionStart = 4; + ta.selectionEnd = 4; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await flushDebounce(); + + clickOption('p-aug'); + await tick(); + + expect(host.snapshot.mentionedPersons).toEqual([ + { personId: 'p-aug', displayName: 'Auguste Raddatz' } + ]); + }); + + it('does not duplicate the sidecar entry when the same person is selected twice', async () => { + mockFetchWithPersons(); + const host = renderHost({ + value: '@Auguste Raddatz ', + mentionedPersons: [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }] + }); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@Auguste Raddatz @Aug'; + ta.selectionStart = ta.value.length; + ta.selectionEnd = ta.value.length; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await flushDebounce(); + + clickOption('p-aug'); + await tick(); + + expect(host.snapshot.mentionedPersons).toHaveLength(1); + }); +}); + +// ─── Keyboard navigation (B11b) ────────────────────────────────────────────── + +describe('PersonMentionEditor — keyboard navigation (B11b)', () => { + it('ArrowDown / ArrowUp cycle the highlighted result', async () => { + mockFetchWithPersons(); + renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@A'; + ta.selectionStart = 2; + ta.selectionEnd = 2; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await flushDebounce(); + + const optAuguste = document.querySelector( + '[role="option"][data-test-person-id="p-aug"]' + ) as HTMLElement; + const optAnna = document.querySelector( + '[role="option"][data-test-person-id="p-anna"]' + ) as HTMLElement; + + expect(optAuguste.getAttribute('aria-selected')).toBe('true'); + expect(optAnna.getAttribute('aria-selected')).toBe('false'); + + ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + await tick(); + + expect(optAuguste.getAttribute('aria-selected')).toBe('false'); + expect(optAnna.getAttribute('aria-selected')).toBe('true'); + + ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); + await tick(); + + expect(optAuguste.getAttribute('aria-selected')).toBe('true'); + expect(optAnna.getAttribute('aria-selected')).toBe('false'); + }); + + it('Enter selects the currently highlighted result', async () => { + mockFetchWithPersons(); + const host = renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@A'; + ta.selectionStart = 2; + ta.selectionEnd = 2; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await flushDebounce(); + + ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + await tick(); + ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await tick(); + + expect(host.snapshot.mentionedPersons).toEqual([ + { personId: 'p-anna', displayName: 'Anna Schmidt' } + ]); + }); + + it('Escape closes the popup without inserting anything', async () => { + mockFetchWithPersons(); + const host = renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@Aug'; + ta.selectionStart = 4; + ta.selectionEnd = 4; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await flushDebounce(); + + ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await tick(); + + await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument(); + expect(host.snapshot.value).toBe('@Aug'); + expect(host.snapshot.mentionedPersons).toEqual([]); + }); +}); + +// ─── Touch target (WCAG 2.2 AA) ────────────────────────────────────────────── + +describe('PersonMentionEditor — touch target', () => { + it('each result row has min-h-[44px] (WCAG 2.2 AA)', async () => { + mockFetchWithPersons(); + renderHost(); + + const ta = getTextarea(); + ta.focus(); + ta.value = '@Aug'; + ta.selectionStart = 4; + ta.selectionEnd = 4; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await flushDebounce(); + + const option = document.querySelector('[role="option"]') as HTMLElement; + expect(option).not.toBeNull(); + expect(option.className).toContain('min-h-[44px]'); + }); +}); diff --git a/frontend/src/lib/components/PersonMentionEditor.test-host.svelte b/frontend/src/lib/components/PersonMentionEditor.test-host.svelte new file mode 100644 index 00000000..e608d694 --- /dev/null +++ b/frontend/src/lib/components/PersonMentionEditor.test-host.svelte @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte b/frontend/src/lib/components/TranscriptionBlock.svelte index 74598c7e..982b43ff 100644 --- a/frontend/src/lib/components/TranscriptionBlock.svelte +++ b/frontend/src/lib/components/TranscriptionBlock.svelte @@ -2,6 +2,8 @@ import { m } from '$lib/paraglide/messages.js'; import { getConfirmService } from '$lib/services/confirm.svelte.js'; import CommentThread from './CommentThread.svelte'; +import PersonMentionEditor from './PersonMentionEditor.svelte'; +import type { PersonMention } from '$lib/types'; const { confirm } = getConfirmService(); @@ -12,13 +14,14 @@ type Props = { documentId: string; blockNumber: number; text: string; + mentionedPersons: PersonMention[]; label: string | null; active: boolean; reviewed: boolean; saveState: SaveState; canComment: boolean; currentUserId: string | null; - onTextChange: (text: string) => void; + onTextChange: (text: string, mentionedPersons: PersonMention[]) => void; onFocus: () => void; onDeleteClick: () => void; onRetry: () => void; @@ -35,6 +38,7 @@ let { documentId, blockNumber, text, + mentionedPersons, label = null, active, reviewed, @@ -54,10 +58,10 @@ let { }: Props = $props(); let localText = $state(text); +let localMentions = $state([...mentionedPersons]); let commentOpen = $state(false); let commentCount = $state(0); let selectedQuote = $state(null); -let textareaEl = $state(null); const hasComments = $derived(commentCount > 0); @@ -66,6 +70,7 @@ let prevBlockId = $state(blockId); $effect(() => { if (blockId !== prevBlockId) { localText = text; + localMentions = [...mentionedPersons]; prevBlockId = blockId; } }); @@ -74,29 +79,19 @@ let leftBorderClass = $derived( saveState === 'error' ? 'border-l-2 border-error' : active ? 'border-l-2 border-turquoise' : '' ); -function autoresize(node: HTMLTextAreaElement) { +// Single source of truth for the editor's textarea — stored on attach so +// we can read selection bounds for quote selection without re-querying the DOM. +let textareaEl: HTMLTextAreaElement | null = null; + +function captureTextarea(node: HTMLTextAreaElement) { textareaEl = node; - function resize() { - node.style.height = 'auto'; - node.style.height = `${node.scrollHeight}px`; - } - - resize(); - - return { - update() { - resize(); - }, - destroy() { - textareaEl = null; - } + return () => { + textareaEl = null; }; } -function handleInput(event: Event) { - const target = event.target as HTMLTextAreaElement; - localText = target.value; - onTextChange(target.value); +function emitChange() { + onTextChange(localText, localMentions); } async function handleDelete() { @@ -181,17 +176,24 @@ function handleTextareaMouseUp() { {/if} - - + +
+ localText, + (v) => { + localText = v; + emitChange(); + }} + bind:mentionedPersons={() => localMentions, + (next) => { + localMentions = next; + emitChange(); + }} + placeholder={m.transcription_block_placeholder()} + onfocus={onFocus} + captureTextarea={captureTextarea} + /> +
{#if selectedQuote}

{m.transcription_block_quote_hint()}

diff --git a/frontend/src/lib/components/TranscriptionBlock.test-host.svelte b/frontend/src/lib/components/TranscriptionBlock.test-host.svelte index 9a03b4bf..d2e2055d 100644 --- a/frontend/src/lib/components/TranscriptionBlock.test-host.svelte +++ b/frontend/src/lib/components/TranscriptionBlock.test-host.svelte @@ -1,21 +1,24 @@ - + diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte b/frontend/src/lib/components/TranscriptionEditView.svelte index eae2c825..64e97fef 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte +++ b/frontend/src/lib/components/TranscriptionEditView.svelte @@ -3,7 +3,7 @@ import { m } from '$lib/paraglide/messages.js'; import TranscriptionBlock from './TranscriptionBlock.svelte'; import OcrTrigger from './OcrTrigger.svelte'; import TranscribeCoachEmptyState from './TranscribeCoachEmptyState.svelte'; -import type { TranscriptionBlockData } from '$lib/types'; +import type { PersonMention, TranscriptionBlockData } from '$lib/types'; import { createBlockAutoSave } from '$lib/hooks/useBlockAutoSave.svelte'; import { createBlockDragDrop } from '$lib/hooks/useBlockDragDrop.svelte'; @@ -16,7 +16,7 @@ type Props = { storedScriptType?: string; canRunOcr?: boolean; onBlockFocus: (blockId: string) => void; - onSaveBlock: (blockId: string, text: string) => Promise; + onSaveBlock: (blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise; onDeleteBlock: (blockId: string) => Promise; onReviewToggle: (blockId: string) => Promise; onMarkAllReviewed?: () => Promise; @@ -245,16 +245,19 @@ async function handleLabelToggle(label: string) { documentId={documentId} blockNumber={i + 1} text={block.text} + mentionedPersons={block.mentionedPersons ?? []} label={block.label} active={activeBlockId === block.id} reviewed={block.reviewed ?? false} saveState={autoSave.getSaveState(block.id)} canComment={canComment} currentUserId={currentUserId} - onTextChange={(text) => autoSave.handleTextChange(block.id, text)} + onTextChange={(text, mentions) => + autoSave.handleTextChange(block.id, text, mentions)} onFocus={() => handleFocus(block.id)} onDeleteClick={() => handleDelete(block.id)} - onRetry={() => autoSave.handleRetry(block.id, block.text)} + onRetry={() => + autoSave.handleRetry(block.id, block.text, block.mentionedPersons ?? [])} onReviewToggle={() => onReviewToggle(block.id)} onMoveUp={() => handleMoveUp(block.id)} onMoveDown={() => handleMoveDown(block.id)} diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts b/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts index beeea901..39bf098c 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts +++ b/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts @@ -15,7 +15,8 @@ const block1 = { sortOrder: 0, version: 0, source: 'MANUAL' as const, - reviewed: false + reviewed: false, + mentionedPersons: [] }; const block2 = { id: 'b2', @@ -26,7 +27,8 @@ const block2 = { sortOrder: 1, version: 0, source: 'OCR' as const, - reviewed: true + reviewed: true, + mentionedPersons: [] }; function renderView(overrides: Record = {}, service = createConfirmService()) { @@ -141,7 +143,28 @@ describe('TranscriptionEditView — auto-save debounce', () => { vi.advanceTimersByTime(1500); await vi.runAllTimersAsync(); - expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Neue Zeile'); + expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Neue Zeile', []); + vi.useRealTimers(); + }); + + it('passes the block mentionedPersons array as the 3rd save argument', async () => { + vi.useFakeTimers(); + const onSaveBlock = vi.fn().mockResolvedValue(undefined); + const blockWithMention = { + ...block1, + mentionedPersons: [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }] + }; + renderView({ blocks: [blockWithMention], onSaveBlock }); + + const textarea = page.getByRole('textbox').first(); + await textarea.fill('Hallo @Auguste Raddatz'); + + vi.advanceTimersByTime(1500); + await vi.runAllTimersAsync(); + + expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Hallo @Auguste Raddatz', [ + { personId: 'p-aug', displayName: 'Auguste Raddatz' } + ]); vi.useRealTimers(); }); @@ -165,7 +188,7 @@ describe('TranscriptionEditView — auto-save debounce', () => { // Only one save with the final value expect(onSaveBlock).toHaveBeenCalledTimes(1); - expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Second'); + expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Second', []); vi.useRealTimers(); }); }); @@ -220,7 +243,7 @@ describe('TranscriptionEditView — flush on blur', () => { el.dispatchEvent(new FocusEvent('blur', { bubbles: true })); await vi.runAllTimersAsync(); - expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Blur text'); + expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Blur text', []); vi.useRealTimers(); }); }); diff --git a/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts b/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts index 70823be2..f49287cc 100644 --- a/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts +++ b/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts @@ -12,7 +12,10 @@ const blocks: TranscriptionBlockData[] = [ text: 'First paragraph text.', label: null, sortOrder: 1, - version: 1 + version: 1, + source: 'MANUAL', + reviewed: false, + mentionedPersons: [] }, { id: 'b2', @@ -21,7 +24,10 @@ const blocks: TranscriptionBlockData[] = [ text: 'Second paragraph text.', label: null, sortOrder: 2, - version: 1 + version: 1, + source: 'MANUAL', + reviewed: false, + mentionedPersons: [] } ]; @@ -49,7 +55,10 @@ describe('TranscriptionReadView', () => { text: 'Text before [unleserlich] text after', label: null, sortOrder: 1, - version: 1 + version: 1, + source: 'MANUAL', + reviewed: false, + mentionedPersons: [] } ], onParagraphClick: () => {} @@ -71,7 +80,10 @@ describe('TranscriptionReadView', () => { text: 'Some [...] text', label: null, sortOrder: 1, - version: 1 + version: 1, + source: 'MANUAL', + reviewed: false, + mentionedPersons: [] } ], onParagraphClick: () => {} diff --git a/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts b/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts index 739fb432..f6a87a8f 100644 --- a/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts +++ b/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; +import type { PersonMention } from '$lib/types'; -const mockSaveFn = vi.fn<(blockId: string, text: string) => Promise>(); +const mockSaveFn = + vi.fn<(blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise>(); + +const NO_MENTIONS: PersonMention[] = []; const { createBlockAutoSave } = await import('../useBlockAutoSave.svelte'); @@ -22,25 +26,25 @@ describe('createBlockAutoSave', () => { it('debounce coalesces multiple changes — saves once after 1500ms', async () => { const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'text 1'); - as.handleTextChange('block-1', 'text 2'); - as.handleTextChange('block-1', 'text 3'); + as.handleTextChange('block-1', 'text 1', NO_MENTIONS); + as.handleTextChange('block-1', 'text 2', NO_MENTIONS); + as.handleTextChange('block-1', 'text 3', NO_MENTIONS); await vi.advanceTimersByTimeAsync(1500); expect(mockSaveFn).toHaveBeenCalledTimes(1); - expect(mockSaveFn).toHaveBeenCalledWith('block-1', 'text 3'); + expect(mockSaveFn).toHaveBeenCalledWith('block-1', 'text 3', NO_MENTIONS); }); it('handles concurrent blocks independently', async () => { const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'hello'); - as.handleTextChange('block-2', 'world'); + as.handleTextChange('block-1', 'hello', NO_MENTIONS); + as.handleTextChange('block-2', 'world', NO_MENTIONS); await vi.advanceTimersByTimeAsync(1500); expect(mockSaveFn).toHaveBeenCalledTimes(2); }); it('sets save state to saving then saved on success', async () => { const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'text'); + as.handleTextChange('block-1', 'text', NO_MENTIONS); vi.advanceTimersByTime(1500); expect(as.getSaveState('block-1')).toBe('saving'); await Promise.resolve(); @@ -50,7 +54,7 @@ describe('createBlockAutoSave', () => { it('sets save state to error on save failure', async () => { mockSaveFn.mockRejectedValue(new Error('save failed')); const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'text'); + as.handleTextChange('block-1', 'text', NO_MENTIONS); await vi.advanceTimersByTimeAsync(1500); expect(as.getSaveState('block-1')).toBe('error'); }); @@ -59,24 +63,49 @@ describe('createBlockAutoSave', () => { mockSaveFn.mockRejectedValueOnce(new Error('first fails')); mockSaveFn.mockResolvedValueOnce(undefined); const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'original'); + as.handleTextChange('block-1', 'original', NO_MENTIONS); await vi.advanceTimersByTimeAsync(1500); expect(as.getSaveState('block-1')).toBe('error'); - await as.handleRetry('block-1', 'original'); + await as.handleRetry('block-1', 'original', NO_MENTIONS); expect(mockSaveFn).toHaveBeenCalledTimes(2); expect(as.getSaveState('block-1')).toBe('saved'); }); + it('preserves the in-flight text + mentionedPersons across a save failure (B12)', async () => { + // Hold the second saveFn so we can observe the saving→saved transition + // (Tester #5506 §5). + let resolveSecond!: () => void; + mockSaveFn.mockRejectedValueOnce(new Error('boom')); + mockSaveFn.mockReturnValueOnce(new Promise((r) => (resolveSecond = r))); + const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); + + const mentions: PersonMention[] = [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }]; + as.handleTextChange('block-1', '@Auguste Raddatz hi', mentions); + await vi.advanceTimersByTimeAsync(1500); + expect(as.getSaveState('block-1')).toBe('error'); + + // Retry without re-passing the data — the hook resends the preserved payload. + const retryPromise = as.handleRetry('block-1', 'should-not-be-used', []); + // Yield once so executeSave runs synchronously up to the saveFn await. + await Promise.resolve(); + expect(as.getSaveState('block-1')).toBe('saving'); + expect(mockSaveFn).toHaveBeenLastCalledWith('block-1', '@Auguste Raddatz hi', mentions); + + resolveSecond(); + await retryPromise; + expect(as.getSaveState('block-1')).toBe('saved'); + }); + it('clearBlock removes all state for a block', () => { const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'text'); + as.handleTextChange('block-1', 'text', NO_MENTIONS); as.clearBlock('block-1'); expect(as.getSaveState('block-1')).toBe('idle'); }); it('destroy clears all pending timers so no save occurs', async () => { const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'text'); + as.handleTextChange('block-1', 'text', NO_MENTIONS); as.destroy(); await vi.advanceTimersByTimeAsync(2000); expect(mockSaveFn).not.toHaveBeenCalled(); @@ -101,8 +130,8 @@ describe('flushOnUnload', () => { it('sends a PUT request with keepalive:true for each pending block', () => { const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'hello'); - as.handleTextChange('block-2', 'world'); + as.handleTextChange('block-1', 'hello', NO_MENTIONS); + as.handleTextChange('block-2', 'world', NO_MENTIONS); as.flushOnUnload(); expect(mockFetch).toHaveBeenCalledTimes(2); @@ -111,7 +140,7 @@ describe('flushOnUnload', () => { expect.objectContaining({ method: 'PUT', keepalive: true, - body: JSON.stringify({ text: 'hello' }) + body: JSON.stringify({ text: 'hello', mentionedPersons: [] }) }) ); expect(mockFetch).toHaveBeenCalledWith( @@ -119,7 +148,7 @@ describe('flushOnUnload', () => { expect.objectContaining({ method: 'PUT', keepalive: true, - body: JSON.stringify({ text: 'world' }) + body: JSON.stringify({ text: 'world', mentionedPersons: [] }) }) ); }); @@ -127,7 +156,7 @@ describe('flushOnUnload', () => { it('does not call navigator.sendBeacon', () => { const sendBeaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'text'); + as.handleTextChange('block-1', 'text', NO_MENTIONS); as.flushOnUnload(); expect(sendBeaconSpy).not.toHaveBeenCalled(); @@ -142,7 +171,7 @@ describe('flushOnUnload', () => { it('cancels the debounce timer so saveFn is not also called', async () => { const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'text'); + as.handleTextChange('block-1', 'text', NO_MENTIONS); as.flushOnUnload(); await vi.advanceTimersByTimeAsync(2000); @@ -151,13 +180,26 @@ describe('flushOnUnload', () => { it('does not send fetch if debounce already fired and pendingTexts is empty', async () => { const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); - as.handleTextChange('block-1', 'text'); + as.handleTextChange('block-1', 'text', NO_MENTIONS); await vi.advanceTimersByTimeAsync(1500); - // debounce has fired; pendingTexts should be empty now mockFetch.mockClear(); as.flushOnUnload(); expect(mockFetch).not.toHaveBeenCalled(); }); + + it('flushes the pending mentionedPersons sidecar alongside text', () => { + const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); + const mentions: PersonMention[] = [{ personId: 'p-1', displayName: 'Auguste Raddatz' }]; + as.handleTextChange('block-1', '@Auguste Raddatz', mentions); + as.flushOnUnload(); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/documents/doc-1/transcription-blocks/block-1', + expect.objectContaining({ + body: JSON.stringify({ text: '@Auguste Raddatz', mentionedPersons: mentions }) + }) + ); + }); }); diff --git a/frontend/src/lib/hooks/__tests__/useBlockDragDrop.svelte.test.ts b/frontend/src/lib/hooks/__tests__/useBlockDragDrop.svelte.test.ts index a0c541f3..3e4adaa2 100644 --- a/frontend/src/lib/hooks/__tests__/useBlockDragDrop.svelte.test.ts +++ b/frontend/src/lib/hooks/__tests__/useBlockDragDrop.svelte.test.ts @@ -12,7 +12,8 @@ function makeBlock(id: string, sortOrder: number): TranscriptionBlockData { sortOrder, version: 1, source: 'MANUAL', - reviewed: false + reviewed: false, + mentionedPersons: [] }; } diff --git a/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts b/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts index 07dc5692..21de5442 100644 --- a/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts +++ b/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts @@ -1,9 +1,10 @@ import { SvelteMap } from 'svelte/reactivity'; +import type { PersonMention } from '$lib/types'; export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error'; type Options = { - saveFn: (blockId: string, text: string) => Promise; + saveFn: (blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise; documentId: string; }; @@ -11,6 +12,7 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) { const saveStates = new SvelteMap(); const debounceTimers = new SvelteMap>(); const pendingTexts = new SvelteMap(); + const pendingMentions = new SvelteMap(); const fadeTimers: ReturnType[] = []; function getSaveState(blockId: string): SaveState { @@ -25,14 +27,19 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) { const text = pendingTexts.get(blockId); if (text === undefined) return; + const mentions = pendingMentions.get(blockId) ?? []; pendingTexts.delete(blockId); + pendingMentions.delete(blockId); setSaveState(blockId, 'saving'); try { - await saveFn(blockId, text); + await saveFn(blockId, text, mentions); setSaveState(blockId, 'saved'); scheduleSavedFade(blockId); } catch { + // Preserve in-flight payload so the user can retry without re-typing. + pendingTexts.set(blockId, text); + pendingMentions.set(blockId, mentions); setSaveState(blockId, 'error'); } } @@ -69,8 +76,13 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) { } } - function handleTextChange(blockId: string, text: string): void { + function handleTextChange( + blockId: string, + text: string, + mentionedPersons: PersonMention[] + ): void { pendingTexts.set(blockId, text); + pendingMentions.set(blockId, mentionedPersons); scheduleDebounce(blockId); } @@ -81,29 +93,37 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) { } } - async function handleRetry(blockId: string, currentText: string): Promise { - const pending = pendingTexts.get(blockId); - const text = pending ?? currentText; + async function handleRetry( + blockId: string, + currentText: string, + currentMentions: PersonMention[] + ): Promise { + const text = pendingTexts.get(blockId) ?? currentText; + const mentions = pendingMentions.get(blockId) ?? currentMentions; pendingTexts.set(blockId, text); + pendingMentions.set(blockId, mentions); await executeSave(blockId); } function clearBlock(blockId: string): void { clearDebounce(blockId); pendingTexts.delete(blockId); + pendingMentions.delete(blockId); saveStates.delete(blockId); } function flushOnUnload(): void { for (const [blockId, text] of pendingTexts) { + const mentions = pendingMentions.get(blockId) ?? []; clearDebounce(blockId); void fetch(`/api/documents/${documentId}/transcription-blocks/${blockId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text }), + body: JSON.stringify({ text, mentionedPersons: mentions }), keepalive: true }); pendingTexts.delete(blockId); + pendingMentions.delete(blockId); } } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 2458d35a..f9f810e7 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -37,6 +37,11 @@ export type Comment = { export type DocumentPanelTab = 'metadata' | 'transcription' | 'discussion' | 'history'; +export type PersonMention = { + personId: string; + displayName: string; +}; + export type TranscriptionBlockData = { id: string; annotationId: string; @@ -47,6 +52,7 @@ export type TranscriptionBlockData = { version: number; source: 'MANUAL' | 'OCR'; reviewed: boolean; + mentionedPersons: PersonMention[]; updatedAt?: string | null; }; diff --git a/frontend/src/lib/utils/blockConflictMerge.spec.ts b/frontend/src/lib/utils/blockConflictMerge.spec.ts new file mode 100644 index 00000000..e10b245d --- /dev/null +++ b/frontend/src/lib/utils/blockConflictMerge.spec.ts @@ -0,0 +1,128 @@ +import { describe, it, expect } from 'vitest'; +import { BlockConflictResolvedError, mergeBlockOnConflict } from './blockConflictMerge'; +import type { PersonMention, TranscriptionBlockData } from '$lib/types'; + +const baseBlock: TranscriptionBlockData = { + id: 'b1', + annotationId: 'a1', + documentId: 'd1', + text: 'old text from server', + label: null, + sortOrder: 0, + version: 7, + source: 'MANUAL', + reviewed: false, + mentionedPersons: [] +}; + +describe('mergeBlockOnConflict', () => { + it('keeps the local unsaved text — never overwritten by server text (B12b)', () => { + const merged = mergeBlockOnConflict({ + serverBlock: { ...baseBlock, text: 'server-side text' }, + localText: 'transcriber unsaved input', + localMentions: [] + }); + expect(merged.text).toBe('transcriber unsaved input'); + }); + + it('takes server-side displayName for personIds present on both sides (rename win)', () => { + const localMentions: PersonMention[] = [ + { personId: 'p-aug', displayName: 'Auguste Raddatz' } // stale: server renamed her + ]; + const serverMentions: PersonMention[] = [ + { personId: 'p-aug', displayName: 'Augusta Raddatz' } // post-rename + ]; + const merged = mergeBlockOnConflict({ + serverBlock: { ...baseBlock, mentionedPersons: serverMentions }, + localText: '@Augusta Raddatz', + localMentions + }); + expect(merged.mentionedPersons).toEqual([ + { personId: 'p-aug', displayName: 'Augusta Raddatz' } + ]); + }); + + it('keeps local-only mentions added since last save', () => { + const localMentions: PersonMention[] = [ + { personId: 'p-anna', displayName: 'Anna Schmidt' } // typed since last save + ]; + const merged = mergeBlockOnConflict({ + serverBlock: { ...baseBlock, mentionedPersons: [] }, + localText: '@Anna Schmidt', + localMentions + }); + expect(merged.mentionedPersons).toContainEqual({ + personId: 'p-anna', + displayName: 'Anna Schmidt' + }); + }); + + it('returns a union of personIds when local and server diverge', () => { + const localMentions: PersonMention[] = [{ personId: 'p-anna', displayName: 'Anna Schmidt' }]; + const serverMentions: PersonMention[] = [{ personId: 'p-aug', displayName: 'Augusta Raddatz' }]; + const merged = mergeBlockOnConflict({ + serverBlock: { ...baseBlock, mentionedPersons: serverMentions }, + localText: '@Augusta Raddatz und @Anna Schmidt', + localMentions + }); + expect(merged.mentionedPersons).toHaveLength(2); + expect(merged.mentionedPersons).toContainEqual({ + personId: 'p-aug', + displayName: 'Augusta Raddatz' + }); + expect(merged.mentionedPersons).toContainEqual({ + personId: 'p-anna', + displayName: 'Anna Schmidt' + }); + }); + + it('carries server version forward so the next save sends the latest revision', () => { + const merged = mergeBlockOnConflict({ + serverBlock: { ...baseBlock, version: 42 }, + localText: 'x', + localMentions: [] + }); + expect(merged.version).toBe(42); + }); + + it('carries server-only mention array through when local has none', () => { + const merged = mergeBlockOnConflict({ + serverBlock: { + ...baseBlock, + mentionedPersons: [ + { personId: 'p-aug', displayName: 'Augusta Raddatz' }, + { personId: 'p-anna', displayName: 'Anna Schmidt' } + ] + }, + localText: 'x', + localMentions: [] + }); + expect(merged.mentionedPersons).toHaveLength(2); + }); + + it('carries other server fields (sortOrder, reviewed, updatedAt) forward', () => { + const merged = mergeBlockOnConflict({ + serverBlock: { + ...baseBlock, + sortOrder: 9, + reviewed: true, + updatedAt: '2026-04-29T10:00:00Z' + }, + localText: 'x', + localMentions: [] + }); + expect(merged.sortOrder).toBe(9); + expect(merged.reviewed).toBe(true); + expect(merged.updatedAt).toBe('2026-04-29T10:00:00Z'); + }); +}); + +describe('BlockConflictResolvedError', () => { + it('is an Error with code = CONFLICT_RESOLVED', () => { + const err = new BlockConflictResolvedError('block-1'); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('CONFLICT_RESOLVED'); + expect(err.name).toBe('BlockConflictResolvedError'); + expect(err.message).toContain('block-1'); + }); +}); diff --git a/frontend/src/lib/utils/blockConflictMerge.ts b/frontend/src/lib/utils/blockConflictMerge.ts new file mode 100644 index 00000000..7077d6c9 --- /dev/null +++ b/frontend/src/lib/utils/blockConflictMerge.ts @@ -0,0 +1,50 @@ +import type { PersonMention, TranscriptionBlockData } from '$lib/types'; + +/** + * Sentinel thrown by saveBlockWithConflictRetry after a 409 rename-mid-edit + * has been merged into local state. Surfaces to the autosave hook as an + * error (so the UI shows the retry indicator), but distinguishable from a + * genuine network failure via the code. Carries the merged block snapshot + * on its `merged` property so the caller can update local state without + * a second roundtrip. + */ +export class BlockConflictResolvedError extends Error { + readonly code = 'CONFLICT_RESOLVED' as const; + merged?: TranscriptionBlockData; + constructor(blockId: string) { + super( + `Block ${blockId} was rebased onto the latest server snapshot — retry to save the merged result` + ); + this.name = 'BlockConflictResolvedError'; + } +} + +type MergeArgs = { + serverBlock: TranscriptionBlockData; + localText: string; + localMentions: PersonMention[]; +}; + +/** + * Resolves a 409-Conflict from the server by combining the latest server + * snapshot with the transcriber's unsaved local edits (B12b). + * + * Rules: + * - The transcriber's typed text always wins — never overwrite their input. + * - Server is the source of truth for the displayName of any person it + * knows about; renames that just landed on the server replace stale local + * names by personId. + * - Local-only mentions added since the last save are preserved. + * - All non-mention fields (version, sortOrder, reviewed, updatedAt, ...) + * come from the server snapshot so the next save sends the current + * revision and matches the latest persisted state. + */ +export function mergeBlockOnConflict(args: MergeArgs): TranscriptionBlockData { + const serverIds = new Set(args.serverBlock.mentionedPersons.map((m) => m.personId)); + const localOnly = args.localMentions.filter((m) => !serverIds.has(m.personId)); + return { + ...args.serverBlock, + text: args.localText, + mentionedPersons: [...args.serverBlock.mentionedPersons, ...localOnly] + }; +} diff --git a/frontend/src/lib/utils/mention.spec.ts b/frontend/src/lib/utils/mention.spec.ts index 5a7982be..04b659b1 100644 --- a/frontend/src/lib/utils/mention.spec.ts +++ b/frontend/src/lib/utils/mention.spec.ts @@ -1,7 +1,42 @@ import { describe, it, expect } from 'vitest'; -import { detectMention, extractContent, renderBody } from './mention'; +import { detectMention, escapeHtml, extractContent, renderBody } from './mention'; import type { MentionDTO } 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('