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 { 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",
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 {}
|
} 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";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user