refactor(frontend): migrate JS modules to TypeScript

Wholesale JS -> TS migration of the frontend — stores, utils, actions,
and all Svelte component scripts adopt type annotations. Compile-time
surfaces (app.d.ts, lib/types/) added for shared DTO types.

Build plumbing:
  - package.json: dev:frontend script that runs svelte-kit sync first
  - tauri.conf.json: beforeDevCommand points at dev:frontend
  - run.sh: dropped the sed-hack that temporarily blanked beforeDevCommand;
    now relies on npm run dev:frontend to avoid double-Vite
  - jsconfig.json: allowImportingTsExtensions

Preserves all Group 1 behaviour:
  - page.svelte.ts keeps loadHistory / loadTasks Tauri-first, no
    localStorage; saveTranscriptMeta + mapTranscriptRow + mapTaskRow
    intact; update_task_cmd and update_transcript_meta_cmd invocations
    carry the correct payload shape.
  - Toasts, preferences stores typed without behaviour change.
  - Viewer still routes segment edits through saveTranscriptMeta; the
    Task 1.5 TODO markers are gone.

taskExtractor.ts is functionally improved during the migration:
  - multi-task matches in the same sentence
  - list-style shopping-verb expansion (get bread, milk, and cheese)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-19 20:05:54 +01:00
parent 6605266587
commit d6bf9ed245
48 changed files with 1693 additions and 1156 deletions

View File

