import { invoke } from "@tauri-apps/api/core"; import type { EnergyLevel, PageState, Profile, Segment, SettingsState, TaskDraft, TaskDto, TaskEntry, TaskList, TaskUpdate, Template, TranscriptDto, TranscriptEntry, TranscriptMetaPatch, TranscriptWriteEntry, } from "$lib/types/app"; import { toasts } from "$lib/stores/toasts.svelte.ts"; import { errorMessage } from "$lib/utils/errors.js"; import { parseStoredJson } from "$lib/utils/storage.js"; import { loadSettingsWithMigration, saveSettingsWithVersion, } from "$lib/utils/settingsMigrations.js"; import { hasTauriRuntime } from "$lib/utils/runtime.js"; export const page = $state({ current: "dictation", status: "Ready", statusColor: "#7ec89a", activeProfile: "None", recording: false, timerText: "00:00", handedness: "Right", taskSidebarOpen: false, }); import { migrateLocalStorageKeys } from "$lib/utils/localStorageMigration"; const SETTINGS_KEY = "lumotia_settings"; const PROFILES_KEY = "lumotia_profiles"; const TASK_LISTS_KEY = "lumotia_task_lists"; const TEMPLATES_KEY = "lumotia_templates"; // One-shot key rename from the magnotia era. Idempotent. Runs once at // module load before any localStorage read below. migrateLocalStorageKeys([ ["magnotia_settings", SETTINGS_KEY], ["magnotia_profiles", PROFILES_KEY], ["magnotia_task_lists", TASK_LISTS_KEY], ["magnotia_templates", TEMPLATES_KEY], ]); const defaults: SettingsState = { engine: "whisper", modelSize: "Base", language: "en", device: "auto", formatMode: "Smart", removeFillers: true, antiHallucination: true, britishEnglish: true, autoCopy: true, autoPaste: false, transcriptionPreview: false, meetingAutoCapture: false, meetingAutoCaptureApps: ["zoom", "teams"], soundCues: false, soundCueVolume: 0.15, includeTimestamps: true, theme: "Dark", fontSize: 14, aiTier: "cleanup", llmModelId: null, llmPromptPreset: "default", aiGpuConcurrency: "parallel", prewarmModelOnStartup: false, saveAudio: false, outputFolder: "", globalHotkey: "Ctrl+Shift+R", sidebarCollapsed: false, microphoneDevice: "", currentEnergy: null, matchMyEnergy: false, ttsVoice: null, ttsRate: 1.0, ritualsMorning: false, ritualsMorningTime: "08:00", ritualsEvening: false, launchAtLogin: false, ritualsPromptSeen: false, nudgesEnabled: false, nudgesMuted: false, nudgesSpeakAloud: false, showMomentumSparkline: true, }; function canUseStorage(): boolean { return typeof localStorage !== "undefined"; } function loadSettings(): SettingsState { if (!canUseStorage()) return { ...defaults }; // Versioned migration: reads the envelope or an unversioned legacy // blob, runs the migration chain (currently identity: bare object -> // v1), and returns the current-shape data. Falls back to defaults // on corruption — the toast below surfaces the reset to the user. const migrated = loadSettingsWithMigration>(SETTINGS_KEY); if (migrated === null && localStorage.getItem(SETTINGS_KEY) !== null) { // There was data but it failed to migrate — tell the user we // reset rather than silently mangling their preferences. queueMicrotask(() => { toasts.warn( "Settings reset", "Your saved settings couldn't be read, so Lumotia fell back to defaults. " + "Earlier Lumotia builds can still read your old data.", ); }); } return { ...defaults, ...(migrated ?? {}), }; } export const settings = $state(loadSettings()); export function saveSettings() { if (!canUseStorage()) return; // Versioned envelope {version, data}. Older Lumotia builds that used // the bare-object layout can still spread the `data` key over // their defaults — they will just silently drop fields they don't // know about, which is the same behaviour as today. saveSettingsWithVersion(SETTINGS_KEY, { ...settings }); } function loadProfiles(): Profile[] { if (!canUseStorage()) return []; return parseStoredJson(localStorage.getItem(PROFILES_KEY)) ?? []; } export const profiles = $state(loadProfiles()); export function saveProfiles() { if (!canUseStorage()) return; try { localStorage.setItem(PROFILES_KEY, JSON.stringify(profiles)); } catch {} } export const history = $state([]); export function mapTranscriptRow(row: TranscriptDto): TranscriptEntry { const text = row.text ?? ""; const rawTags = row.manualTags ?? ""; const manualTags = rawTags ? rawTags.split(",").filter(Boolean) : []; const rawLlmTags = row.llmTags ?? ""; const llmTags = rawLlmTags ? rawLlmTags.split(",").filter(Boolean) : []; const rawSegments = row.segmentsJson ?? ""; let segments: Segment[] = []; if (rawSegments) { try { const parsed = JSON.parse(rawSegments); if (Array.isArray(parsed)) segments = parsed as Segment[]; } catch (err) { console.warn("mapTranscriptRow: segmentsJson parse failed", err); } } return { id: row.id, text, source: row.source ?? "", profileId: row.profileId ?? "00000000-0000-0000-0000-000000000001", title: row.title ?? "", audioPath: row.audioPath ?? null, duration: Number(row.duration ?? 0), engine: row.engine ?? null, modelId: row.modelId ?? null, createdAt: row.createdAt ?? null, date: row.createdAt ? new Date(row.createdAt).toLocaleString("en-GB") : "", preview: text.slice(0, 120), starred: !!row.starred, manualTags, template: row.template ?? "", language: row.language ?? "", segments, llmTags, }; } function normaliseTranscriptEntry(entry: TranscriptWriteEntry): TranscriptEntry { const createdAt = entry.createdAt ?? new Date().toISOString(); return { id: String(entry.id), text: entry.text ?? "", source: entry.source ?? "microphone", profileId: entry.profileId ?? "00000000-0000-0000-0000-000000000001", title: entry.title ?? "", audioPath: entry.audioPath ?? null, duration: Number(entry.duration ?? 0), engine: entry.engine ?? null, modelId: entry.modelId ?? null, createdAt, date: entry.date ?? new Date(createdAt).toLocaleString("en-GB"), preview: entry.preview ?? entry.text.slice(0, 120), starred: !!entry.starred, manualTags: Array.isArray(entry.manualTags) ? entry.manualTags : [], template: entry.template ?? "", language: entry.language ?? "", segments: Array.isArray(entry.segments) ? entry.segments : [], llmTags: Array.isArray(entry.llmTags) ? entry.llmTags : [], }; } async function loadHistory() { if (!hasTauriRuntime()) { history.length = 0; return; } try { const rows = await invoke("list_transcripts", { limit: 500, offset: 0 }); history.splice(0, history.length, ...(Array.isArray(rows) ? rows.map(mapTranscriptRow) : [])); } catch (err) { console.error("loadHistory failed", err); history.length = 0; toasts.error("Failed to load history", errorMessage(err)); } } if (typeof window !== "undefined" && hasTauriRuntime()) { loadHistory().catch(() => {}); } export async function addToHistory(entry: TranscriptWriteEntry) { const normalised = normaliseTranscriptEntry(entry); history.unshift(normalised); if (history.length > 500) history.length = 500; if (!hasTauriRuntime()) return; try { await invoke("add_transcript", { transcript: { id: normalised.id, text: normalised.text, source: normalised.source, profileId: normalised.profileId, title: normalised.title || null, audioPath: normalised.audioPath ?? null, duration: normalised.duration, engine: normalised.engine ?? null, modelId: normalised.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 write failed, entry will not persist past restart", err); } } export async function renameHistoryEntry( id: string, updates: { title?: string; text?: string }, ) { const idx = history.findIndex((entry) => String(entry.id) === String(id)); if (idx >= 0) { if (updates.title !== undefined) history[idx].title = updates.title; if (updates.text !== undefined) history[idx].text = updates.text; } 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 async function saveTranscriptMeta(id: string, patch: TranscriptMetaPatch) { if (!hasTauriRuntime()) return; try { const payload = { starred: patch.starred, manualTags: patch.manualTags == null ? undefined : (Array.isArray(patch.manualTags) ? patch.manualTags.join(",") : String(patch.manualTags)), template: patch.template, language: patch.language, segmentsJson: patch.segments === undefined ? undefined : JSON.stringify(patch.segments), llmTags: patch.llmTags == null ? undefined : (Array.isArray(patch.llmTags) ? patch.llmTags.join(",") : String(patch.llmTags)), }; const row = await invoke("update_transcript_meta_cmd", { id: String(id), patch: payload, }); const idx = history.findIndex((entry) => String(entry.id) === String(id)); if (idx !== -1) history[idx] = mapTranscriptRow(row); } catch (err) { toasts.error("Failed to save transcript metadata", errorMessage(err)); } } export function deleteFromHistory(index: number) { const entry = history[index]; history.splice(index, 1); if (!hasTauriRuntime() || !entry?.id) return; invoke("delete_transcript", { id: String(entry.id) }) .catch((err) => console.warn("deleteFromHistory: SQLite delete failed", err)); } export function deleteFromHistoryById(id: string) { const idx = history.findIndex((entry) => String(entry.id) === String(id)); if (idx === -1) return; deleteFromHistory(idx); } export const tasks = $state([]); function mapTaskRow(row: TaskDto): TaskEntry { return { id: row.id, text: row.text ?? "", bucket: (row.bucket ?? "inbox") as TaskEntry["bucket"], listId: row.listId ?? null, effort: row.effort ?? "", notes: row.notes ?? "", done: !!row.done, doneAt: row.doneAt ?? null, createdAt: row.createdAt ?? new Date().toISOString(), sourceTranscriptId: row.sourceTranscriptId ?? null, parentTaskId: row.parentTaskId ?? null, energy: row.energy ?? null, }; } async function loadTasks() { if (!hasTauriRuntime()) { tasks.length = 0; return; } try { const rows = await invoke("list_tasks_cmd"); tasks.splice(0, tasks.length, ...(Array.isArray(rows) ? rows.map(mapTaskRow) : [])); } catch (err) { console.error("loadTasks failed", err); tasks.length = 0; toasts.error("Failed to load tasks", errorMessage(err)); } } if (typeof window !== "undefined" && hasTauriRuntime()) { loadTasks().catch(() => {}); } export function saveTasks() {} export async function addTask(task: TaskDraft) { if (!hasTauriRuntime()) return; const id = crypto.randomUUID(); try { const row = await invoke("create_task_cmd", { request: { id, text: task.text, bucket: task.bucket || "inbox", sourceTranscriptId: task.sourceTranscriptId || null, listId: task.listId ?? null, effort: task.effort ?? null, energy: task.energy ?? null, }, }); tasks.unshift(mapTaskRow(row)); broadcastTasks(); } catch (err) { toasts.error("Couldn't add task", errorMessage(err)); } } function applyLocalTaskUpdate(id: string, updates: Partial) { const idx = tasks.findIndex((task) => task.id === id); if (idx >= 0) { Object.assign(tasks[idx], updates); broadcastTasks(); } } export async function updateTask(id: string, updates: TaskUpdate) { if (!hasTauriRuntime()) { applyLocalTaskUpdate(id, updates as Partial); return; } try { const row = await invoke("update_task_cmd", { id, patch: { text: updates.text ?? null, bucket: updates.bucket ?? null, listId: updates.listId ?? null, effort: updates.effort ?? null, notes: updates.notes ?? null, }, }); const idx = tasks.findIndex((task) => task.id === id); if (idx >= 0) { tasks[idx] = mapTaskRow(row); broadcastTasks(); } } catch (err) { toasts.error("Couldn't update task", errorMessage(err)); } } /** * Phase 3: explicit tri-state energy setter. Lives outside `updateTask` * because the backend uses a dedicated command for clearing — see * `set_task_energy_cmd` in src-tauri/src/commands/tasks.rs. Pass `null` * to clear the tag entirely; pass an `EnergyLevel` string to set it. */ export async function setTaskEnergy(id: string, energy: EnergyLevel | null) { if (!hasTauriRuntime()) { applyLocalTaskUpdate(id, { energy }); return; } try { const row = await invoke("set_task_energy_cmd", { id, energy }); const idx = tasks.findIndex((task) => task.id === id); if (idx >= 0) { tasks[idx] = mapTaskRow(row); broadcastTasks(); } } catch (err) { toasts.error("Couldn't change energy", errorMessage(err)); } } export async function deleteTask(id: string) { if (!hasTauriRuntime()) return; try { await invoke("delete_task_cmd", { id }); const idx = tasks.findIndex((task) => task.id === id); if (idx >= 0) { tasks.splice(idx, 1); broadcastTasks(); if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("lumotia:task-deleted", { detail: { id } })); } } } catch (err) { toasts.error("Couldn't delete task", errorMessage(err)); } } export async function completeTask(id: string) { if (!hasTauriRuntime()) return; try { await invoke("complete_task_cmd", { id }); applyLocalTaskUpdate(id, { done: true, doneAt: new Date().toISOString() }); // Phase 6 nudge-bus signal. Fire-and-forget; the bus subscribes // to this to clear micro-step-idle timers for the completed task // and, later, to drive Phase 7 "after a task completes" rules. if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("lumotia:task-completed", { detail: { id } })); } } catch (err) { toasts.error("Couldn't complete task", errorMessage(err)); } } export async function uncompleteTask(id: string) { if (!hasTauriRuntime()) return; try { await invoke("uncomplete_task_cmd", { id }); applyLocalTaskUpdate(id, { done: false, doneAt: null }); if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("lumotia:task-uncompleted", { detail: { id } })); } } catch (err) { toasts.error("Couldn't uncomplete task", errorMessage(err)); } } interface TaskChannelMessage { type: "tasks_updated"; tasks: TaskEntry[]; } const taskChannel = typeof BroadcastChannel !== "undefined" ? new BroadcastChannel("lumotia_task_sync") : null; if (taskChannel) { taskChannel.onmessage = (event: MessageEvent) => { if (event.data.type === "tasks_updated") { tasks.length = 0; tasks.push(...event.data.tasks); } }; } function broadcastTasks() { if (!taskChannel) return; taskChannel.postMessage({ type: "tasks_updated", tasks: $state.snapshot(tasks), } satisfies TaskChannelMessage); } function defaultTaskLists(): TaskList[] { return [ { id: "all", name: "All Tasks", builtIn: true, createdAt: null }, { id: "inbox", name: "Inbox", builtIn: true, createdAt: null }, ]; } function loadTaskLists(): TaskList[] { if (!canUseStorage()) return defaultTaskLists(); return parseStoredJson(localStorage.getItem(TASK_LISTS_KEY)) ?? defaultTaskLists(); } export const taskLists = $state(loadTaskLists()); export function saveTaskLists() { if (canUseStorage()) { try { localStorage.setItem(TASK_LISTS_KEY, JSON.stringify(taskLists)); } catch {} } broadcastTaskLists(); } export function addTaskList(name: string, profileId: string | null = null) { taskLists.push({ id: crypto.randomUUID(), name, builtIn: false, profileId, createdAt: new Date().toISOString(), }); saveTaskLists(); } export function addProfileTaskList(profileName: string) { const existing = taskLists.find((list) => list.profileId === profileName); if (existing) return existing.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: string) { const idx = taskLists.findIndex((list) => list.profileId === profileName); if (idx < 0) return; const listId = taskLists[idx].id; for (const task of tasks) { if (task.listId === listId) task.listId = null; } saveTasks(); broadcastTasks(); taskLists.splice(idx, 1); saveTaskLists(); } export function moveTaskToList(taskId: string, listId: string) { updateTask(taskId, { listId: listId === "inbox" || listId === "all" ? null : listId }); } export function moveTaskListToProfile(listId: string, profileId: string | null) { const list = taskLists.find((entry) => entry.id === listId); if (list && !list.builtIn) { list.profileId = profileId || null; if (profileId && !list.name) list.name = profileId; saveTaskLists(); } } export function renameTaskList(id: string, name: string) { const list = taskLists.find((entry) => entry.id === id); if (list && !list.builtIn) { list.name = name; saveTaskLists(); } } export function deleteTaskList(id: string) { const idx = taskLists.findIndex((list) => list.id === id); if (idx < 0 || taskLists[idx].builtIn) return; for (const task of tasks) { if (task.listId === id) task.listId = null; } saveTasks(); broadcastTasks(); taskLists.splice(idx, 1); saveTaskLists(); } export function moveTaskList(id: string, direction: number) { const idx = taskLists.findIndex((list) => list.id === id); const targetIdx = idx + direction; if (idx < 0 || targetIdx < 0 || targetIdx >= taskLists.length) return; if (taskLists[targetIdx].builtIn) return; [taskLists[idx], taskLists[targetIdx]] = [taskLists[targetIdx], taskLists[idx]]; saveTaskLists(); } export function sortTaskLists(mode: "alpha" | "date") { const builtIn = taskLists.filter((list) => list.builtIn); const custom = taskLists.filter((list) => !list.builtIn); if (mode === "alpha") { custom.sort((a, b) => a.name.localeCompare(b.name)); } else { custom.sort( (a, b) => new Date(a.createdAt ?? 0).getTime() - new Date(b.createdAt ?? 0).getTime(), ); } taskLists.length = 0; taskLists.push(...builtIn, ...custom); saveTaskLists(); } interface TaskListChannelMessage { type: "task_lists_updated"; lists: TaskList[]; } const taskListChannel = typeof BroadcastChannel !== "undefined" ? new BroadcastChannel("lumotia_task_lists") : null; if (taskListChannel) { taskListChannel.onmessage = (event: MessageEvent) => { if (event.data.type === "task_lists_updated") { taskLists.length = 0; taskLists.push(...event.data.lists); } }; } function broadcastTaskLists() { if (!taskListChannel) return; taskListChannel.postMessage({ type: "task_lists_updated", lists: $state.snapshot(taskLists), } satisfies TaskListChannelMessage); } function defaultTemplates(): Template[] { 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"] }, ]; } function loadTemplates(): Template[] { if (!canUseStorage()) return defaultTemplates(); return parseStoredJson(localStorage.getItem(TEMPLATES_KEY)) ?? defaultTemplates(); } export const templates = $state(loadTemplates()); export function saveTemplates() { if (!canUseStorage()) return; try { localStorage.setItem(TEMPLATES_KEY, JSON.stringify(templates)); } catch {} }