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:
@@ -2,7 +2,8 @@
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
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";
|
||||
const tasks = getTasks();
|
||||
import Card from "$lib/components/Card.svelte";
|
||||
@@ -378,7 +379,7 @@
|
||||
}
|
||||
|
||||
const historyId = crypto.randomUUID();
|
||||
addToHistory({
|
||||
saveTranscript({
|
||||
id: historyId,
|
||||
date: new Date().toLocaleString("en-GB"),
|
||||
source: "live",
|
||||
@@ -389,6 +390,11 @@
|
||||
language: settings.language,
|
||||
template: activeTemplate || undefined,
|
||||
audioPath,
|
||||
engine: settings.engine,
|
||||
formatMode: settings.formatMode,
|
||||
removeFillers: settings.removeFillers,
|
||||
britishEnglish: settings.britishEnglish,
|
||||
antiHallucination: settings.antiHallucination,
|
||||
});
|
||||
|
||||
// Extract tasks from transcript
|
||||
@@ -484,7 +490,7 @@
|
||||
|
||||
function saveTypedText() {
|
||||
if (!transcript.trim()) return;
|
||||
addToHistory({
|
||||
saveTranscript({
|
||||
id: crypto.randomUUID(),
|
||||
date: new Date().toLocaleString("en-GB"),
|
||||
source: "typed",
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
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 EmptyState from "$lib/components/EmptyState.svelte";
|
||||
import { exportTranscript } from "$lib/utils/export.js";
|
||||
@@ -91,16 +92,9 @@
|
||||
|
||||
segments = [...segments, ...result.segments];
|
||||
|
||||
addToHistory({
|
||||
id: crypto.randomUUID(),
|
||||
date: new Date().toLocaleString("en-GB"),
|
||||
source: fileName,
|
||||
preview: text.slice(0, 120),
|
||||
text,
|
||||
segments: result.segments,
|
||||
duration: result.duration,
|
||||
language: result.language,
|
||||
});
|
||||
// transcribe_file auto-persists to SQLite (Task 2).
|
||||
// Refresh local history state to pick up the new entry.
|
||||
loadHistory();
|
||||
} catch (err) {
|
||||
error = `Failed: ${fileName} — ${typeof err === "string" ? err : err.message}`;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<script>
|
||||
import { onDestroy } from "svelte";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
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";
|
||||
|
||||
const history = getHistory();
|
||||
|
||||
onMount(() => { loadHistory(); });
|
||||
import Card from "$lib/components/Card.svelte";
|
||||
import EmptyState from "$lib/components/EmptyState.svelte";
|
||||
import { formatTime, formatDuration } from "$lib/utils/time.js";
|
||||
@@ -17,29 +21,36 @@
|
||||
let duration = $state(0);
|
||||
let playbackRate = $state(1);
|
||||
let animFrameId = null;
|
||||
let searchTimeout = null;
|
||||
|
||||
onDestroy(() => {
|
||||
stopPlayback();
|
||||
clearTimeout(searchTimeout);
|
||||
});
|
||||
|
||||
let filtered = $derived(
|
||||
searchQuery
|
||||
? history.filter((h) => {
|
||||
const q = searchQuery.toLowerCase();
|
||||
return (
|
||||
(h.text && h.text.toLowerCase().includes(q)) ||
|
||||
(h.preview && h.preview.toLowerCase().includes(q)) ||
|
||||
(h.source && h.source.toLowerCase().includes(q)) ||
|
||||
(h.title && h.title.toLowerCase().includes(q))
|
||||
);
|
||||
})
|
||||
: history
|
||||
);
|
||||
// Debounced FTS5 search — 300ms delay
|
||||
$effect(() => {
|
||||
const q = searchQuery;
|
||||
clearTimeout(searchTimeout);
|
||||
if (!q || !q.trim()) {
|
||||
// Restore full list when search cleared
|
||||
loadHistory();
|
||||
return;
|
||||
}
|
||||
searchTimeout = setTimeout(() => {
|
||||
searchTranscripts(q);
|
||||
}, 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;
|
||||
history.splice(0);
|
||||
saveHistory();
|
||||
// Delete each transcript from the backend
|
||||
for (const item of [...history]) {
|
||||
await deleteTranscript(item.id);
|
||||
}
|
||||
expandedId = null;
|
||||
stopPlayback();
|
||||
}
|
||||
@@ -52,21 +63,18 @@
|
||||
invoke("copy_to_clipboard", { text: item.text }).catch(() => {});
|
||||
}
|
||||
|
||||
function removeItem(item) {
|
||||
const idx = history.indexOf(item);
|
||||
if (idx !== -1) {
|
||||
deleteFromHistory(idx);
|
||||
if (expandedId === item.id) expandedId = null;
|
||||
if (playingId === item.id) stopPlayback();
|
||||
}
|
||||
async function removeItem(item) {
|
||||
await deleteTranscript(item.id);
|
||||
if (expandedId === item.id) expandedId = null;
|
||||
if (playingId === item.id) stopPlayback();
|
||||
}
|
||||
|
||||
function renameItem(item) {
|
||||
const name = prompt("Name this transcript:", item.title || "");
|
||||
if (name !== null) {
|
||||
item.title = name.trim();
|
||||
item.preview = name.trim() ? `${name.trim()} — ${item.text.slice(0, 80)}` : item.text.slice(0, 120);
|
||||
saveHistory();
|
||||
item.preview = name.trim() ? `${name.trim()} — ${(item.text || '').slice(0, 80)}` : (item.text || '').slice(0, 120);
|
||||
// TODO: Persist rename to SQLite when update_transcript command is added
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
188
src/lib/stores/history.svelte.js
Normal file
188
src/lib/stores/history.svelte.js
Normal 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;
|
||||
}
|
||||
@@ -72,7 +72,9 @@ export function saveProfiles() {
|
||||
} 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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user