From ab3bb9370c350571bbb031a791fbe181b18afb7b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 08:47:12 +0000 Subject: [PATCH] fix(viewer): hand off transcript ID only, fetch row from SQLite on mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HistoryPage previously serialised the full TranscriptDto — text, segments, manual + LLM tags, audio path — into `localStorage["kon_viewer_item"]` so the viewer window could pick it up on mount. On a multi-hour transcript that's MB-scale of user voice content sitting in storage that any same-origin script in any open Kon window can read. Hand off only `{ id }` (and a timestamp on re-saves). The viewer fetches the canonical row from SQLite via the existing `get_transcript` Tauri command and hydrates via the now-exported `mapTranscriptRow`. Cross-window sync via the `storage` event still works — the receiving window re-fetches on event instead of trusting the payload. - HistoryPage `openViewer` + `openEditor`: write `{ id }` only. - viewer `onMount` + `handleStorageChange`: route through new `loadFromHandoff` which calls `invoke("get_transcript", { id })`. - viewer `saveItemToHistory`: re-stamp localStorage with `{ id, stamp }` to retrigger the storage event in sibling windows without leaking content. - `mapTranscriptRow` exported from page.svelte.ts for the viewer's use. Backward-compatible at the parse layer: the `{ id }` shape extracts cleanly from a stale full-DTO payload (TranscriptEntry already carries `id` at top level), so a session that survives the upgrade picks up the new path on next handoff without manual cleanup. https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb --- src/lib/pages/HistoryPage.svelte | 17 +++++++++---- src/lib/stores/page.svelte.ts | 2 +- src/routes/viewer/+page.svelte | 41 +++++++++++++++++++++++++------- 3 files changed, 46 insertions(+), 14 deletions(-) diff --git a/src/lib/pages/HistoryPage.svelte b/src/lib/pages/HistoryPage.svelte index c0c2199..61bbafb 100644 --- a/src/lib/pages/HistoryPage.svelte +++ b/src/lib/pages/HistoryPage.svelte @@ -335,12 +335,18 @@ } async function openViewer(item) { + // Hand off the transcript ID only — the viewer window fetches the full + // row from SQLite via `get_transcript`. Previously the entire DTO + // (text + segments + tags) was stuffed into localStorage, putting + // potentially MB-scale PII in a place readable by any same-origin + // script. + const handoff = JSON.stringify({ id: String(item.id) }); try { - localStorage.setItem("kon_viewer_item", JSON.stringify(item)); + localStorage.setItem("kon_viewer_item", handoff); localStorage.setItem("kon_viewer_mode", "view"); await invoke("open_viewer_window"); } catch { - localStorage.setItem("kon_viewer_item", JSON.stringify(item)); + localStorage.setItem("kon_viewer_item", handoff); localStorage.setItem("kon_viewer_mode", "view"); window.open("/viewer", "_blank", "width=600,height=700"); } @@ -499,12 +505,15 @@ }); async function openEditor(item) { + // Hand off the transcript ID only; the viewer hydrates from SQLite + // (see openViewer above for the rationale). + const handoff = JSON.stringify({ id: String(item.id) }); try { - localStorage.setItem("kon_viewer_item", JSON.stringify(item)); + localStorage.setItem("kon_viewer_item", handoff); localStorage.setItem("kon_viewer_mode", "edit"); await invoke("open_viewer_window"); } catch { - localStorage.setItem("kon_viewer_item", JSON.stringify(item)); + localStorage.setItem("kon_viewer_item", handoff); localStorage.setItem("kon_viewer_mode", "edit"); window.open("/viewer", "_blank", "width=600,height=700"); } diff --git a/src/lib/stores/page.svelte.ts b/src/lib/stores/page.svelte.ts index 80845f2..f3c096b 100644 --- a/src/lib/stores/page.svelte.ts +++ b/src/lib/stores/page.svelte.ts @@ -140,7 +140,7 @@ export function saveProfiles() { export const history = $state([]); -function mapTranscriptRow(row: TranscriptDto): TranscriptEntry { +export function mapTranscriptRow(row: TranscriptDto): TranscriptEntry { const text = row.text ?? ""; const rawTags = row.manualTags ?? ""; const manualTags = rawTags ? rawTags.split(",").filter(Boolean) : []; diff --git a/src/routes/viewer/+page.svelte b/src/routes/viewer/+page.svelte index 2e24941..af79a1c 100644 --- a/src/routes/viewer/+page.svelte +++ b/src/routes/viewer/+page.svelte @@ -2,13 +2,13 @@ import { onMount, onDestroy, tick } from "svelte"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { convertFileSrc, invoke } from "@tauri-apps/api/core"; - import type { TranscriptEntry, ViewerSegment } from "$lib/types/app"; + import type { TranscriptDto, TranscriptEntry, ViewerSegment } from "$lib/types/app"; import SpeakerButton from "$lib/components/SpeakerButton.svelte"; import { formatTime, formatDuration } from "$lib/utils/time.js"; import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js"; import { errorMessage } from "$lib/utils/errors.js"; import { parseStoredJson } from "$lib/utils/storage.js"; - import { saveTranscriptMeta } from "$lib/stores/page.svelte.js"; + import { mapTranscriptRow, saveTranscriptMeta } from "$lib/stores/page.svelte.js"; import { toasts } from "$lib/stores/toasts.svelte.ts"; import { DEFAULT_PROFILE_ID } from "$lib/stores/profiles.svelte.ts"; @@ -71,8 +71,28 @@ buildAudio(nextItem?.audioPath ?? null); } + // Hydrate the viewer from a transcript ID handed off via localStorage. + // The full row is fetched from SQLite via `get_transcript` so transcript + // text never lives in localStorage where any same-origin script could + // read it. + async function loadFromHandoff(raw: string | null) { + const handoff = parseStoredJson<{ id?: unknown }>(raw); + const id = handoff?.id != null ? String(handoff.id) : ""; + if (!id) { + loadViewerItem(null); + return; + } + try { + const dto = await invoke("get_transcript", { id }); + loadViewerItem(dto ? mapTranscriptRow(dto) : null); + } catch (err) { + console.warn("viewer get_transcript failed", errorMessage(err)); + loadViewerItem(null); + } + } + onMount(() => { - loadViewerItem(parseStoredJson(localStorage.getItem("kon_viewer_item"))); + loadFromHandoff(localStorage.getItem("kon_viewer_item")); const mode = localStorage.getItem("kon_viewer_mode"); if (mode === "edit" || mode === "view") viewerMode = mode; @@ -120,7 +140,7 @@ function handleStorageChange(e: StorageEvent) { if (e.key === "kon_viewer_item" && e.newValue) { - loadViewerItem(parseStoredJson(e.newValue)); + void loadFromHandoff(e.newValue); } } @@ -289,14 +309,17 @@ // store's `saveTranscriptMeta` helper — callers in this file wire those // up alongside the plain-text update below. // - // The cross-window `kon_viewer_item` localStorage payload stays in sync so - // a second viewer window hydrating from it reflects the latest edit. + // We re-stamp the localStorage handoff with the same ID so a sibling + // viewer window receives a `storage` event and re-fetches from SQLite. + // The handoff intentionally contains no transcript content — see + // `loadFromHandoff` for the rationale. function saveItemToHistory() { if (!item) return; - // Update the cross-window payload only. The old localStorage history - // cache was removed as part of the SQLite-canonical refactor. try { - localStorage.setItem("kon_viewer_item", JSON.stringify(item)); + localStorage.setItem( + "kon_viewer_item", + JSON.stringify({ id: String(item.id), stamp: Date.now() }), + ); } catch {} // Best-effort SQLite persistence. Silently ignored in browser preview // (no Tauri runtime) — matches the main-window store's behaviour.