fix(viewer): hand off transcript ID only, fetch row from SQLite on mount

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
This commit is contained in:
Claude
2026-04-25 08:47:12 +00:00
parent 3d568148b8
commit ab3bb9370c
3 changed files with 46 additions and 14 deletions

View File

@@ -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");
}

View File

@@ -140,7 +140,7 @@ export function saveProfiles() {
export const history = $state<TranscriptEntry[]>([]);
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) : [];

View File

@@ -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<TranscriptDto | null>("get_transcript", { id });
loadViewerItem(dto ? mapTranscriptRow(dto) : null);
} catch (err) {
console.warn("viewer get_transcript failed", errorMessage(err));
loadViewerItem(null);
}
}
onMount(() => {
loadViewerItem(parseStoredJson<TranscriptEntry>(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<TranscriptEntry>(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.