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(); 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 }; }