From 676775659269ab83878c6f65bc91f8c1dab968f5 Mon Sep 17 00:00:00 2001 From: ComputelessComputer <63365510+ComputelessComputer@users.noreply.github.com> Date: Tue, 19 May 2026 15:05:34 +0900 Subject: [PATCH] refactor(desktop): convert legacy sessions to markdown Add a compatibility converter from legacy session tables into the canonical session Markdown document shape. --- .../src/session/legacy-file-converter.test.ts | 158 ++++++++++++++ .../src/session/legacy-file-converter.ts | 201 ++++++++++++++++++ 2 files changed, 359 insertions(+) create mode 100644 apps/desktop/src/session/legacy-file-converter.test.ts create mode 100644 apps/desktop/src/session/legacy-file-converter.ts diff --git a/apps/desktop/src/session/legacy-file-converter.test.ts b/apps/desktop/src/session/legacy-file-converter.test.ts new file mode 100644 index 0000000000..df8c0b2087 --- /dev/null +++ b/apps/desktop/src/session/legacy-file-converter.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "vitest"; + +import { md2json } from "@hypr/editor/markdown"; + +import { legacySessionToMarkdownDocument } from "./legacy-file-converter"; + +import { createEmptyLoadedSessionData } from "~/store/tinybase/persister/session/load"; + +function richText(markdown: string): string { + return JSON.stringify(md2json(markdown)); +} + +describe("legacySessionToMarkdownDocument", () => { + test("converts legacy session tables into the canonical markdown document", () => { + const data = createEmptyLoadedSessionData(); + data.sessions["session-1"] = { + user_id: "user-1", + created_at: "2026-05-19T00:00:00.000Z", + folder_id: "work", + event_json: JSON.stringify({ + tracking_id_event: "event-1", + title: "Weekly sync", + }), + title: "Weekly sync", + raw_md: richText("Raw notes.\n\n- follow up"), + }; + data.mapping_session_participant["participant-1"] = { + user_id: "user-1", + session_id: "session-1", + human_id: "human-1", + source: "manual", + }; + data.tags["tag-1"] = { + user_id: "user-1", + name: "work", + }; + data.mapping_tag_session["mapping-1"] = { + user_id: "user-1", + session_id: "session-1", + tag_id: "tag-1", + }; + data.enhanced_notes["summary-2"] = { + user_id: "user-1", + session_id: "session-1", + content: richText("Second summary."), + template_id: "summary", + position: 2, + title: "Later", + }; + data.enhanced_notes["summary-1"] = { + user_id: "user-1", + session_id: "session-1", + content: richText("First summary."), + template_id: "summary", + position: 1, + title: "Earlier", + }; + data.transcripts["transcript-1"] = { + user_id: "user-1", + created_at: "2026-05-19T00:00:00.000Z", + session_id: "session-1", + started_at: 20, + ended_at: 30, + words: JSON.stringify([ + { + text: "Hello", + start_ms: 0, + end_ms: 100, + channel: 0, + speaker: "Jane", + }, + { + text: ".", + start_ms: 100, + end_ms: 120, + channel: 0, + speaker: "Jane", + }, + { + text: "Hi", + start_ms: 130, + end_ms: 200, + channel: 0, + speaker: "Sam", + }, + ]), + speaker_hints: "[]", + memo_md: "Transcript memo.", + }; + + expect(legacySessionToMarkdownDocument(data, "session-1")).toEqual({ + schemaVersion: 1, + id: "session-1", + createdAt: "2026-05-19T00:00:00.000Z", + title: "Weekly sync", + folderId: "work", + eventId: "event-1", + event: { + tracking_id_event: "event-1", + title: "Weekly sync", + }, + participants: [ + { + legacy_human_id: "human-1", + source: "manual", + }, + ], + tags: ["work"], + notes: "Raw notes.\n\n- follow up", + summary: "## Earlier\n\nFirst summary.\n\n## Later\n\nSecond summary.", + transcript: "Jane: Hello.\nSam: Hi\n\nTranscript memo.", + }); + }); + + test("returns null when the session does not exist", () => { + const data = createEmptyLoadedSessionData(); + + expect(legacySessionToMarkdownDocument(data, "missing")).toBeNull(); + }); + + test("preserves malformed legacy rich text as markdown text", () => { + const data = createEmptyLoadedSessionData(); + data.sessions["session-1"] = { + user_id: "user-1", + created_at: "", + folder_id: "", + event_json: "legacy-event-id", + title: "", + raw_md: "plain markdown", + }; + data.enhanced_notes["summary-1"] = { + user_id: "user-1", + session_id: "session-1", + content: "{bad json", + template_id: "", + position: 0, + title: "", + }; + data.transcripts["transcript-1"] = { + user_id: "user-1", + created_at: "", + session_id: "session-1", + started_at: 0, + ended_at: undefined, + words: "{bad json", + speaker_hints: "[]", + memo_md: "", + }; + + expect(legacySessionToMarkdownDocument(data, "session-1")).toMatchObject({ + createdAt: "1970-01-01T00:00:00.000Z", + eventId: "legacy-event-id", + notes: "plain markdown", + summary: "{bad json", + transcript: "", + }); + }); +}); diff --git a/apps/desktop/src/session/legacy-file-converter.ts b/apps/desktop/src/session/legacy-file-converter.ts new file mode 100644 index 0000000000..452bb1d903 --- /dev/null +++ b/apps/desktop/src/session/legacy-file-converter.ts @@ -0,0 +1,201 @@ +import { isValidContent, json2md } from "@hypr/editor/markdown"; + +import { + SESSION_MARKDOWN_SCHEMA_VERSION, + type SessionMarkdownDocument, + type SessionMarkdownParticipant, +} from "./file-format"; + +import type { LoadedSessionData } from "~/store/tinybase/persister/session/load"; + +const FALLBACK_CREATED_AT = "1970-01-01T00:00:00.000Z"; + +type LegacyEvent = { + event?: Record; + eventId?: string; +}; + +type LegacyTranscriptWord = { + text?: unknown; + speaker?: unknown; +}; + +export function legacySessionToMarkdownDocument( + data: LoadedSessionData, + sessionId: string, +): SessionMarkdownDocument | null { + const session = data.sessions[sessionId]; + if (!session) return null; + + const legacyEvent = parseLegacyEvent(session.event_json); + + return { + schemaVersion: SESSION_MARKDOWN_SCHEMA_VERSION, + id: sessionId, + createdAt: session.created_at || FALLBACK_CREATED_AT, + title: session.title ?? "", + folderId: session.folder_id || undefined, + eventId: legacyEvent.eventId, + event: legacyEvent.event, + participants: collectParticipants(data, sessionId), + tags: collectTags(data, sessionId), + notes: legacyRichTextToMarkdown(session.raw_md), + summary: collectSummary(data, sessionId), + transcript: collectTranscript(data, sessionId), + }; +} + +function legacyRichTextToMarkdown(content: unknown): string { + if (typeof content !== "string") return ""; + + const trimmed = content.trim(); + if (!trimmed) return ""; + + try { + const parsed = JSON.parse(trimmed); + if (!isValidContent(parsed)) return trimmed; + return json2md(parsed).trim(); + } catch { + return trimmed; + } +} + +function collectParticipants( + data: LoadedSessionData, + sessionId: string, +): SessionMarkdownParticipant[] { + return Object.values(data.mapping_session_participant) + .filter((participant) => participant.session_id === sessionId) + .map((participant) => ({ + legacy_human_id: participant.human_id || undefined, + source: participant.source || undefined, + })); +} + +function collectTags(data: LoadedSessionData, sessionId: string): string[] { + return Object.values(data.mapping_tag_session) + .filter((mapping) => mapping.session_id === sessionId) + .map((mapping) => { + if (!mapping.tag_id) return undefined; + return data.tags[mapping.tag_id]?.name || mapping.tag_id; + }) + .filter((tag): tag is string => Boolean(tag)); +} + +function collectSummary(data: LoadedSessionData, sessionId: string): string { + const notes = Object.entries(data.enhanced_notes) + .filter(([, note]) => note.session_id === sessionId) + .sort(([leftId, left], [rightId, right]) => { + const byPosition = (left.position ?? 0) - (right.position ?? 0); + return byPosition || leftId.localeCompare(rightId); + }) + .flatMap(([, note]) => { + const content = legacyRichTextToMarkdown(note.content); + if (!content) return []; + + return [ + { + title: note.title?.trim(), + content, + }, + ]; + }); + + if (notes.length === 1 && !notes[0].title) { + return notes[0].content; + } + + return notes + .map((note) => + note.title ? `## ${note.title}\n\n${note.content}` : note.content, + ) + .join("\n\n"); +} + +function collectTranscript(data: LoadedSessionData, sessionId: string): string { + return Object.entries(data.transcripts) + .filter(([, transcript]) => transcript.session_id === sessionId) + .sort(([leftId, left], [rightId, right]) => { + const byStart = (left.started_at ?? 0) - (right.started_at ?? 0); + return byStart || leftId.localeCompare(rightId); + }) + .map(([, transcript]) => { + const spoken = renderWords(parseTranscriptWords(transcript.words)); + const memo = transcript.memo_md?.trim(); + + if (spoken && memo) return `${spoken}\n\n${memo}`; + return spoken || memo || ""; + }) + .filter(Boolean) + .join("\n\n"); +} + +function parseTranscriptWords(content: unknown): LegacyTranscriptWord[] { + if (typeof content !== "string" || !content.trim()) return []; + + try { + const parsed = JSON.parse(content); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function renderWords(words: LegacyTranscriptWord[]): string { + const lines: string[] = []; + let currentSpeaker = ""; + let currentWords: string[] = []; + + function flush(): void { + if (currentWords.length === 0) return; + + const text = currentWords.join(" ").replace(/\s+([.,!?;:])/g, "$1"); + lines.push(currentSpeaker ? `${currentSpeaker}: ${text}` : text); + currentWords = []; + } + + for (const word of words) { + const text = typeof word.text === "string" ? word.text.trim() : ""; + if (!text) continue; + + const speaker = typeof word.speaker === "string" ? word.speaker.trim() : ""; + if (speaker !== currentSpeaker) { + flush(); + currentSpeaker = speaker; + } + currentWords.push(text); + } + flush(); + + return lines.join("\n"); +} + +function parseLegacyEvent(content: unknown): LegacyEvent { + const raw = typeof content === "string" ? content.trim() : ""; + if (!raw) return {}; + + try { + const parsed = JSON.parse(raw); + if (typeof parsed === "string") { + return { eventId: parsed || undefined }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; + } + + const event = parsed as Record; + return { + event, + eventId: extractEventId(event), + }; + } catch { + return { eventId: raw }; + } +} + +function extractEventId(event: Record): string | undefined { + for (const key of ["tracking_id_event", "tracking_id", "id"]) { + const value = event[key]; + if (typeof value === "string" && value) return value; + } +}