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

@@ -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.