@@ -1,656 +0,0 @@
/** @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 (SQLite via Tauri = canonical; no localStorage cache) ----
//
// The canonical store is the SQLite transcripts table reachable via the
// `add_transcript`, `list_transcripts`, `update_transcript`,
// `delete_transcript`, `search_transcripts` Tauri commands. The history
// array is seeded empty on module init and hydrated from SQLite on boot
// in the desktop runtime. Browser preview (no Tauri) shows an empty list.
//
// The previous localStorage history read-cache was removed to kill the
// cold-start flicker where the UI painted stale cached data and then
// overwrote it from SQLite a tick later.
import { invoke } from "@tauri-apps/api/core";
import { hasTauriRuntime } from "$lib/utils/runtime.js";
import { toasts } from "$lib/stores/toasts.svelte.js";
export const history = $state([]);
/** Shape a TranscriptDto row from `list_transcripts` into the UI entry shape
* that HistoryPage expects.
*
* Task 2.5 — the meta columns (starred, manualTags, template, language,
* segments_json) are now carried by SQLite. `manualTags` is stored comma-
* joined in a single TEXT column (empty string = no tags); `segmentsJson`
* is a serialised JSON array of segment objects (empty string = no
* segments).
*/
function mapTranscriptRow(row) {
const text = row.text ?? "";
// manual_tags is a single TEXT column. Empty string ⇒ empty array.
const rawTags = row.manualTags ?? "";
const manualTags = rawTags ? rawTags.split(",").filter(Boolean) : [];
// segments_json is a serialised array. Empty string ⇒ empty array.
const rawSegments = row.segmentsJson ?? "";
let segments = [];
if (rawSegments) {
try {
const parsed = JSON.parse(rawSegments);
if (Array.isArray(parsed)) segments = parsed;
} catch (err) {
console.warn("mapTranscriptRow: segmentsJson parse failed", err);
}
}
return {
id: row.id,
text,
source: row.source ?? "",
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,
};
}
async function loadHistory() {
if (!hasTauriRuntime()) {
history.length = 0;
return;
}
try {
const rows = await invoke("list_transcripts", { limit: 500, offset: 0 });
const mapped = Array.isArray(rows) ? rows.map(mapTranscriptRow) : [];
history.splice(0, history.length, ...mapped);
} catch (err) {
console.error("loadHistory failed", err);
history.length = 0;
toasts.error("Failed to load history", err?.message ?? String(err));
}
}
// Hydrate history from SQLite on boot. Mirrors the tasks hydration pattern
// further down this file.
if (typeof window !== "undefined" && hasTauriRuntime()) {
loadHistory().catch(() => {});
}
// `saveHistory` is retained as a no-op for callers in HistoryPage that use
// it as a mutation hook (tag add/remove, clear-all). SQLite writes happen
// via the specific `add_transcript` / `update_transcript` /
// `update_transcript_meta_cmd` / `delete_transcript` commands in the
// functions below. HistoryPage's manualTags mutations will need to call
// `saveTranscriptMeta` directly (Task 2.5) to persist past reload.
export function saveHistory() {}
/**
* Add a transcript to history. Writes to SQLite (canonical) and updates
* the in-memory `history` array. SQLite is best-effort: if it fails we
* keep the in-memory copy so the UI stays responsive for the session,
* but the entry will not survive a restart.
*
* `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 > 500) history.length = 500;
if (!hasTauriRuntime()) return; // Browser preview: in-memory 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 write failed, entry will not persist past restart", err);
}
}
/**
* Update text and/or title of an existing transcript. Persists to SQLite
* via update_transcript; updates the in-memory entry. 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;
}
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;
}
}
/**
* Task 2.5 — persist viewer metadata to SQLite via update_transcript_meta_cmd.
* `patch` may contain any of:
* - `starred: boolean`
* - `manualTags: string[] | string` (array joined on ',' server-side)
* - `template: string`
* - `language: string`
* - `segments: Array<object>` (JSON.stringify'd before the wire hop)
* Omitted fields are preserved by COALESCE server-side. The in-memory
* history row (if present) is refreshed from the returned canonical row
* so the main-window list stays consistent.
*
* Returns nothing — callers needing the new row should read `history`.
*
* @param {string} id
* @param {Record<string, any>} patch
*/
export async function saveTranscriptMeta(id, patch) {
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 ? JSON.stringify(patch.segments) : undefined,
};
const row = await invoke("update_transcript_meta_cmd", { id: String(id), patch: payload });
const idx = history.findIndex((h) => String(h.id) === String(id));
if (idx !== -1) {
history[idx] = mapTranscriptRow(row);
}
} catch (err) {
toasts.error("Failed to save transcript metadata", String(err));
}
}
export function deleteFromHistory(index) {
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));
}
// ---- Tasks (SQLite via Tauri = canonical; no localStorage cache) ----
//
// The canonical store is the SQLite tasks table reachable via the
// `create_task_cmd`, `list_tasks_cmd`, `delete_task_cmd`,
// `complete_task_cmd`, `uncomplete_task_cmd` Tauri commands. The tasks
// array is seeded empty on module init and hydrated from SQLite on boot
// in the desktop runtime. Browser preview (no Tauri) shows an empty list.
//
// The previous localStorage tasks cache was removed to kill the cold-
// start flicker and the dual-write drift between localStorage and SQLite.
export const tasks = $state([]);
/** Shape a TaskDto row from the tasks commands into the UI entry shape
* that TasksPage / WipTaskList / TaskSidebar expect. Mirrors
* mapTranscriptRow's placement and field-defaulting pattern. */
function mapTaskRow(row) {
return {
id: row.id,
text: row.text ?? "",
bucket: row.bucket ?? "inbox",
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,
};
}
async function loadTasks() {
if (!hasTauriRuntime()) {
tasks.length = 0;
return;
}
try {
const rows = await invoke("list_tasks_cmd");
const mapped = Array.isArray(rows) ? rows.map(mapTaskRow) : [];
tasks.splice(0, tasks.length, ...mapped);
} catch (err) {
console.error("loadTasks failed", err);
tasks.length = 0;
toasts.error("Failed to load tasks", err?.message ?? String(err));
}
}
// Hydrate tasks from SQLite on boot. Mirrors the history hydration pattern
// earlier in this file.
if (typeof window !== "undefined" && hasTauriRuntime()) {
loadTasks().catch(() => {});
}
// `saveTasks` is retained as a no-op for callers in the Task Lists section
// further down (removeProfileTaskList, deleteTaskList) that mutate
// `t.listId` to null when a list is deleted and previously flushed via
// localStorage. SQLite writes happen via the specific `create_task_cmd`
// / `delete_task_cmd` / `complete_task_cmd` / `uncomplete_task_cmd`
// commands in the functions below; the listId-clearing mutation is
// currently session-scoped because listId is not round-tripped through
// those commands. See report note.
export function saveTasks() {}
export async function addTask(task) {
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,
// Task 2.6: forward list_id / effort so the SQLite row carries the
// metadata callers pass in. Callers that omit these get server
// defaults (NULL for list_id/effort, '' for notes at the column).
listId: task.listId ?? null,
effort: task.effort ?? null,
},
});
tasks.unshift(mapTaskRow(row));
broadcastTasks();
} catch (err) {
toasts.error("Couldn't add task", err?.message ?? String(err));
}
}
/**
* Apply a partial update to an in-memory task and broadcast the change.
* Used by `completeTask` / `uncompleteTask` after their dedicated server
* commands succeed. Not exported — external callers go through `updateTask`
* which persists via `update_task_cmd`.
*
* @param {string} id
* @param {Record<string, any>} updates
*/
function applyLocalTaskUpdate(id, updates) {
const idx = tasks.findIndex((t) => t.id === id);
if (idx >= 0) {
Object.assign(tasks[idx], updates);
broadcastTasks();
}
}
/**
* Persist a patch to an existing task and refresh the in-memory copy with
* the server's canonical row. `updates` may contain any of
* `{ text, bucket, listId, effort, notes }`; omitted fields are preserved
* server-side via COALESCE. `done` / `doneAt` are ignored by the server's
* UpdateTaskRequest — use `completeTask` / `uncompleteTask` for those.
*
* Closes the Task 2.6 gap: before this, updateTask was a session-only
* mutation, so bucket / effort / listId edits vanished on restart.
*
* @param {string} id
* @param {Record<string, any>} updates
*/
export async function updateTask(id, updates) {
if (!hasTauriRuntime()) {
// Browser preview: fall back to the local-only path so the UI still
// reflects edits during Vite dev runs outside the Tauri shell.
applyLocalTaskUpdate(id, updates);
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((t) => t.id === id);
if (idx >= 0) {
tasks[idx] = mapTaskRow(row);
broadcastTasks();
}
} catch (err) {
toasts.error("Couldn't update task", err?.message ?? String(err));
}
}
export async function deleteTask(id) {
if (!hasTauriRuntime()) return;
try {
await invoke("delete_task_cmd", { id });
const idx = tasks.findIndex((t) => t.id === id);
if (idx >= 0) {
tasks.splice(idx, 1);
broadcastTasks();
}
} catch (err) {
toasts.error("Couldn't delete task", err?.message ?? String(err));
}
}
export async function completeTask(id) {
if (!hasTauriRuntime()) return;
try {
await invoke("complete_task_cmd", { id });
// Local-only mirror: complete_task_cmd already stamped done/done_at
// server-side, so we just mirror the state here. Going through the
// persisting `updateTask` path would be a wasted round-trip.
applyLocalTaskUpdate(id, { done: true, doneAt: new Date().toISOString() });
} catch (err) {
toasts.error("Couldn't complete task", err?.message ?? String(err));
}
}
export async function uncompleteTask(id) {
if (!hasTauriRuntime()) return;
try {
await invoke("uncomplete_task_cmd", { id });
applyLocalTaskUpdate(id, { done: false, doneAt: null });
} catch (err) {
toasts.error("Couldn't uncomplete task", err?.message ?? String(err));
}
}
// ---- BroadcastChannel for multi-window task sync ----
const taskChannel = typeof BroadcastChannel !== "undefined"
? new BroadcastChannel("kon_task_sync")
: 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 {}
}

