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

@@ -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;
}