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:
@@ -335,12 +335,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openViewer(item) {
|
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 {
|
try {
|
||||||
localStorage.setItem("kon_viewer_item", JSON.stringify(item));
|
localStorage.setItem("kon_viewer_item", handoff);
|
||||||
localStorage.setItem("kon_viewer_mode", "view");
|
localStorage.setItem("kon_viewer_mode", "view");
|
||||||
await invoke("open_viewer_window");
|
await invoke("open_viewer_window");
|
||||||
} catch {
|
} catch {
|
||||||
localStorage.setItem("kon_viewer_item", JSON.stringify(item));
|
localStorage.setItem("kon_viewer_item", handoff);
|
||||||
localStorage.setItem("kon_viewer_mode", "view");
|
localStorage.setItem("kon_viewer_mode", "view");
|
||||||
window.open("/viewer", "_blank", "width=600,height=700");
|
window.open("/viewer", "_blank", "width=600,height=700");
|
||||||
}
|
}
|
||||||
@@ -499,12 +505,15 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function openEditor(item) {
|
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 {
|
try {
|
||||||
localStorage.setItem("kon_viewer_item", JSON.stringify(item));
|
localStorage.setItem("kon_viewer_item", handoff);
|
||||||
localStorage.setItem("kon_viewer_mode", "edit");
|
localStorage.setItem("kon_viewer_mode", "edit");
|
||||||
await invoke("open_viewer_window");
|
await invoke("open_viewer_window");
|
||||||
} catch {
|
} catch {
|
||||||
localStorage.setItem("kon_viewer_item", JSON.stringify(item));
|
localStorage.setItem("kon_viewer_item", handoff);
|
||||||
localStorage.setItem("kon_viewer_mode", "edit");
|
localStorage.setItem("kon_viewer_mode", "edit");
|
||||||
window.open("/viewer", "_blank", "width=600,height=700");
|
window.open("/viewer", "_blank", "width=600,height=700");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export function saveProfiles() {
|
|||||||
|
|
||||||
export const history = $state<TranscriptEntry[]>([]);
|
export const history = $state<TranscriptEntry[]>([]);
|
||||||
|
|
||||||
function mapTranscriptRow(row: TranscriptDto): TranscriptEntry {
|
export function mapTranscriptRow(row: TranscriptDto): TranscriptEntry {
|
||||||
const text = row.text ?? "";
|
const text = row.text ?? "";
|
||||||
const rawTags = row.manualTags ?? "";
|
const rawTags = row.manualTags ?? "";
|
||||||
const manualTags = rawTags ? rawTags.split(",").filter(Boolean) : [];
|
const manualTags = rawTags ? rawTags.split(",").filter(Boolean) : [];
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
import { onMount, onDestroy, tick } from "svelte";
|
import { onMount, onDestroy, tick } from "svelte";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
|
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 SpeakerButton from "$lib/components/SpeakerButton.svelte";
|
||||||
import { formatTime, formatDuration } from "$lib/utils/time.js";
|
import { formatTime, formatDuration } from "$lib/utils/time.js";
|
||||||
import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js";
|
import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js";
|
||||||
import { errorMessage } from "$lib/utils/errors.js";
|
import { errorMessage } from "$lib/utils/errors.js";
|
||||||
import { parseStoredJson } from "$lib/utils/storage.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 { toasts } from "$lib/stores/toasts.svelte.ts";
|
||||||
import { DEFAULT_PROFILE_ID } from "$lib/stores/profiles.svelte.ts";
|
import { DEFAULT_PROFILE_ID } from "$lib/stores/profiles.svelte.ts";
|
||||||
|
|
||||||
@@ -71,8 +71,28 @@
|
|||||||
buildAudio(nextItem?.audioPath ?? null);
|
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(() => {
|
onMount(() => {
|
||||||
loadViewerItem(parseStoredJson<TranscriptEntry>(localStorage.getItem("kon_viewer_item")));
|
loadFromHandoff(localStorage.getItem("kon_viewer_item"));
|
||||||
const mode = localStorage.getItem("kon_viewer_mode");
|
const mode = localStorage.getItem("kon_viewer_mode");
|
||||||
if (mode === "edit" || mode === "view") viewerMode = mode;
|
if (mode === "edit" || mode === "view") viewerMode = mode;
|
||||||
|
|
||||||
@@ -120,7 +140,7 @@
|
|||||||
|
|
||||||
function handleStorageChange(e: StorageEvent) {
|
function handleStorageChange(e: StorageEvent) {
|
||||||
if (e.key === "kon_viewer_item" && e.newValue) {
|
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
|
// store's `saveTranscriptMeta` helper — callers in this file wire those
|
||||||
// up alongside the plain-text update below.
|
// up alongside the plain-text update below.
|
||||||
//
|
//
|
||||||
// The cross-window `kon_viewer_item` localStorage payload stays in sync so
|
// We re-stamp the localStorage handoff with the same ID so a sibling
|
||||||
// a second viewer window hydrating from it reflects the latest edit.
|
// 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() {
|
function saveItemToHistory() {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
// Update the cross-window payload only. The old localStorage history
|
|
||||||
// cache was removed as part of the SQLite-canonical refactor.
|
|
||||||
try {
|
try {
|
||||||
localStorage.setItem("kon_viewer_item", JSON.stringify(item));
|
localStorage.setItem(
|
||||||
|
"kon_viewer_item",
|
||||||
|
JSON.stringify({ id: String(item.id), stamp: Date.now() }),
|
||||||
|
);
|
||||||
} catch {}
|
} catch {}
|
||||||
// Best-effort SQLite persistence. Silently ignored in browser preview
|
// Best-effort SQLite persistence. Silently ignored in browser preview
|
||||||
// (no Tauri runtime) — matches the main-window store's behaviour.
|
// (no Tauri runtime) — matches the main-window store's behaviour.
|
||||||
|
|||||||
Reference in New Issue
Block a user