diff --git a/frontend/src/lib/utils/blockConflictMerge.spec.ts b/frontend/src/lib/utils/blockConflictMerge.spec.ts new file mode 100644 index 00000000..e9bd1aad --- /dev/null +++ b/frontend/src/lib/utils/blockConflictMerge.spec.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest'; +import { 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 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'); + }); +}); diff --git a/frontend/src/lib/utils/blockConflictMerge.ts b/frontend/src/lib/utils/blockConflictMerge.ts new file mode 100644 index 00000000..af37f9a3 --- /dev/null +++ b/frontend/src/lib/utils/blockConflictMerge.ts @@ -0,0 +1,31 @@ +import type { PersonMention, TranscriptionBlockData } from '$lib/types'; + +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/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 1ea3e10d..b07bad86 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -98,6 +98,21 @@ async function saveBlock( headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, mentionedPersons }) }); + if (res.status === 409) { + // Rename-mid-edit (B12b): refetch latest, merge so transcriber input survives. + const { mergeBlockOnConflict } = await import('$lib/utils/blockConflictMerge'); + const fresh = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}`); + if (fresh.ok) { + const serverBlock = await fresh.json(); + const merged = mergeBlockOnConflict({ + serverBlock, + localText: text, + localMentions: mentionedPersons + }); + transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? merged : b)); + } + throw new Error('Conflict resolved — please save again'); + } if (!res.ok) throw new Error('Save failed'); const updated = await res.json(); transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? updated : b));