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, }); const SETTINGS_KEY = "kon_settings"; const PROFILES_KEY = "kon_profiles"; const TASK_LISTS_KEY = "kon_task_lists"; const TEMPLATES_KEY = "kon_templates"; /** * B3.6 — exported energy-label defaults so the Settings "Reset to * defaults" button can pull them without reaching into the private * `defaults` block. Returns a fresh object on each call so callers * cannot mutate the source-of-truth. */ export function defaultEnergyLabels(): SettingsState["energyLabels"] { return { high: { label: "High", description: "Sharp focus, big lifts" }, medium: { label: "Medium", description: "Steady work, moderate lifts" }, brain_dead: { label: "Zero", description: "Rest only, low-touch admin" }, }; } 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, // B2b — additive schema fields. Fresh users get sane defaults; existing // users get them spread over their saved blob and then the migration // step in loadSettings() backfills nudgeMode from nudgesEnabled. lastLaunchAt: null, reentryFreshStartUntil: null, lastActiveProfileId: null, microStepGranularity: "default", // For fresh users we default off; existing users with nudgesEnabled // = true get bumped to "immediate" by the migration in loadSettings. nudgeMode: "off", nudgeDigestTimes: [], sparklineRangeDays: 7, // Defaults mirror current EnergyChip copy so existing behaviour is // unchanged until the user edits via the (B3.6) UI. energyLabels: defaultEnergyLabels(), // B3.6 — first-tap energy-prompt gates. Default ON for prompts; // never-shown date stamp; first-Zero one-shot unfired. The merge in // loadSettings will fill these on existing installs as well. energyPromptsEnabled: true, energyPromptLastShownDate: null, energyPromptZeroSeen: false, }; 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 Kon fell back to defaults. " + "Earlier Kon builds can still read your old data.", ); }); } const merged: SettingsState = { ...defaults, ...(migrated ?? {}), }; // B2b nudge migration: if the loaded blob carries a boolean // `nudgesEnabled` but no `nudgeMode`, derive the new tri-state from it. // We keep `nudgesEnabled` in the schema so callers that haven't been // migrated yet still see a sane value. The new `nudgeMode` is the // source of truth for the digest/immediate/off split going forward. // This is a *one-time* derivation: once the merged blob has nudgeMode // set explicitly (next save), the boolean stops driving anything. const blob = migrated as Partial | undefined; if (blob && blob.nudgeMode === undefined && typeof blob.nudgesEnabled === "boolean") { merged.nudgeMode = blob.nudgesEnabled ? "immediate" : "off"; } return merged; } export const settings = $state(loadSettings()); export function saveSettings() { if (!canUseStorage()) return; // Versioned envelope {version, data}. Older Kon 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(() => {}); } /** * Result of an `addToHistory` call. `persisted: true` means the entry is * safely on disk (Tauri SQLite write succeeded, or we're in the non-Tauri * shell where there is no disk). `persisted: false` means the in-memory * history was NOT updated either — the caller is responsible for keeping * the user's source content (e.g. the dictation draft) alive so a retry * is possible. Surfacing this is what closes the historical silent-loss * bug where SQLite failures were swallowed by `console.warn`. */ export interface AddToHistoryResult { persisted: boolean; error?: unknown; } export async function addToHistory(entry: TranscriptWriteEntry): Promise { const normalised = normaliseTranscriptEntry(entry); // Non-Tauri shell (browser preview, SSR test): no disk to fail. Update // the in-memory store and report success so callers don't think the // write was lost — there was no write to lose. if (!hasTauriRuntime()) { history.unshift(normalised); if (history.length > 500) history.length = 500; return { persisted: true }; } 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, }, }); // Only mutate the in-memory history after the disk write lands. // Adding to memory while the disk write fails is exactly the misleading // state PR 1.4 set out to fix — the row would appear in History, then // silently vanish on next launch. history.unshift(normalised); if (history.length > 500) history.length = 500; // Fire-and-forget auto-title. Same gate as the existing auto-cleanup // path (`aiTier !== "off" && formatMode !== "Raw"`) so users opt in // by the same Settings choice — no new toggle. Skipped silently if // the LLM isn't loaded; HistoryPage's per-row "Title" button is the // retry path. Never overwrites a title the caller already set. void maybeAutoGenerateTitle(normalised.id, normalised.text, normalised.title); return { persisted: true }; } catch (err) { console.warn("addToHistory: SQLite write failed, entry will not persist past restart", err); return { persisted: false, error: err }; } } /// Run the auto-title pass when the same gate that drives auto-cleanup /// is met. Best-effort: any failure (LLM not loaded, model rejected the /// transcript, sanitisation produced an empty string) is swallowed /// because the user can still title the row manually via the per-row /// button in History. async function maybeAutoGenerateTitle( id: string, text: string, existingTitle: string | undefined | null, ): Promise { if (!hasTauriRuntime()) return; if (settings.aiTier === "off" || settings.formatMode === "Raw") return; if (!text.trim()) return; if (existingTitle && existingTitle.trim()) return; try { const title = await invoke("generate_title_cmd", { transcript: text }); if (title && title.trim()) { await renameHistoryEntry(id, { title: title.trim() }); } } catch (err) { // Auto-title is a nice-to-have, not core flow. The most common // failure (LLM not loaded yet) is exactly the case where the // user can re-trigger via the per-row "Title" button. console.warn("maybeAutoGenerateTitle skipped", 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, archived: !!row.archived, archivedAt: row.archivedAt ?? 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 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(); } } /** * PR 1.5: replace an in-store task with a fresh DTO from the backend * (e.g. the auto-completed parent returned by `complete_subtask_cmd`). * No-op if the task is not in the store — that's expected when a parent * has already been pruned client-side. Broadcasts on success so other * windows pick up the new state. */ export function replaceTaskFromDto(row: TaskDto) { const idx = tasks.findIndex((task) => task.id === row.id); if (idx >= 0) { tasks[idx] = mapTaskRow(row); 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("kon: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("kon: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("kon: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("kon_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); } // B2a: task_lists are now persisted in SQLite (migration v1 created // the table; the actual CRUD wiring happens via the *_task_list_cmd // handlers). The frontend keeps the two synthetic built-in entries // (`all`, `inbox`) in-memory so the sidebar always shows them — neither // has a corresponding DB row. function defaultTaskLists(): TaskList[] { return [ { id: "all", name: "All Tasks", builtIn: true, createdAt: null }, { id: "inbox", name: "Inbox", builtIn: true, createdAt: null }, ]; } interface TaskListDto { id: string; name: string; builtIn: boolean; profileId: string | null; createdAt: string; } function mapTaskListRow(row: TaskListDto): TaskList { return { id: row.id, name: row.name, builtIn: !!row.builtIn, profileId: row.profileId ?? null, createdAt: row.createdAt ?? null, }; } export const taskLists = $state(defaultTaskLists()); async function loadTaskListsFromSqlite() { if (!hasTauriRuntime()) return; try { const rows = await invoke("list_task_lists_cmd"); const mapped = Array.isArray(rows) ? rows.map(mapTaskListRow) : []; taskLists.length = 0; taskLists.push(...defaultTaskLists(), ...mapped); } catch (err) { console.error("loadTaskListsFromSqlite failed", err); toasts.error("Failed to load task lists", errorMessage(err)); } } // One-time migration: pull the legacy `kon_task_lists` blob into SQLite // via import_task_lists_cmd. Idempotent because the import command // skips ids that already exist; we still only run it when the // localStorage key is present so we don't pay a no-op round trip on // every launch. The localStorage key is removed only on a successful // import so a transient backend error doesn't lose data. async function migrateTaskListsFromLocalStorage() { if (!canUseStorage()) return; const raw = localStorage.getItem(TASK_LISTS_KEY); if (!raw) return; const parsed = parseStoredJson(raw); if (!parsed || parsed.length === 0) { // Empty or corrupt — clear and skip so we don't keep retrying. localStorage.removeItem(TASK_LISTS_KEY); return; } // Drop the synthetic built-ins (`all`, `inbox`) — they're recreated // in-memory each launch and have no DB row, so importing them would // collide with the next launch's defaults. const importable = parsed.filter((l) => l.id !== "all" && l.id !== "inbox"); if (importable.length === 0) { localStorage.removeItem(TASK_LISTS_KEY); return; } try { const summary = await invoke<{ imported: number; skipped: number }>( "import_task_lists_cmd", { items: importable.map((l) => ({ id: l.id, name: l.name, builtIn: l.builtIn, profileId: l.profileId ?? null, createdAt: l.createdAt ?? null, })), }, ); // Only clear localStorage AFTER the import succeeds. localStorage.removeItem(TASK_LISTS_KEY); // Re-load from SQLite so the store reflects the imported rows. await loadTaskListsFromSqlite(); toasts.info( "Task lists migrated", `${summary.imported} list${summary.imported === 1 ? "" : "s"} moved to local database.`, ); } catch (err) { console.error("migrateTaskListsFromLocalStorage failed", err); toasts.error( "Couldn't migrate task lists", "Your data is safe — Kon will retry on next launch.", ); } } if (typeof window !== "undefined" && hasTauriRuntime()) { // Sequencing matters: load first so the store reflects existing DB // state, then run the import which is idempotent vs. that state. The // import re-loads on success so the toasted summary matches what's // visible. (async () => { await loadTaskListsFromSqlite(); await migrateTaskListsFromLocalStorage(); })().catch(() => {}); } export async function addTaskList(name: string, profileId: string | null = null) { if (!hasTauriRuntime()) return; const id = crypto.randomUUID(); try { const row = await invoke("create_task_list_cmd", { request: { id, name, builtIn: false, profileId, }, }); taskLists.push(mapTaskListRow(row)); broadcastTaskLists(); } catch (err) { toasts.error("Couldn't add list", errorMessage(err)); } } export async function addProfileTaskList(profileName: string) { const existing = taskLists.find((list) => list.profileId === profileName); if (existing) return existing.id; if (!hasTauriRuntime()) return null; const id = crypto.randomUUID(); try { const row = await invoke("create_task_list_cmd", { request: { id, name: profileName, builtIn: false, profileId: profileName, }, }); taskLists.push(mapTaskListRow(row)); broadcastTaskLists(); return id; } catch (err) { toasts.error("Couldn't add profile list", errorMessage(err)); return null; } } export async function removeProfileTaskList(profileName: string) { const idx = taskLists.findIndex((list) => list.profileId === profileName); if (idx < 0) return; const listId = taskLists[idx].id; if (!hasTauriRuntime()) return; try { await invoke("delete_task_list_cmd", { id: listId }); // delete_task_list nulls dependent tasks server-side; mirror that // locally so the UI doesn't show stale list pointers. for (const task of tasks) { if (task.listId === listId) task.listId = null; } broadcastTasks(); taskLists.splice(idx, 1); broadcastTaskLists(); } catch (err) { toasts.error("Couldn't remove profile list", errorMessage(err)); } } export function moveTaskToList(taskId: string, listId: string) { updateTask(taskId, { listId: listId === "inbox" || listId === "all" ? null : listId }); } export async function moveTaskListToProfile(listId: string, profileId: string | null) { const list = taskLists.find((entry) => entry.id === listId); if (!list || list.builtIn) return; if (!hasTauriRuntime()) return; try { const row = await invoke("update_task_list_cmd", { id: listId, patch: { // Only patch profile_id; leave name alone. profileId: profileId || null, }, }); const idx = taskLists.findIndex((l) => l.id === listId); if (idx >= 0) { taskLists[idx] = mapTaskListRow(row); broadcastTaskLists(); } } catch (err) { toasts.error("Couldn't move list", errorMessage(err)); } } export async function renameTaskList(id: string, name: string) { const list = taskLists.find((entry) => entry.id === id); if (!list || list.builtIn) return; if (!hasTauriRuntime()) return; try { const row = await invoke("update_task_list_cmd", { id, patch: { name }, }); const idx = taskLists.findIndex((l) => l.id === id); if (idx >= 0) { taskLists[idx] = mapTaskListRow(row); broadcastTaskLists(); } } catch (err) { toasts.error("Couldn't rename list", errorMessage(err)); } } export async function deleteTaskList(id: string) { const idx = taskLists.findIndex((list) => list.id === id); if (idx < 0 || taskLists[idx].builtIn) return; if (!hasTauriRuntime()) return; try { await invoke("delete_task_list_cmd", { id }); for (const task of tasks) { if (task.listId === id) task.listId = null; } broadcastTasks(); taskLists.splice(idx, 1); broadcastTaskLists(); } catch (err) { toasts.error("Couldn't delete list", errorMessage(err)); } } // NOTE: `moveTaskList` (manual reorder) and `sortTaskLists` mutate // the in-memory array only. Persisting display order requires a new // `display_order` column on `task_lists`; until then, the order survives // only for the current session. This is a known, scoped limitation // called out in B2a part 2. 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]]; broadcastTaskLists(); } 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); broadcastTaskLists(); } interface TaskListChannelMessage { type: "task_lists_updated"; lists: TaskList[]; } const taskListChannel = typeof BroadcastChannel !== "undefined" ? new BroadcastChannel("kon_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); } // B2b — templates moved from localStorage["kon_templates"] to SQLite // (migration v17). The store starts empty and is populated asynchronously // from the backend. The Dictation "Section scaffold" picker reads // `templates.name` / `templates.sections`, both of which survive the new // shape — the migration just gains `id`, `createdAt`, `updatedAt`. // // Default seeding is gated on a *truly fresh install* (no legacy // localStorage key AND empty SQLite), so existing users keep their // custom templates and never get the v3 defaults sprayed on top of // their data. interface TemplateDto { id: string; name: string; sections: string[]; createdAt: string; updatedAt: string; } /** * v3-spec defaults seeded only on a truly fresh install (no legacy * kon_templates and no rows in SQLite). Existing user templates * (Meeting Notes / Report / Interview from the pre-B2b defaults) are * preserved verbatim by the migration — they appear in localStorage * because the user's first launch wrote them out, so the "fresh * install" check correctly skips seeding for them. */ function freshInstallTemplateSeed() { return [ { name: "Meeting notes", sections: ["Action items", "Decisions", "Team members", "Open questions"] }, { name: "Daily check-in", sections: ["Wins", "Blockers", "Next"] }, ]; } function mapTemplateRow(row: TemplateDto): Template { return { id: row.id, name: row.name, sections: Array.isArray(row.sections) ? row.sections : [], createdAt: row.createdAt ?? "", updatedAt: row.updatedAt ?? "", }; } export const templates = $state([]); async function loadTemplatesFromSqlite(): Promise { if (!hasTauriRuntime()) return []; try { const rows = await invoke("list_templates_cmd"); const mapped = Array.isArray(rows) ? rows.map(mapTemplateRow) : []; templates.length = 0; templates.push(...mapped); return Array.isArray(rows) ? rows : []; } catch (err) { console.error("loadTemplatesFromSqlite failed", err); toasts.error("Failed to load templates", errorMessage(err)); return []; } } // One-time migration: pull the legacy kon_templates blob into SQLite. // Idempotent because import_templates_cmd skips ids that already exist; // we still gate on the localStorage key's presence to avoid a no-op // round-trip on every launch. The localStorage key is removed only on // a successful import so a transient backend error doesn't lose data. // // Each item gets a server-minted uuid because the legacy shape didn't // store one — TemplateImport.id is optional on purpose for this path. async function migrateTemplatesFromLocalStorage(): Promise { if (!canUseStorage()) return false; const raw = localStorage.getItem(TEMPLATES_KEY); if (!raw) return false; const parsed = parseStoredJson>(raw); if (!parsed || parsed.length === 0) { // Empty or corrupt — clear and skip so we don't keep retrying. localStorage.removeItem(TEMPLATES_KEY); return false; } try { const summary = await invoke<{ imported: number; skipped: number }>( "import_templates_cmd", { items: parsed.map((t) => ({ name: t.name, sections: Array.isArray(t.sections) ? t.sections : [], })), }, ); // Only clear localStorage AFTER the import succeeds. localStorage.removeItem(TEMPLATES_KEY); // Re-load from SQLite so the store reflects the imported rows. await loadTemplatesFromSqlite(); toasts.info( "Templates migrated", `${summary.imported} template${summary.imported === 1 ? "" : "s"} moved to local database.`, ); return true; } catch (err) { console.error("migrateTemplatesFromLocalStorage failed", err); toasts.error( "Couldn't migrate templates", "Your data is safe — Kon will retry on next launch.", ); return false; } } // Fresh-install seeding. Only runs when: // - SQLite returned zero rows (nothing migrated, nothing pre-existing) // - localStorage key is absent (no legacy data the migration could // have moved over — i.e. truly first launch) // Existing users with localStorage templates take the migration path // above; existing users who already migrated have non-empty SQLite and // fall through here without seeding. async function maybeSeedDefaultTemplates(sqliteRows: TemplateDto[]): Promise { if (!hasTauriRuntime()) return; if (sqliteRows.length > 0) return; if (canUseStorage() && localStorage.getItem(TEMPLATES_KEY) !== null) return; try { await invoke<{ imported: number; skipped: number }>("import_templates_cmd", { items: freshInstallTemplateSeed(), }); await loadTemplatesFromSqlite(); } catch (err) { console.error("maybeSeedDefaultTemplates failed", err); // Non-fatal: the user can still create templates by hand. } } if (typeof window !== "undefined" && hasTauriRuntime()) { // Sequencing: load → migrate-if-needed → seed-if-fresh-install. The // migration re-loads on success so the store reflects DB state at // each step, and the seeding gate sees the post-migration row count. (async () => { const initialRows = await loadTemplatesFromSqlite(); const migrated = await migrateTemplatesFromLocalStorage(); // After a migration the row count changed; re-fetch before deciding // whether to seed defaults. const rowsForSeed = migrated ? await invoke("list_templates_cmd").catch(() => []) : initialRows; await maybeSeedDefaultTemplates(Array.isArray(rowsForSeed) ? rowsForSeed : []); })().catch(() => {}); } export async function createTemplate(name: string, sections: string[]): Promise