Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions apps/desktop/src/session/legacy-file-converter.test.ts
Original file line number Diff line number Diff line change
@@ -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: "",
});
});
});
201 changes: 201 additions & 0 deletions apps/desktop/src/session/legacy-file-converter.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown>;
return {
event,
eventId: extractEventId(event),
};
} catch {
return { eventId: raw };
}
}

function extractEventId(event: Record<string, unknown>): string | undefined {
for (const key of ["tracking_id_event", "tracking_id", "id"]) {
const value = event[key];
if (typeof value === "string" && value) return value;
}
}
Loading