/** @type {{ current: string, status: string, statusColor: string, activeProfile: string, recording: boolean, timerText: string, handedness: string, taskSidebarOpen: boolean }} */ export const page = $state({ current: "dictation", status: "Ready", statusColor: "#7ec89a", activeProfile: "None", recording: false, timerText: "00:00", handedness: "Right", taskSidebarOpen: false, }); // ---- Settings (persisted to localStorage) ---- const SETTINGS_KEY = "kon_settings"; const defaults = { engine: "whisper", modelSize: "Base", language: "en", device: "auto", formatMode: "Smart", removeFillers: true, antiHallucination: true, britishEnglish: true, autoCopy: true, includeTimestamps: true, theme: "Dark", fontSize: 14, llmModelSize: "small", llmEnabled: false, saveAudio: false, outputFolder: "", globalHotkey: "Ctrl+Shift+R", sidebarCollapsed: false, // Empty string = let backend auto-select. Otherwise, exact device name as // returned by `list_audio_devices`. Set via Settings → Audio → Microphone. microphoneDevice: "", }; function loadSettings() { try { const raw = localStorage.getItem(SETTINGS_KEY); if (raw) return { ...defaults, ...JSON.parse(raw) }; } catch {} return { ...defaults }; } export const settings = $state(loadSettings()); /** Save current settings to localStorage */ export function saveSettings() { try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } catch {} } // ---- Profiles (persisted to localStorage) ---- const PROFILES_KEY = "kon_profiles"; function loadProfiles() { try { const raw = localStorage.getItem(PROFILES_KEY); if (raw) return JSON.parse(raw); } catch {} return []; } export const profiles = $state(loadProfiles()); export function saveProfiles() { try { localStorage.setItem(PROFILES_KEY, JSON.stringify(profiles)); } catch {} } // ---- History (dual-write: SQLite via Tauri = canonical, localStorage = read-cache) ---- // // As of Day 4 of the upgrade plan, the canonical store is the SQLite // transcripts table reachable via the `add_transcript`, `list_transcripts`, // `update_transcript`, `delete_transcript`, `search_transcripts` Tauri // commands. localStorage continues to back the in-memory `history` array // for UI snappiness and offline browser-preview support, but the source // of truth is SQLite. Browser preview falls back to localStorage only. import { invoke } from "@tauri-apps/api/core"; import { hasTauriRuntime } from "$lib/utils/runtime.js"; const HISTORY_KEY = "kon_history"; function loadHistory() { try { const raw = localStorage.getItem(HISTORY_KEY); if (raw) return JSON.parse(raw); } catch {} return []; } export const history = $state(loadHistory()); export function saveHistory() { try { localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); } catch {} } /** * Add a transcript to history. Dual-writes to SQLite (canonical) and * localStorage (cache). The SQLite write is best-effort: if it fails, * we still update the in-memory + localStorage copy so the UI stays * responsive. The next session boot reconciles by reading SQLite first. * * `entry` shape: * { id, text, source?, title?, audioPath?, duration?, engine?, modelId?, * inferenceMs?, sampleRate?, audioChannels?, formatMode?, * removeFillers?, britishEnglish?, antiHallucination?, ...uiOnlyFields } */ export async function addToHistory(entry) { history.unshift(entry); if (history.length > 100) history.length = 100; saveHistory(); if (!hasTauriRuntime()) return; // Browser preview: localStorage only. try { await invoke("add_transcript", { transcript: { id: String(entry.id), text: entry.text ?? "", source: entry.source ?? "microphone", title: entry.title ?? null, audioPath: entry.audioPath ?? null, duration: Number(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, britishEnglish: !!entry.britishEnglish, antiHallucination: !!entry.antiHallucination, }, }); } catch (err) { console.warn("addToHistory: SQLite dual-write failed, kept in localStorage", err); } } /** * Update text and/or title of an existing transcript. Persists to SQLite * via update_transcript; updates the in-memory + localStorage cache. * Closes the historic "rename never persists" bug from * architecture-review.md §13. */ export async function renameHistoryEntry(id, updates) { const idx = history.findIndex(h => String(h.id) === String(id)); if (idx >= 0) { if (updates.title !== undefined) history[idx].title = updates.title; if (updates.text !== undefined) history[idx].text = updates.text; saveHistory(); } if (!hasTauriRuntime()) return; try { await invoke("update_transcript", { id: String(id), text: updates.text ?? null, title: updates.title ?? null, }); } catch (err) { console.warn("renameHistoryEntry: SQLite update failed", err); throw err; } } export function deleteFromHistory(index) { const entry = history[index]; history.splice(index, 1); saveHistory(); if (!hasTauriRuntime() || !entry?.id) return; invoke("delete_transcript", { id: String(entry.id) }) .catch(err => console.warn("deleteFromHistory: SQLite delete failed", err)); } // ---- Tasks (persisted to localStorage) ---- const TASKS_KEY = "kon_tasks"; function loadTasks() { try { const raw = localStorage.getItem(TASKS_KEY); if (raw) return JSON.parse(raw); } catch {} return []; } export const tasks = $state(loadTasks()); export function saveTasks() { try { localStorage.setItem(TASKS_KEY, JSON.stringify(tasks)); } catch {} } export function addTask(task) { tasks.unshift({ id: crypto.randomUUID(), text: task.text, bucket: task.bucket || "inbox", listId: task.listId || null, context: task.context || "", effort: task.effort || "", done: false, doneAt: null, createdAt: new Date().toISOString(), sourceTranscriptId: task.sourceTranscriptId || null, notes: "", }); saveTasks(); broadcastTasks(); } export function updateTask(id, updates) { const idx = tasks.findIndex((t) => t.id === id); if (idx >= 0) { Object.assign(tasks[idx], updates); saveTasks(); broadcastTasks(); } } export function deleteTask(id) { const idx = tasks.findIndex((t) => t.id === id); if (idx >= 0) { tasks.splice(idx, 1); saveTasks(); broadcastTasks(); } } export function completeTask(id) { updateTask(id, { done: true, doneAt: new Date().toISOString() }); } export function uncompleteTask(id) { updateTask(id, { done: false, doneAt: null }); } // ---- BroadcastChannel for multi-window task sync ---- const taskChannel = typeof BroadcastChannel !== "undefined" ? new BroadcastChannel("kon_tasks") : null; if (taskChannel) { taskChannel.onmessage = (event) => { if (event.data.type === "tasks_updated") { tasks.length = 0; tasks.push(...event.data.tasks); } }; } function broadcastTasks() { if (taskChannel) { taskChannel.postMessage({ type: "tasks_updated", tasks: $state.snapshot(tasks) }); } } // ---- Task Lists (persisted to localStorage) ---- const TASK_LISTS_KEY = "kon_task_lists"; function loadTaskLists() { try { const raw = localStorage.getItem(TASK_LISTS_KEY); if (raw) return JSON.parse(raw); } catch {} return [ { id: "all", name: "All Tasks", builtIn: true, createdAt: null }, { id: "inbox", name: "Inbox", builtIn: true, createdAt: null }, ]; } export const taskLists = $state(loadTaskLists()); export function saveTaskLists() { try { localStorage.setItem(TASK_LISTS_KEY, JSON.stringify(taskLists)); } catch {} broadcastTaskLists(); } export function addTaskList(name, profileId = null) { taskLists.push({ id: crypto.randomUUID(), name, builtIn: false, profileId, createdAt: new Date().toISOString(), }); saveTaskLists(); } export function addProfileTaskList(profileName) { const exists = taskLists.find((l) => l.profileId === profileName); if (exists) return exists.id; const id = crypto.randomUUID(); taskLists.push({ id, name: profileName, builtIn: false, profileId: profileName, createdAt: new Date().toISOString(), }); saveTaskLists(); return id; } export function removeProfileTaskList(profileName) { const idx = taskLists.findIndex((l) => l.profileId === profileName); if (idx >= 0) { const listId = taskLists[idx].id; for (const t of tasks) { if (t.listId === listId) t.listId = null; } saveTasks(); broadcastTasks(); taskLists.splice(idx, 1); saveTaskLists(); } } export function moveTaskToList(taskId, listId) { updateTask(taskId, { listId: listId === "inbox" || listId === "all" ? null : listId }); } export function moveTaskListToProfile(listId, profileId) { const list = taskLists.find((l) => l.id === listId); if (list && !list.builtIn) { list.profileId = profileId || null; if (profileId && !list.name) list.name = profileId; saveTaskLists(); } } export function renameTaskList(id, name) { const list = taskLists.find((l) => l.id === id); if (list && !list.builtIn) { list.name = name; saveTaskLists(); } } export function deleteTaskList(id) { const idx = taskLists.findIndex((l) => l.id === id); if (idx >= 0 && !taskLists[idx].builtIn) { for (const t of tasks) { if (t.listId === id) t.listId = null; } saveTasks(); broadcastTasks(); taskLists.splice(idx, 1); saveTaskLists(); } } export function moveTaskList(id, direction) { const idx = taskLists.findIndex((l) => l.id === id); const targetIdx = idx + direction; if (targetIdx < 0 || targetIdx >= taskLists.length) return; if (taskLists[targetIdx].builtIn) return; [taskLists[idx], taskLists[targetIdx]] = [taskLists[targetIdx], taskLists[idx]]; saveTaskLists(); } export function sortTaskLists(mode) { const builtIn = taskLists.filter((l) => l.builtIn); const custom = taskLists.filter((l) => !l.builtIn); if (mode === "alpha") { custom.sort((a, b) => a.name.localeCompare(b.name)); } else if (mode === "date") { custom.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)); } taskLists.length = 0; taskLists.push(...builtIn, ...custom); saveTaskLists(); } // BroadcastChannel for task lists const taskListChannel = typeof BroadcastChannel !== "undefined" ? new BroadcastChannel("kon_task_lists") : null; if (taskListChannel) { taskListChannel.onmessage = (event) => { if (event.data.type === "task_lists_updated") { taskLists.length = 0; taskLists.push(...event.data.lists); } }; } function broadcastTaskLists() { if (taskListChannel) { taskListChannel.postMessage({ type: "task_lists_updated", lists: $state.snapshot(taskLists) }); } } // ---- Templates (persisted to localStorage) ---- const TEMPLATES_KEY = "kon_templates"; function loadTemplates() { try { const raw = localStorage.getItem(TEMPLATES_KEY); if (raw) return JSON.parse(raw); } catch {} return [ { name: "Meeting Notes", sections: ["Attendees", "Agenda", "Discussion", "Action Items", "Next Steps"] }, { name: "Report", sections: ["Summary", "Background", "Findings", "Recommendations"] }, { name: "Interview", sections: ["Candidate", "Role", "Questions & Answers", "Assessment", "Next Steps"] }, ]; } export const templates = $state(loadTemplates()); export function saveTemplates() { try { localStorage.setItem(TEMPLATES_KEY, JSON.stringify(templates)); } catch {} }