View File

@@ -0,0 +1,599 @@
import { invoke } from "@tauri-apps/api/core";
import type {
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 { hasTauriRuntime } from "$lib/utils/runtime.js";
export const page = $state<PageState>({
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";
const defaults: SettingsState = {
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,
microphoneDevice: "",
};
function canUseStorage(): boolean {
return typeof localStorage !== "undefined";
}
function loadSettings(): SettingsState {
if (!canUseStorage()) return { ...defaults };
return {
...defaults,
...(parseStoredJson<Partial<SettingsState>>(localStorage.getItem(SETTINGS_KEY)) ?? {}),
};
}
export const settings = $state<SettingsState>(loadSettings());
export function saveSettings() {
if (!canUseStorage()) return;
try {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
} catch {}
}
function loadProfiles(): Profile[] {
if (!canUseStorage()) return [];
return parseStoredJson<Profile[]>(localStorage.getItem(PROFILES_KEY)) ?? [];
}
export const profiles = $state<Profile[]>(loadProfiles());
export function saveProfiles() {
if (!canUseStorage()) return;
try {
localStorage.setItem(PROFILES_KEY, JSON.stringify(profiles));
} catch {}
}
export const history = $state<TranscriptEntry[]>([]);
function mapTranscriptRow(row: TranscriptDto): TranscriptEntry {
const text = row.text ?? "";
const rawTags = row.manualTags ?? "";
const manualTags = rawTags ? rawTags.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 ?? "",
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,
};
}
function normaliseTranscriptEntry(entry: TranscriptWriteEntry): TranscriptEntry {
const createdAt = entry.createdAt ?? new Date().toISOString();
return {
id: String(entry.id),
text: entry.text ?? "",
source: entry.source ?? "microphone",
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 : [],
};
}
async function loadHistory() {
if (!hasTauriRuntime()) {
history.length = 0;
return;
}
try {
const rows = await invoke<TranscriptDto[]>("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 function saveHistory() {}
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,
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),
};
const row = await invoke<TranscriptDto>("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 const tasks = $state<TaskEntry[]>([]);
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,
};
}
async function loadTasks() {
if (!hasTauriRuntime()) {
tasks.length = 0;
return;
}
try {
const rows = await invoke<TaskDto[]>("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<TaskDto>("create_task_cmd", {
request: {
id,
text: task.text,
bucket: task.bucket || "inbox",
sourceTranscriptId: task.sourceTranscriptId || null,
listId: task.listId ?? null,
effort: task.effort ?? null,
},
});
tasks.unshift(mapTaskRow(row));
broadcastTasks();
} catch (err) {
toasts.error("Couldn't add task", errorMessage(err));
}
}
function applyLocalTaskUpdate(id: string, updates: Partial<TaskEntry>) {
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<TaskEntry>);
return;
}
try {
const row = await invoke<TaskDto>("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));
}
}
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();
}
} 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() });
} 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 });
} 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<TaskChannelMessage>) => {
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<TaskList[]>(localStorage.getItem(TASK_LISTS_KEY)) ?? defaultTaskLists();
}
export const taskLists = $state<TaskList[]>(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("kon_task_lists")
: null;
if (taskListChannel) {
taskListChannel.onmessage = (event: MessageEvent<TaskListChannelMessage>) => {
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<Template[]>(localStorage.getItem(TEMPLATES_KEY)) ?? defaultTemplates();
}
export const templates = $state<Template[]>(loadTemplates());
export function saveTemplates() {
if (!canUseStorage()) return;
try {
localStorage.setItem(TEMPLATES_KEY, JSON.stringify(templates));
} catch {}
}

View File

@@ -1,10 +1,14 @@
// src/lib/stores/preferences.svelte.js
import { invoke } from '@tauri-apps/api/core';
import { emit } from '@tauri-apps/api/event';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { toasts } from './toasts.svelte.js';
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import type { AccessibilityPreferences, Preferences } from "$lib/types/app";
import { errorMessage } from "$lib/utils/errors.js";
import { toasts } from "./toasts.svelte.ts";
export const PREFERENCES_CHANGED_EVENT = 'kon:preferences-changed';
export const PREFERENCES_CHANGED_EVENT = "kon:preferences-changed";
type FontFamilies = Record<AccessibilityPreferences["fontFamily"], string>;
function currentWindowLabel() {
try {
@@ -14,7 +18,7 @@ function currentWindowLabel() {
}
}
function broadcastPreferences(prefs) {
function broadcastPreferences(prefs: Preferences) {
const source = currentWindowLabel();
if (source === null) return;
// Fire-and-forget — cross-window sync must never block the local apply path.
@@ -22,53 +26,53 @@ function broadcastPreferences(prefs) {
.catch(() => {});
}
const DEFAULTS = {
theme: 'dark',
zone: 'default',
const DEFAULTS: Preferences = {
theme: "dark",
zone: "default",
accessibility: {
fontFamily: 'lexend',
fontFamily: "lexend",
fontSize: 16,
letterSpacing: 0,
lineHeight: 1.5,
transcriptSize: 16,
bionicReading: false,
reduceMotion: 'system'
}
reduceMotion: "system",
},
};
const FONT_FAMILIES = {
const FONT_FAMILIES: FontFamilies = {
lexend: "'Lexend', system-ui, sans-serif",
atkinson: "'Atkinson Hyperlegible Next', system-ui, sans-serif",
opendyslexic: "'OpenDyslexic', system-ui, sans-serif"
opendyslexic: "'OpenDyslexic', system-ui, sans-serif",
};
function readFromDOM() {
function readFromDOM(): Preferences {
const el = document.documentElement;
return {
theme: el.dataset.theme || DEFAULTS.theme,
theme: (el.dataset.theme || DEFAULTS.theme) as Preferences["theme"],
zone: el.dataset.zone || DEFAULTS.zone,
accessibility: {
fontFamily: el.dataset.fontFamily || DEFAULTS.accessibility.fontFamily,
fontFamily: (el.dataset.fontFamily || DEFAULTS.accessibility.fontFamily) as AccessibilityPreferences["fontFamily"],
fontSize: parseFloat(el.style.getPropertyValue('--font-size-body')) || DEFAULTS.accessibility.fontSize,
letterSpacing: parseFloat(el.style.getPropertyValue('--letter-spacing-body')) || DEFAULTS.accessibility.letterSpacing,
lineHeight: parseFloat(el.style.getPropertyValue('--line-height-body')) || DEFAULTS.accessibility.lineHeight,
transcriptSize: DEFAULTS.accessibility.transcriptSize,
bionicReading: el.dataset.bionicReading === 'true',
reduceMotion: el.dataset.reduceMotion || DEFAULTS.accessibility.reduceMotion
}
bionicReading: el.dataset.bionicReading === "true",
reduceMotion: (el.dataset.reduceMotion || DEFAULTS.accessibility.reduceMotion) as AccessibilityPreferences["reduceMotion"],
},
};
}
function applyToDOM(prefs) {
function applyToDOM(prefs: Preferences) {
const el = document.documentElement;
// Theme — resolve 'system' to actual value
el.dataset.theme = prefs.theme === 'system'
? (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark')
el.dataset.theme = prefs.theme === "system"
? (window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark")
: prefs.theme;
// Zone
if (prefs.zone === 'default') {
if (prefs.zone === "default") {
delete el.dataset.zone;
} else {
el.dataset.zone = prefs.zone;
@@ -85,32 +89,31 @@ function applyToDOM(prefs) {
el.dataset.fontFamily = a.fontFamily;
// Reduce motion — three-value resolution
const motionReduced = a.reduceMotion === 'on'
|| (a.reduceMotion === 'system' && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
const motionReduced = a.reduceMotion === "on"
|| (a.reduceMotion === "system" && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
if (motionReduced) {
el.dataset.reduceMotion = 'true';
el.dataset.reduceMotion = "true";
} else {
delete el.dataset.reduceMotion;
}
}
let saveTimeout = null;
let saveTimeout: ReturnType<typeof setTimeout> | undefined;
// Show the failure toast at most once per process so a stuck SQLite path
// doesn't spam the user every time they nudge a slider.
let saveFailureToastShown = false;
function persistToSQLite(prefs) {
function persistToSQLite(prefs: Preferences) {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(async () => {
try {
await invoke('save_preferences', { preferences: JSON.stringify(prefs) });
await invoke("save_preferences", { preferences: JSON.stringify(prefs) });
saveFailureToastShown = false;
} catch (e) {
console.error('Failed to save preferences:', e);
console.error("Failed to save preferences:", e);
if (!saveFailureToastShown) {
const msg = typeof e === 'string' ? e : (e?.message ?? String(e));
toasts.warn(
'Could not save preferences',
`${msg}. Your changes still apply for this session.`,
"Could not save preferences",
`${errorMessage(e)}. Your changes still apply for this session.`,
);
saveFailureToastShown = true;
}
@@ -133,14 +136,14 @@ export function getPreferences() {
return preferences;
}
export function updatePreferences(updates) {
export function updatePreferences(updates: Partial<Preferences>) {
Object.assign(preferences, updates);
applyToDOM(preferences);
persistToSQLite(preferences);
broadcastPreferences(preferences);
}
export function updateAccessibility(updates) {
export function updateAccessibility(updates: Partial<AccessibilityPreferences>) {
Object.assign(preferences.accessibility, updates);
applyToDOM(preferences);
persistToSQLite(preferences);
@@ -149,8 +152,8 @@ export function updateAccessibility(updates) {
// Apply preferences received from another Tauri window. Mutates local state
// and DOM only — never persists or re-broadcasts, so there is no echo loop.
export function applyExternalPreferences(prefs) {
if (!prefs || typeof prefs !== 'object') return;
export function applyExternalPreferences(prefs: Partial<Preferences> | null | undefined) {
if (!prefs || typeof prefs !== "object") return;
Object.assign(preferences, prefs);
if (prefs.accessibility) {
Object.assign(preferences.accessibility, prefs.accessibility);
@@ -159,11 +162,11 @@ export function applyExternalPreferences(prefs) {
}
// Re-resolve when OS preferences change
if (typeof window !== 'undefined') {
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => {
if (preferences.theme === 'system') applyToDOM(preferences);
if (typeof window !== "undefined") {
window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => {
if (preferences.theme === "system") applyToDOM(preferences);
});
window.matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', () => {
if (preferences.accessibility.reduceMotion === 'system') applyToDOM(preferences);
window.matchMedia("(prefers-reduced-motion: reduce)").addEventListener("change", () => {
if (preferences.accessibility.reduceMotion === "system") applyToDOM(preferences);
});
}

View File

@@ -1,3 +1,6 @@
import type { ToastItem, ToastSeverity } from "$lib/types/app";
import { errorMessage } from "$lib/utils/errors.js";
// Minimal toast store. Roll-our-own (no svelte-french-toast etc) so Kon
// stays lean. Toasts auto-dismiss after `duration` ms unless duration is 0
// (sticky) or unless the user clicks the close button.
@@ -15,22 +18,22 @@
let nextId = 1;
function defaultDuration(severity) {
function defaultDuration(severity: ToastSeverity): number {
switch (severity) {
case 'success': return 3000;
case 'info': return 4000;
case 'warn': return 6000;
case 'error': return 0; // sticky
default: return 4000;
case "success": return 3000;
case "info": return 4000;
case "warn": return 6000;
case "error": return 0; // sticky
default: return 4000;
}
}
function createToastsStore() {
// $state requires a class field or top-level let in Svelte 5; we expose
// `items` via a getter on the singleton.
let items = $state([]);
let items = $state<ToastItem[]>([]);
function show(severity, title, body) {
function show(severity: ToastSeverity, title: string, body?: string) {
const id = nextId++;
const duration = defaultDuration(severity);
const toast = { id, severity, title, body: body ?? '', duration };
@@ -41,7 +44,7 @@ function createToastsStore() {
return id;
}
function dismiss(id) {
function dismiss(id: number) {
const ix = items.findIndex(t => t.id === id);
if (ix >= 0) items.splice(ix, 1);
}
@@ -52,10 +55,10 @@ function createToastsStore() {
return {
get items() { return items; },
info: (title, body) => show('info', title, body),
success: (title, body) => show('success', title, body),
warn: (title, body) => show('warn', title, body),
error: (title, body) => show('error', title, body),
info: (title: string, body?: string) => show("info", title, body),
success: (title: string, body?: string) => show("success", title, body),
warn: (title: string, body?: string) => show("warn", title, body),
error: (title: string, body?: string) => show("error", title, body),
dismiss,
dismissAll,
};
@@ -72,12 +75,16 @@ export const toasts = createToastsStore();
// const result = await invokeWithToast('start_native_capture', { deviceName });
//
// Optional `errorTitle` overrides the default ("Action failed").
export async function invokeWithToast(invokeFn, command, args, errorTitle) {
export async function invokeWithToast<TArgs, TResult>(
invokeFn: (command: string, args?: TArgs) => Promise<TResult>,
command: string,
args?: TArgs,
errorTitle?: string,
): Promise<TResult> {
try {
return await invokeFn(command, args);
} catch (err) {
const msg = typeof err === 'string' ? err : (err?.message ?? String(err));
toasts.error(errorTitle ?? 'Action failed', msg);
toasts.error(errorTitle ?? "Action failed", errorMessage(err));
throw err;
}
}