feat(dictation): PR 1.4 — draft recovery + addToHistory persistence outcome
addToHistory previously swallowed SQLite write failures with a console
warning while still pushing the entry into the in-memory history store
— a silent data-loss bug, since the row vanished on next restart with
no signal to the user. It now returns { persisted, error }, only mutates
in-memory history after the disk write lands, and the dictation flow
awaits the result so it can surface a "Couldn't save" toast and keep
the user's content intact for a retry. The new dictation autosave
writes transcript + segments to localStorage on a 1.5s debounce, on
every segment boundary, and on visibilitychange/pagehide — covering
both live capture and the stopped-but-unsaved walk-away case. On
DictationPage mount any draft <24h old surfaces an inline
Restore/Discard banner that styles as a peer to the existing error
notices. The draft is cleared only after a successful SQLite write or
explicit Discard/Clear; on persistence failure it stays on disk so the
user can recover on relaunch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,14 @@
|
|||||||
import { transcriptPretextFont, transcriptPretextLineHeight } from '$lib/utils/accessibilityTypography.js';
|
import { transcriptPretextFont, transcriptPretextLineHeight } from '$lib/utils/accessibilityTypography.js';
|
||||||
import { hasTauriRuntime, isAndroid } from '$lib/utils/runtime.js';
|
import { hasTauriRuntime, isAndroid } from '$lib/utils/runtime.js';
|
||||||
import { errorMessage } from '$lib/utils/errors.js';
|
import { errorMessage } from '$lib/utils/errors.js';
|
||||||
|
import {
|
||||||
|
buildDraftPayload,
|
||||||
|
clearDraft,
|
||||||
|
formatTimeAgo,
|
||||||
|
loadRecoverableDraft,
|
||||||
|
saveDraft,
|
||||||
|
type DictationDraftPayload,
|
||||||
|
} from '$lib/utils/dictationDraft.js';
|
||||||
const prefs = getPreferences();
|
const prefs = getPreferences();
|
||||||
const tauriRuntimeAvailable = hasTauriRuntime();
|
const tauriRuntimeAvailable = hasTauriRuntime();
|
||||||
const browserPreviewMessage = "You're viewing Kon in a normal browser. Local transcription only works in the Tauri desktop app window.";
|
const browserPreviewMessage = "You're viewing Kon in a normal browser. Local transcription only works in the Tauri desktop app window.";
|
||||||
@@ -65,9 +73,42 @@
|
|||||||
// Global hotkey listener
|
// Global hotkey listener
|
||||||
let hotkeyHandler = () => toggleRecording();
|
let hotkeyHandler = () => toggleRecording();
|
||||||
|
|
||||||
|
// PR 1.4 draft recovery — surfaces an inline banner on mount when a
|
||||||
|
// <24h-old draft is in localStorage. The actual recover/discard wiring
|
||||||
|
// lives in restoreDraft() / discardDraft() below; this state just
|
||||||
|
// drives the banner render.
|
||||||
|
let recoverableDraft = $state<DictationDraftPayload | null>(null);
|
||||||
|
let recoverableDraftAgo = $state("");
|
||||||
|
// Debounce handle for the autosave $effect. Stored at module scope so
|
||||||
|
// we can clear-and-reschedule on each transcript/segment change without
|
||||||
|
// accumulating timers. visibilitychange flushes it eagerly.
|
||||||
|
let autosaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let visibilityHandler: (() => void) | null = null;
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
window.addEventListener("kon:toggle-recording", hotkeyHandler);
|
window.addEventListener("kon:toggle-recording", hotkeyHandler);
|
||||||
|
|
||||||
|
// Recovery check — runs even in non-Tauri preview so the browser
|
||||||
|
// shell can also offer to restore. Only shows the banner if there's
|
||||||
|
// nothing currently in the textarea (otherwise the user has live
|
||||||
|
// content and a banner would be confusing).
|
||||||
|
const draft = loadRecoverableDraft();
|
||||||
|
if (draft && !transcript.trim()) {
|
||||||
|
recoverableDraft = draft;
|
||||||
|
recoverableDraftAgo = formatTimeAgo(draft.updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// visibilitychange + pagehide flush the autosave immediately so
|
||||||
|
// hiding the window or closing the app captures the latest state
|
||||||
|
// without waiting for the 1.5s debounce.
|
||||||
|
visibilityHandler = () => {
|
||||||
|
if (document.visibilityState === "hidden") {
|
||||||
|
flushAutosave();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", visibilityHandler);
|
||||||
|
window.addEventListener("pagehide", flushAutosave);
|
||||||
|
|
||||||
if (!tauriRuntimeAvailable) {
|
if (!tauriRuntimeAvailable) {
|
||||||
error = browserPreviewMessage;
|
error = browserPreviewMessage;
|
||||||
return;
|
return;
|
||||||
@@ -79,6 +120,15 @@
|
|||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
window.removeEventListener("kon:toggle-recording", hotkeyHandler);
|
window.removeEventListener("kon:toggle-recording", hotkeyHandler);
|
||||||
|
if (visibilityHandler) {
|
||||||
|
document.removeEventListener("visibilitychange", visibilityHandler);
|
||||||
|
visibilityHandler = null;
|
||||||
|
}
|
||||||
|
window.removeEventListener("pagehide", flushAutosave);
|
||||||
|
if (autosaveTimer) {
|
||||||
|
clearTimeout(autosaveTimer);
|
||||||
|
autosaveTimer = null;
|
||||||
|
}
|
||||||
clearInterval(timerInterval);
|
clearInterval(timerInterval);
|
||||||
if (page.recording) {
|
if (page.recording) {
|
||||||
page.recording = false;
|
page.recording = false;
|
||||||
@@ -88,6 +138,76 @@
|
|||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronously persist the current dictation state to localStorage.
|
||||||
|
* Called on visibilitychange/pagehide and on segment boundaries — paths
|
||||||
|
* where we can't afford to wait for the 1.5s debounce. No-op when the
|
||||||
|
* transcript is empty: we deliberately do NOT clear the draft here,
|
||||||
|
* because "transcript happens to be empty right now" is not the same
|
||||||
|
* as "user wants to discard". The explicit clear paths are (a) a
|
||||||
|
* successful save, (b) the Discard button, and (c) the Clear button.
|
||||||
|
*/
|
||||||
|
function flushAutosave() {
|
||||||
|
if (autosaveTimer) {
|
||||||
|
clearTimeout(autosaveTimer);
|
||||||
|
autosaveTimer = null;
|
||||||
|
}
|
||||||
|
const payload = buildDraftPayload(transcript, segments, effectiveLanguage());
|
||||||
|
if (payload) saveDraft(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PR 1.4 autosave — debounced 1.5s on any transcript or segments
|
||||||
|
* change. Reading both reactive references in the $effect causes
|
||||||
|
* Svelte 5 to re-run on either change. Empty transcript + no segments
|
||||||
|
* is a no-op (no save, no clear) so the on-mount empty state can't
|
||||||
|
* destroy the very draft we just loaded for the recovery banner; the
|
||||||
|
* user has to explicitly Save / Discard / Clear to drop the draft.
|
||||||
|
*/
|
||||||
|
$effect(() => {
|
||||||
|
// Read reactive deps explicitly so the effect re-runs on either.
|
||||||
|
const t = transcript;
|
||||||
|
const segCount = segments.length;
|
||||||
|
if (autosaveTimer) {
|
||||||
|
clearTimeout(autosaveTimer);
|
||||||
|
autosaveTimer = null;
|
||||||
|
}
|
||||||
|
if (!t.trim() && segCount === 0) return;
|
||||||
|
// Once content lands the recovery banner is misleading: clicking
|
||||||
|
// Restore would blow away the in-progress work. Dismiss the banner
|
||||||
|
// (but do NOT clearDraft — the autosave that's about to fire below
|
||||||
|
// will overwrite localStorage with the new content anyway).
|
||||||
|
if (recoverableDraft) {
|
||||||
|
recoverableDraft = null;
|
||||||
|
recoverableDraftAgo = "";
|
||||||
|
}
|
||||||
|
autosaveTimer = setTimeout(() => {
|
||||||
|
autosaveTimer = null;
|
||||||
|
const payload = buildDraftPayload(transcript, segments, effectiveLanguage());
|
||||||
|
if (payload) saveDraft(payload);
|
||||||
|
}, 1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
function restoreDraft() {
|
||||||
|
if (!recoverableDraft) return;
|
||||||
|
transcript = recoverableDraft.transcript;
|
||||||
|
segments = Array.isArray(recoverableDraft.segments)
|
||||||
|
? [...recoverableDraft.segments]
|
||||||
|
: [];
|
||||||
|
recoverableDraft = null;
|
||||||
|
recoverableDraftAgo = "";
|
||||||
|
// Don't clear the localStorage entry — the autosave $effect will
|
||||||
|
// immediately rewrite it with a fresh `updatedAt` reflecting the
|
||||||
|
// restored state. If the app crashes between restore and next
|
||||||
|
// autosave the draft is still there.
|
||||||
|
}
|
||||||
|
|
||||||
|
function discardDraft() {
|
||||||
|
recoverableDraft = null;
|
||||||
|
recoverableDraftAgo = "";
|
||||||
|
clearDraft();
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
settings.engine;
|
settings.engine;
|
||||||
settings.modelSize;
|
settings.modelSize;
|
||||||
@@ -192,6 +312,11 @@
|
|||||||
end: segment.end + result.chunkStartSecs,
|
end: segment.end + result.chunkStartSecs,
|
||||||
}));
|
}));
|
||||||
segments = [...segments, ...absoluteSegments];
|
segments = [...segments, ...absoluteSegments];
|
||||||
|
// PR 1.4: each new segment chunk is a natural checkpoint — flush
|
||||||
|
// the draft immediately rather than waiting for the 1.5s debounce.
|
||||||
|
// This is the strongest guarantee we can give for "user pulled the
|
||||||
|
// plug mid-sentence" scenarios.
|
||||||
|
if (absoluteSegments.length > 0) flushAutosave();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLiveStatus(status) {
|
function handleLiveStatus(status) {
|
||||||
@@ -312,6 +437,13 @@
|
|||||||
liveWarning = "";
|
liveWarning = "";
|
||||||
saved = false;
|
saved = false;
|
||||||
transcriptionFailed = false;
|
transcriptionFailed = false;
|
||||||
|
// PR 1.4: starting a new recording is implicit "I'm working on
|
||||||
|
// something new" — drop the recovery banner so the user doesn't
|
||||||
|
// accidentally Restore over their fresh capture. We do NOT call
|
||||||
|
// clearDraft() here: the autosave will overwrite the localStorage
|
||||||
|
// entry with the new content on the first chunk anyway.
|
||||||
|
recoverableDraft = null;
|
||||||
|
recoverableDraftAgo = "";
|
||||||
if (!tauriRuntimeAvailable) {
|
if (!tauriRuntimeAvailable) {
|
||||||
error = browserPreviewMessage;
|
error = browserPreviewMessage;
|
||||||
page.status = "Desktop app required";
|
page.status = "Desktop app required";
|
||||||
@@ -572,7 +704,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const historyId = crypto.randomUUID();
|
const historyId = crypto.randomUUID();
|
||||||
addToHistory({
|
// PR 1.4: await so we can gate draft-clearing on the SQLite write
|
||||||
|
// outcome. On `persisted: false` we keep the draft + textarea so
|
||||||
|
// the user can retry; on success we clear the draft (the row is
|
||||||
|
// safely on disk and the recovery banner would be misleading on
|
||||||
|
// next launch).
|
||||||
|
const writeResult = await addToHistory({
|
||||||
id: historyId,
|
id: historyId,
|
||||||
date: new Date().toLocaleString("en-GB"),
|
date: new Date().toLocaleString("en-GB"),
|
||||||
source: "live",
|
source: "live",
|
||||||
@@ -587,6 +724,16 @@
|
|||||||
template: activeTemplate || undefined,
|
template: activeTemplate || undefined,
|
||||||
audioPath,
|
audioPath,
|
||||||
});
|
});
|
||||||
|
if (!writeResult.persisted) {
|
||||||
|
toasts.error(
|
||||||
|
"Couldn't save",
|
||||||
|
"Your transcript stays here. Try again or restart.",
|
||||||
|
);
|
||||||
|
page.status = "Error";
|
||||||
|
page.statusColor = "#e87171";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearDraft();
|
||||||
|
|
||||||
// Extract tasks from transcript
|
// Extract tasks from transcript
|
||||||
const extracted = await extractTasksForTranscript(transcript);
|
const extracted = await extractTasksForTranscript(transcript);
|
||||||
@@ -646,6 +793,13 @@
|
|||||||
saved = false;
|
saved = false;
|
||||||
insertPos = -1;
|
insertPos = -1;
|
||||||
activeTemplate = "";
|
activeTemplate = "";
|
||||||
|
// PR 1.4: explicit user discard. Drop the recovery banner if it
|
||||||
|
// was still visible AND drop the localStorage draft so we don't
|
||||||
|
// re-prompt on next launch with the work the user just chose to
|
||||||
|
// throw away.
|
||||||
|
recoverableDraft = null;
|
||||||
|
recoverableDraftAgo = "";
|
||||||
|
clearDraft();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleExport(format) {
|
function handleExport(format) {
|
||||||
@@ -698,9 +852,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveTypedText() {
|
async function saveTypedText() {
|
||||||
if (!transcript.trim()) return;
|
if (!transcript.trim()) return;
|
||||||
addToHistory({
|
// PR 1.4: same gate as the live path. Typed text is just as
|
||||||
|
// recoverable as captured text — the autosave $effect already has
|
||||||
|
// it in localStorage; we just need to keep it there on failure.
|
||||||
|
const writeResult = await addToHistory({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
date: new Date().toLocaleString("en-GB"),
|
date: new Date().toLocaleString("en-GB"),
|
||||||
source: "typed",
|
source: "typed",
|
||||||
@@ -711,6 +868,14 @@
|
|||||||
duration: 0,
|
duration: 0,
|
||||||
language: settings.language,
|
language: settings.language,
|
||||||
});
|
});
|
||||||
|
if (!writeResult.persisted) {
|
||||||
|
toasts.error(
|
||||||
|
"Couldn't save",
|
||||||
|
"Your transcript stays here. Try again or restart.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearDraft();
|
||||||
saved = true;
|
saved = true;
|
||||||
setTimeout(() => { saved = false; }, FEEDBACK_TIMEOUT_MS);
|
setTimeout(() => { saved = false; }, FEEDBACK_TIMEOUT_MS);
|
||||||
}
|
}
|
||||||
@@ -1004,6 +1169,29 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- PR 1.4 draft recovery banner. Inline (not modal) per the spec —
|
||||||
|
offers Restore/Discard for a draft saved within the last 24 h.
|
||||||
|
Styled to match the .error/.liveWarning notices above so it reads
|
||||||
|
as a peer affordance, not a separate dialog. -->
|
||||||
|
{#if recoverableDraft}
|
||||||
|
<div class="px-5 pt-2 animate-fade-in flex-shrink-0">
|
||||||
|
<div class="px-4 py-2 rounded-lg bg-accent-subtle border border-accent/20 text-[12px] flex items-center gap-3">
|
||||||
|
<span class="text-text">
|
||||||
|
Recovered draft from {recoverableDraftAgo}
|
||||||
|
</span>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button
|
||||||
|
class="btn-md rounded-md text-accent hover:bg-accent/10 hover:text-accent font-medium whitespace-nowrap"
|
||||||
|
onclick={restoreDraft}
|
||||||
|
>Restore</button>
|
||||||
|
<button
|
||||||
|
class="btn-md rounded-md text-text-tertiary hover:bg-hover hover:text-text whitespace-nowrap"
|
||||||
|
onclick={discardDraft}
|
||||||
|
>Discard</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if liveWarning && !error}
|
{#if liveWarning && !error}
|
||||||
<div class="px-5 pt-2 animate-fade-in flex-shrink-0">
|
<div class="px-5 pt-2 animate-fade-in flex-shrink-0">
|
||||||
<div class="px-4 py-2 rounded-lg bg-warning/10 border border-warning/20 text-[12px] text-warning">
|
<div class="px-4 py-2 rounded-lg bg-warning/10 border border-warning/20 text-[12px] text-warning">
|
||||||
|
|||||||
@@ -95,7 +95,10 @@
|
|||||||
|
|
||||||
segments = [...segments, ...result.segments];
|
segments = [...segments, ...result.segments];
|
||||||
|
|
||||||
addToHistory({
|
// Fire-and-forget: file transcription is a batch flow, the user
|
||||||
|
// is watching the progress bar, not the history list. If the
|
||||||
|
// write fails the user can re-drop the file.
|
||||||
|
void addToHistory({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
date: new Date().toLocaleString("en-GB"),
|
date: new Date().toLocaleString("en-GB"),
|
||||||
source: fileName,
|
source: fileName,
|
||||||
|
|||||||
@@ -223,12 +223,31 @@ if (typeof window !== "undefined" && hasTauriRuntime()) {
|
|||||||
loadHistory().catch(() => {});
|
loadHistory().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addToHistory(entry: TranscriptWriteEntry) {
|
/**
|
||||||
const normalised = normaliseTranscriptEntry(entry);
|
* Result of an `addToHistory` call. `persisted: true` means the entry is
|
||||||
history.unshift(normalised);
|
* safely on disk (Tauri SQLite write succeeded, or we're in the non-Tauri
|
||||||
if (history.length > 500) history.length = 500;
|
* shell where there is no disk). `persisted: false` means the in-memory
|
||||||
|
* history was NOT updated either — the caller is responsible for keeping
|
||||||
|
* the user's source content (e.g. the dictation draft) alive so a retry
|
||||||
|
* is possible. Surfacing this is what closes the historical silent-loss
|
||||||
|
* bug where SQLite failures were swallowed by `console.warn`.
|
||||||
|
*/
|
||||||
|
export interface AddToHistoryResult {
|
||||||
|
persisted: boolean;
|
||||||
|
error?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasTauriRuntime()) return;
|
export async function addToHistory(entry: TranscriptWriteEntry): Promise<AddToHistoryResult> {
|
||||||
|
const normalised = normaliseTranscriptEntry(entry);
|
||||||
|
|
||||||
|
// Non-Tauri shell (browser preview, SSR test): no disk to fail. Update
|
||||||
|
// the in-memory store and report success so callers don't think the
|
||||||
|
// write was lost — there was no write to lose.
|
||||||
|
if (!hasTauriRuntime()) {
|
||||||
|
history.unshift(normalised);
|
||||||
|
if (history.length > 500) history.length = 500;
|
||||||
|
return { persisted: true };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await invoke("add_transcript", {
|
await invoke("add_transcript", {
|
||||||
@@ -251,14 +270,22 @@ export async function addToHistory(entry: TranscriptWriteEntry) {
|
|||||||
antiHallucination: !!entry.antiHallucination,
|
antiHallucination: !!entry.antiHallucination,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// Only mutate the in-memory history after the disk write lands.
|
||||||
|
// Adding to memory while the disk write fails is exactly the misleading
|
||||||
|
// state PR 1.4 set out to fix — the row would appear in History, then
|
||||||
|
// silently vanish on next launch.
|
||||||
|
history.unshift(normalised);
|
||||||
|
if (history.length > 500) history.length = 500;
|
||||||
// Fire-and-forget auto-title. Same gate as the existing auto-cleanup
|
// Fire-and-forget auto-title. Same gate as the existing auto-cleanup
|
||||||
// path (`aiTier !== "off" && formatMode !== "Raw"`) so users opt in
|
// path (`aiTier !== "off" && formatMode !== "Raw"`) so users opt in
|
||||||
// by the same Settings choice — no new toggle. Skipped silently if
|
// by the same Settings choice — no new toggle. Skipped silently if
|
||||||
// the LLM isn't loaded; HistoryPage's per-row "Title" button is the
|
// the LLM isn't loaded; HistoryPage's per-row "Title" button is the
|
||||||
// retry path. Never overwrites a title the caller already set.
|
// retry path. Never overwrites a title the caller already set.
|
||||||
void maybeAutoGenerateTitle(normalised.id, normalised.text, normalised.title);
|
void maybeAutoGenerateTitle(normalised.id, normalised.text, normalised.title);
|
||||||
|
return { persisted: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("addToHistory: SQLite write failed, entry will not persist past restart", err);
|
console.warn("addToHistory: SQLite write failed, entry will not persist past restart", err);
|
||||||
|
return { persisted: false, error: err };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
132
src/lib/utils/dictationDraft.ts
Normal file
132
src/lib/utils/dictationDraft.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
// Draft autosave/recovery for the Dictation page (PR 1.4).
|
||||||
|
//
|
||||||
|
// Covers two failure modes that previously lost user work:
|
||||||
|
// 1. App or window closes mid-capture before the user hits Stop.
|
||||||
|
// 2. User stops, walks away, app exits before the SQLite write
|
||||||
|
// surfaces its outcome — or the SQLite write fails outright.
|
||||||
|
//
|
||||||
|
// The payload is intentionally tiny — transcript + segments + language
|
||||||
|
// — so the localStorage write stays cheap on every keystroke. The
|
||||||
|
// autosave call site debounces to 1.5s; this module just owns the
|
||||||
|
// shape and the parse/serialise round trip.
|
||||||
|
|
||||||
|
import type { Segment } from "$lib/types/app";
|
||||||
|
import { parseStoredJson } from "$lib/utils/storage.js";
|
||||||
|
|
||||||
|
export const DICTATION_DRAFT_KEY = "kon.dictation.draft.v1";
|
||||||
|
|
||||||
|
/** 24 hours in ms — drafts older than this are ignored on mount. */
|
||||||
|
export const DRAFT_RECOVERY_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface DictationDraftPayload {
|
||||||
|
/** ISO-8601 string. Compared to Date.now() to gate the recovery banner. */
|
||||||
|
updatedAt: string;
|
||||||
|
transcript: string;
|
||||||
|
segments: Segment[];
|
||||||
|
language?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a draft payload from current dictation state. Returns `null` if
|
||||||
|
* there is nothing worth persisting (empty transcript and no segments).
|
||||||
|
* The caller is expected to call `clearDraft()` instead when this returns
|
||||||
|
* null, so we don't pollute localStorage with empty drafts.
|
||||||
|
*/
|
||||||
|
export function buildDraftPayload(
|
||||||
|
transcript: string,
|
||||||
|
segments: Segment[],
|
||||||
|
language: string | undefined,
|
||||||
|
now: Date = new Date(),
|
||||||
|
): DictationDraftPayload | null {
|
||||||
|
const hasTranscript = transcript.trim().length > 0;
|
||||||
|
const hasSegments = Array.isArray(segments) && segments.length > 0;
|
||||||
|
if (!hasTranscript && !hasSegments) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedAt: now.toISOString(),
|
||||||
|
transcript,
|
||||||
|
segments: Array.isArray(segments) ? segments : [],
|
||||||
|
...(language ? { language } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function canUseStorage(): boolean {
|
||||||
|
return typeof localStorage !== "undefined";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveDraft(payload: DictationDraftPayload): void {
|
||||||
|
if (!canUseStorage()) return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DICTATION_DRAFT_KEY, JSON.stringify(payload));
|
||||||
|
} catch {
|
||||||
|
// Quota exceeded or storage disabled — silently drop. The user still
|
||||||
|
// has the in-memory transcript; we just lose the recover-on-relaunch
|
||||||
|
// safety net for this run. No toast; the failure mode is rare and
|
||||||
|
// surfacing it would confuse more than it helps.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearDraft(): void {
|
||||||
|
if (!canUseStorage()) return;
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(DICTATION_DRAFT_KEY);
|
||||||
|
} catch {
|
||||||
|
/* same rationale as saveDraft */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the persisted draft, returning it only if it exists, parses
|
||||||
|
* cleanly, and is fresher than `maxAgeMs` (default 24 h). Anything older
|
||||||
|
* is treated as stale and the caller can show no banner.
|
||||||
|
*
|
||||||
|
* We do NOT auto-prune the stale entry here — leaving it in place is
|
||||||
|
* harmless (it's still <a few KB) and avoids a write on every mount.
|
||||||
|
*/
|
||||||
|
export function loadRecoverableDraft(
|
||||||
|
now: Date = new Date(),
|
||||||
|
maxAgeMs: number = DRAFT_RECOVERY_MAX_AGE_MS,
|
||||||
|
): DictationDraftPayload | null {
|
||||||
|
if (!canUseStorage()) return null;
|
||||||
|
const raw = localStorage.getItem(DICTATION_DRAFT_KEY);
|
||||||
|
const parsed = parseStoredJson<DictationDraftPayload>(raw);
|
||||||
|
if (!parsed || typeof parsed.updatedAt !== "string") return null;
|
||||||
|
|
||||||
|
const updatedAtMs = Date.parse(parsed.updatedAt);
|
||||||
|
if (!Number.isFinite(updatedAtMs)) return null;
|
||||||
|
if (now.getTime() - updatedAtMs > maxAgeMs) return null;
|
||||||
|
|
||||||
|
// Defensive: if the stored draft has neither transcript nor segments
|
||||||
|
// (shouldn't happen because saveDraft refuses empty payloads, but a
|
||||||
|
// hand-edited or truncated entry could trip this), treat as no draft.
|
||||||
|
const hasTranscript = typeof parsed.transcript === "string" && parsed.transcript.trim().length > 0;
|
||||||
|
const hasSegments = Array.isArray(parsed.segments) && parsed.segments.length > 0;
|
||||||
|
if (!hasTranscript && !hasSegments) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedAt: parsed.updatedAt,
|
||||||
|
transcript: typeof parsed.transcript === "string" ? parsed.transcript : "",
|
||||||
|
segments: Array.isArray(parsed.segments) ? parsed.segments : [],
|
||||||
|
...(typeof parsed.language === "string" ? { language: parsed.language } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coarse "x ago" formatter for the recovery banner. Intentionally short
|
||||||
|
* — we only need enough resolution for the user to recognise their own
|
||||||
|
* recent work ("3 minutes ago", "2 hours ago"). Returns "just now" for
|
||||||
|
* <30s and clamps to "yesterday" for things ~24h old (the recovery cap).
|
||||||
|
*/
|
||||||
|
export function formatTimeAgo(iso: string, now: Date = new Date()): string {
|
||||||
|
const then = Date.parse(iso);
|
||||||
|
if (!Number.isFinite(then)) return "";
|
||||||
|
const deltaMs = Math.max(0, now.getTime() - then);
|
||||||
|
const seconds = Math.floor(deltaMs / 1000);
|
||||||
|
if (seconds < 30) return "just now";
|
||||||
|
if (seconds < 60) return `${seconds} seconds ago`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes} ${minutes === 1 ? "minute" : "minutes"} ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours} ${hours === 1 ? "hour" : "hours"} ago`;
|
||||||
|
return "yesterday";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user