feat(history): migrate history to SQLite with FTS5 search

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jake
2026-03-21 13:34:43 +00:00
parent 6d8597e9ea
commit f1ef171acb
5 changed files with 240 additions and 42 deletions

View File

@@ -2,7 +2,8 @@
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { page, settings, templates, addToHistory } from "$lib/stores/page.svelte.js"; import { page, settings, templates } from "$lib/stores/page.svelte.js";
import { saveTranscript } from "$lib/stores/history.svelte.js";
import { getTasks, createTask } from "$lib/stores/tasks.svelte.js"; import { getTasks, createTask } from "$lib/stores/tasks.svelte.js";
const tasks = getTasks(); const tasks = getTasks();
import Card from "$lib/components/Card.svelte"; import Card from "$lib/components/Card.svelte";
@@ -378,7 +379,7 @@
} }
const historyId = crypto.randomUUID(); const historyId = crypto.randomUUID();
addToHistory({ saveTranscript({
id: historyId, id: historyId,
date: new Date().toLocaleString("en-GB"), date: new Date().toLocaleString("en-GB"),
source: "live", source: "live",
@@ -389,6 +390,11 @@
language: settings.language, language: settings.language,
template: activeTemplate || undefined, template: activeTemplate || undefined,
audioPath, audioPath,
engine: settings.engine,
formatMode: settings.formatMode,
removeFillers: settings.removeFillers,
britishEnglish: settings.britishEnglish,
antiHallucination: settings.antiHallucination,
}); });
// Extract tasks from transcript // Extract tasks from transcript
@@ -484,7 +490,7 @@
function saveTypedText() { function saveTypedText() {
if (!transcript.trim()) return; if (!transcript.trim()) return;
addToHistory({ saveTranscript({
id: crypto.randomUUID(), id: crypto.randomUUID(),
date: new Date().toLocaleString("en-GB"), date: new Date().toLocaleString("en-GB"),
source: "typed", source: "typed",

View File

@@ -2,7 +2,8 @@
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { settings, addToHistory } from "$lib/stores/page.svelte.js"; import { settings } from "$lib/stores/page.svelte.js";
import { loadHistory } from "$lib/stores/history.svelte.js";
import Card from "$lib/components/Card.svelte"; import Card from "$lib/components/Card.svelte";
import EmptyState from "$lib/components/EmptyState.svelte"; import EmptyState from "$lib/components/EmptyState.svelte";
import { exportTranscript } from "$lib/utils/export.js"; import { exportTranscript } from "$lib/utils/export.js";
@@ -91,16 +92,9 @@
segments = [...segments, ...result.segments]; segments = [...segments, ...result.segments];
addToHistory({ // transcribe_file auto-persists to SQLite (Task 2).
id: crypto.randomUUID(), // Refresh local history state to pick up the new entry.
date: new Date().toLocaleString("en-GB"), loadHistory();
source: fileName,
preview: text.slice(0, 120),
text,
segments: result.segments,
duration: result.duration,
language: result.language,
});
} catch (err) { } catch (err) {
error = `Failed: ${fileName}${typeof err === "string" ? err : err.message}`; error = `Failed: ${fileName}${typeof err === "string" ? err : err.message}`;
} }

View File

@@ -1,8 +1,12 @@
<script> <script>
import { onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { history, saveHistory, deleteFromHistory } from "$lib/stores/page.svelte.js"; import { getHistory, loadHistory, deleteTranscript, searchTranscripts } from "$lib/stores/history.svelte.js";
import { convertFileSrc } from "@tauri-apps/api/core"; import { convertFileSrc } from "@tauri-apps/api/core";
const history = getHistory();
onMount(() => { loadHistory(); });
import Card from "$lib/components/Card.svelte"; import Card from "$lib/components/Card.svelte";
import EmptyState from "$lib/components/EmptyState.svelte"; import EmptyState from "$lib/components/EmptyState.svelte";
import { formatTime, formatDuration } from "$lib/utils/time.js"; import { formatTime, formatDuration } from "$lib/utils/time.js";
@@ -17,29 +21,36 @@
let duration = $state(0); let duration = $state(0);
let playbackRate = $state(1); let playbackRate = $state(1);
let animFrameId = null; let animFrameId = null;
let searchTimeout = null;
onDestroy(() => { onDestroy(() => {
stopPlayback(); stopPlayback();
clearTimeout(searchTimeout);
}); });
let filtered = $derived( // Debounced FTS5 search — 300ms delay
searchQuery $effect(() => {
? history.filter((h) => { const q = searchQuery;
const q = searchQuery.toLowerCase(); clearTimeout(searchTimeout);
return ( if (!q || !q.trim()) {
(h.text && h.text.toLowerCase().includes(q)) || // Restore full list when search cleared
(h.preview && h.preview.toLowerCase().includes(q)) || loadHistory();
(h.source && h.source.toLowerCase().includes(q)) || return;
(h.title && h.title.toLowerCase().includes(q)) }
); searchTimeout = setTimeout(() => {
}) searchTranscripts(q);
: history }, 300);
); });
function clearAll() { // The history array IS the filtered results — FTS5 handles filtering server-side
let filtered = $derived(history);
async function clearAll() {
if (!confirm("Delete all history? This can't be undone.")) return; if (!confirm("Delete all history? This can't be undone.")) return;
history.splice(0); // Delete each transcript from the backend
saveHistory(); for (const item of [...history]) {
await deleteTranscript(item.id);
}
expandedId = null; expandedId = null;
stopPlayback(); stopPlayback();
} }
@@ -52,21 +63,18 @@
invoke("copy_to_clipboard", { text: item.text }).catch(() => {}); invoke("copy_to_clipboard", { text: item.text }).catch(() => {});
} }
function removeItem(item) { async function removeItem(item) {
const idx = history.indexOf(item); await deleteTranscript(item.id);
if (idx !== -1) {
deleteFromHistory(idx);
if (expandedId === item.id) expandedId = null; if (expandedId === item.id) expandedId = null;
if (playingId === item.id) stopPlayback(); if (playingId === item.id) stopPlayback();
} }
}
function renameItem(item) { function renameItem(item) {
const name = prompt("Name this transcript:", item.title || ""); const name = prompt("Name this transcript:", item.title || "");
if (name !== null) { if (name !== null) {
item.title = name.trim(); item.title = name.trim();
item.preview = name.trim() ? `${name.trim()}${item.text.slice(0, 80)}` : item.text.slice(0, 120); item.preview = name.trim() ? `${name.trim()}${(item.text || '').slice(0, 80)}` : (item.text || '').slice(0, 120);
saveHistory(); // TODO: Persist rename to SQLite when update_transcript command is added
} }
} }

View File

@@ -0,0 +1,188 @@
/**
* History store backed by SQLite via Tauri commands.
* Replaces the localStorage-based history management in page.svelte.js.
*
* All mutations call the backend first, then update the reactive state
* on success. FTS5 search is handled server-side.
*/
import { invoke } from '@tauri-apps/api/core';
// Reactive state
let transcripts = $state([]);
let loading = $state(false);
let searchActive = $state(false);
// BroadcastChannel for multi-window sync
const historyChannel = typeof BroadcastChannel !== 'undefined'
? new BroadcastChannel('kon_history_v2')
: null;
if (historyChannel) {
historyChannel.onmessage = (event) => {
if (event.data.type === 'history_updated') {
transcripts.length = 0;
transcripts.push(...event.data.transcripts);
}
};
}
function broadcastHistory() {
if (historyChannel) {
historyChannel.postMessage({
type: 'history_updated',
transcripts: $state.snapshot(transcripts),
});
}
}
/**
* Load transcription history from the SQLite backend.
* @param {number} [limit=100] - Maximum number of results
*/
export async function loadHistory(limit = 100) {
loading = true;
searchActive = false;
try {
const result = await invoke('list_all_transcripts', { limit });
transcripts.length = 0;
transcripts.push(...result);
} catch (e) {
console.error('Failed to load history:', e);
} finally {
loading = false;
}
}
/**
* Save a completed transcription to the SQLite backend.
* Also saves segments if provided.
*
* @param {object} entry - Transcript data matching the save_transcript command params
* @returns {Promise<boolean>} Whether the save succeeded
*/
export async function saveTranscript(entry) {
try {
await invoke('save_transcript', {
id: entry.id,
text: entry.text || '',
source: entry.source || 'microphone',
title: entry.title || null,
audioPath: entry.audioPath || null,
duration: entry.duration || 0,
engine: entry.engine || null,
modelId: entry.modelId || null,
inferenceMs: entry.inferenceMs || null,
sampleRate: entry.sampleRate || null,
audioChannels: entry.audioChannels || null,
formatMode: entry.formatMode || null,
removeFillers: entry.removeFillers ?? false,
britishEnglish: entry.britishEnglish ?? false,
antiHallucination: entry.antiHallucination ?? false,
});
// Save segments if provided
if (entry.segments && entry.segments.length > 0) {
const segmentTuples = entry.segments.map(s => [
s.start ?? s.startTime ?? 0,
s.end ?? s.endTime ?? 0,
s.text || '',
]);
await invoke('save_segments', {
transcriptId: entry.id,
segments: segmentTuples,
});
}
// Optimistic local update — add to front of list
transcripts.unshift({
id: entry.id,
text: entry.text,
source: entry.source,
title: entry.title || null,
audioPath: entry.audioPath || null,
duration: entry.duration || 0,
engine: entry.engine || null,
modelId: entry.modelId || null,
inferenceMs: entry.inferenceMs || null,
sampleRate: entry.sampleRate || null,
audioChannels: entry.audioChannels || null,
formatMode: entry.formatMode || null,
removeFillers: entry.removeFillers ?? false,
britishEnglish: entry.britishEnglish ?? false,
antiHallucination: entry.antiHallucination ?? false,
createdAt: new Date().toISOString(),
// Keep original fields for UI compatibility during migration
date: entry.date || new Date().toLocaleString('en-GB'),
preview: entry.preview || (entry.text || '').slice(0, 120),
segments: entry.segments || [],
language: entry.language,
template: entry.template,
});
broadcastHistory();
return true;
} catch (e) {
console.error('Failed to save transcript:', e);
return false;
}
}
/**
* Delete a transcript by ID.
* @param {string} id - Transcript ID
*/
export async function deleteTranscript(id) {
try {
await invoke('delete_transcript_by_id', { id });
const idx = transcripts.findIndex(t => t.id === id);
if (idx >= 0) transcripts.splice(idx, 1);
broadcastHistory();
} catch (e) {
console.error('Failed to delete transcript:', e);
}
}
/**
* Full-text search across transcripts via FTS5.
* Replaces the local state with search results.
* @param {string} query - Search query
* @param {number} [limit=50] - Maximum results
*/
export async function searchTranscripts(query, limit = 50) {
if (!query || !query.trim()) {
// Empty query — restore full list
await loadHistory();
return;
}
loading = true;
searchActive = true;
try {
const result = await invoke('search_all_transcripts', {
query: query.trim(),
limit,
});
transcripts.length = 0;
transcripts.push(...result);
} catch (e) {
// FTS5 can throw on malformed queries — fall back to full list
console.error('Search failed, falling back to full list:', e);
await loadHistory();
} finally {
loading = false;
}
}
/** Get the reactive transcripts array. */
export function getHistory() {
return transcripts;
}
/** Whether history is currently loading from the backend. */
export function getLoading() {
return loading;
}
/** Whether a search filter is currently active. */
export function isSearchActive() {
return searchActive;
}

View File

@@ -72,7 +72,9 @@ export function saveProfiles() {
} catch {} } catch {}
} }
// ---- History (persisted to localStorage) ---- // ---- History (DEPRECATED — migrated to history.svelte.js backed by SQLite) ----
// TODO: Remove localStorage history code after full migration verified.
// Kept temporarily for backwards compatibility with any unmigrated consumers.
const HISTORY_KEY = "kon_history"; const HISTORY_KEY = "kon_history";