Files
familienarchiv/frontend/src/lib/shared/discussion/mentionSerializer.ts
Marcel 567612761d refactor: move lib-root files to lib/shared/ and finalize domain structure
- Move api.server.ts, errors.ts, types.ts, utils.ts, relativeTime.ts to lib/shared/
- Move person relationship components to lib/person/relationship/
- Move Stammbaum components to lib/person/genealogy/
- Move HelpPopover to lib/shared/primitives/
- Update all import paths across routes, specs, and lib files
- Update vi.mock() paths in server-project test files
- Remove now-empty legacy directories (components/, hooks/, server/, etc.)
- Update vite.config.ts coverage include paths for new structure
- Update frontend/CLAUDE.md to reflect domain-based lib/ layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 14:53:31 +02:00

114 lines
3.3 KiB
TypeScript

import type { JSONContent } from '@tiptap/core';
import type { PersonMention } from '$lib/shared/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 };
}