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

@@ -3,6 +3,7 @@
"compilerOptions": { "compilerOptions": {
"allowJs": true, "allowJs": true,
"checkJs": true, "checkJs": true,
"allowImportingTsExtensions": true,
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"resolveJsonModule": true, "resolveJsonModule": true,

View File

@@ -4,7 +4,8 @@
"description": "Kon — Think out loud", "description": "Kon — Think out loud",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "npm run dev:frontend",
"dev:frontend": "svelte-kit sync && vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",

8
run.sh
View File

@@ -8,19 +8,15 @@ cd "$(dirname "$0")"
export LIBCLANG_PATH=/usr/lib64/llvm21/lib64 export LIBCLANG_PATH=/usr/lib64/llvm21/lib64
printf 'Starting Vite dev server...\n' >&2 printf 'Starting Vite dev server...\n' >&2
npm run dev & npm run dev:frontend &
VITE_PID=$! VITE_PID=$!
until curl -sf http://localhost:1420 > /dev/null 2>&1; do sleep 0.5; done until curl -sf http://localhost:1420 > /dev/null 2>&1; do sleep 0.5; done
printf 'Vite ready. Launching Tauri...\n' >&2 printf 'Vite ready. Launching Tauri...\n' >&2
# Blank beforeDevCommand so Tauri doesn't spawn a second Vite
sed -i 's/"beforeDevCommand": "npm run dev"/"beforeDevCommand": ""/' src-tauri/tauri.conf.json
cleanup() { cleanup() {
sed -i 's/"beforeDevCommand": ""/"beforeDevCommand": "npm run dev"/' src-tauri/tauri.conf.json
kill "$VITE_PID" 2>/dev/null || true kill "$VITE_PID" 2>/dev/null || true
} }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
npx tauri dev npx tauri dev --config '{"build":{"beforeDevCommand":""}}'

View File

@@ -4,7 +4,7 @@
"version": "0.1.0", "version": "0.1.0",
"identifier": "uk.co.corbel.kon", "identifier": "uk.co.corbel.kon",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev:frontend",
"devUrl": "http://localhost:1420", "devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build", "beforeBuildCommand": "npm run build",
"frontendDist": "../build" "frontendDist": "../build"

44
src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,44 @@
declare global {
interface Window {
__TAURI_INTERNALS__?: unknown;
isTauri?: boolean;
}
}
declare module "@chenglou/pretext" {
export interface PretextLayoutLine {
text: string;
}
export interface PretextLayoutResult {
height: number;
lineCount: number;
lines: PretextLayoutLine[];
}
export function prepare(
text: string,
font: string,
options?: Record<string, unknown>,
): unknown;
export function prepareWithSegments(
text: string,
font: string,
options?: Record<string, unknown>,
): unknown;
export function layout(
prepared: unknown,
maxWidth: number,
lineHeight: number,
): PretextLayoutResult;
export function layoutWithLines(
prepared: unknown,
maxWidth: number,
lineHeight: number,
): PretextLayoutResult;
}
export {};

View File

@@ -1,4 +1,4 @@
<script> <script lang="ts">
import { page, settings, tasks, saveSettings } from "$lib/stores/page.svelte.js"; import { page, settings, tasks, saveSettings } from "$lib/stores/page.svelte.js";
import { Mic, FileText, SquareCheck, Clock, Settings, ChevronRight, ChevronLeft } from 'lucide-svelte'; import { Mic, FileText, SquareCheck, Clock, Settings, ChevronRight, ChevronLeft } from 'lucide-svelte';
@@ -19,7 +19,7 @@
saveSettings(); saveSettings();
} }
function navigate(id) { function navigate(id: string) {
page.current = id; page.current = id;
if (id !== "dictation") { if (id !== "dictation") {
page.taskSidebarOpen = false; page.taskSidebarOpen = false;

View File

@@ -1,12 +1,15 @@
// src/lib/actions/bionicReading.js // src/lib/actions/bionicReading.js
function applyBionic(node) { function applyBionic(node: HTMLElement) {
const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT); const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);
const textNodes = []; const textNodes: Text[] = [];
let current; let current: Node | null;
while ((current = walker.nextNode())) textNodes.push(current); while ((current = walker.nextNode())) {
if (current.nodeType === Node.TEXT_NODE) textNodes.push(current as Text);
}
for (const textNode of textNodes) { for (const textNode of textNodes) {
if (textNode.textContent == null) continue;
const words = textNode.textContent.split(/(\s+)/); const words = textNode.textContent.split(/(\s+)/);
if (words.length <= 1 && !words[0].trim()) continue; if (words.length <= 1 && !words[0].trim()) continue;
@@ -22,21 +25,21 @@ function applyBionic(node) {
fragment.appendChild(b); fragment.appendChild(b);
fragment.appendChild(document.createTextNode(word.slice(boldLen))); fragment.appendChild(document.createTextNode(word.slice(boldLen)));
} }
textNode.parentNode.replaceChild(fragment, textNode); textNode.parentNode?.replaceChild(fragment, textNode);
} }
} }
function stripBionic(node) { function stripBionic(node: HTMLElement) {
const bolds = node.querySelectorAll('b'); const bolds = node.querySelectorAll("b");
for (const b of bolds) { for (const b of bolds) {
const text = document.createTextNode(b.textContent); const text = document.createTextNode(b.textContent ?? "");
b.parentNode.replaceChild(text, b); b.parentNode?.replaceChild(text, b);
} }
node.normalize(); // merge adjacent text nodes node.normalize(); // merge adjacent text nodes
} }
export function bionicReading(node, enabled) { export function bionicReading(node: HTMLElement, enabled: boolean) {
let observer = null; let observer: MutationObserver | null = null;
function refresh() { function refresh() {
// Disconnect observer while we mutate, to avoid infinite loop // Disconnect observer while we mutate, to avoid infinite loop
@@ -47,6 +50,7 @@ export function bionicReading(node, enabled) {
} }
function observe() { function observe() {
if (!observer) return;
observer.observe(node, { childList: true, characterData: true, subtree: true }); observer.observe(node, { childList: true, characterData: true, subtree: true });
} }
@@ -58,7 +62,7 @@ export function bionicReading(node, enabled) {
observe(); observe();
return { return {
update(newEnabled) { update(newEnabled: boolean) {
enabled = newEnabled; enabled = newEnabled;
refresh(); refresh();
}, },

View File

@@ -1,4 +1,5 @@
<script> <script lang="ts">
import type { FontFamily, ReduceMotion } from "$lib/types/app";
import { getPreferences, updateAccessibility } from '$lib/stores/preferences.svelte.js'; import { getPreferences, updateAccessibility } from '$lib/stores/preferences.svelte.js';
import Toggle from '$lib/components/Toggle.svelte'; import Toggle from '$lib/components/Toggle.svelte';
import SegmentedButton from '$lib/components/SegmentedButton.svelte'; import SegmentedButton from '$lib/components/SegmentedButton.svelte';
@@ -9,7 +10,7 @@
{ id: 'lexend', label: 'Lexend' }, { id: 'lexend', label: 'Lexend' },
{ id: 'atkinson', label: 'Atkinson' }, { id: 'atkinson', label: 'Atkinson' },
{ id: 'opendyslexic', label: 'OpenDyslexic' }, { id: 'opendyslexic', label: 'OpenDyslexic' },
]; ] satisfies { id: FontFamily; label: string }[];
let bionicChecked = $state(prefs.accessibility.bionicReading || false); let bionicChecked = $state(prefs.accessibility.bionicReading || false);
let motionValue = $state( let motionValue = $state(
@@ -25,11 +26,15 @@
}); });
$effect(() => { $effect(() => {
const mapped = motionValue === 'On' ? 'on' : motionValue === 'Off' ? 'off' : 'system'; const mapped: ReduceMotion = motionValue === 'On' ? 'on' : motionValue === 'Off' ? 'off' : 'system';
if (mapped !== prefs.accessibility.reduceMotion) { if (mapped !== prefs.accessibility.reduceMotion) {
updateAccessibility({ reduceMotion: mapped }); updateAccessibility({ reduceMotion: mapped });
} }
}); });
function rangeValue(event: Event): number {
return Number((event.currentTarget as HTMLInputElement).value);
}
</script> </script>
<div class="flex flex-col gap-5"> <div class="flex flex-col gap-5">
@@ -59,7 +64,7 @@
<span class="text-[11px] text-text-tertiary">{prefs.accessibility.fontSize}px</span> <span class="text-[11px] text-text-tertiary">{prefs.accessibility.fontSize}px</span>
</div> </div>
<input type="range" min="16" max="24" step="1" value={prefs.accessibility.fontSize} <input type="range" min="16" max="24" step="1" value={prefs.accessibility.fontSize}
oninput={(e) => updateAccessibility({ fontSize: Number(e.target.value) })} oninput={(e) => updateAccessibility({ fontSize: rangeValue(e) })}
class="w-full accent-accent" /> class="w-full accent-accent" />
</div> </div>
@@ -70,7 +75,7 @@
<span class="text-[11px] text-text-tertiary">{prefs.accessibility.letterSpacing.toFixed(2)}em</span> <span class="text-[11px] text-text-tertiary">{prefs.accessibility.letterSpacing.toFixed(2)}em</span>
</div> </div>
<input type="range" min="0" max="0.15" step="0.01" value={prefs.accessibility.letterSpacing} <input type="range" min="0" max="0.15" step="0.01" value={prefs.accessibility.letterSpacing}
oninput={(e) => updateAccessibility({ letterSpacing: Number(e.target.value) })} oninput={(e) => updateAccessibility({ letterSpacing: rangeValue(e) })}
class="w-full accent-accent" /> class="w-full accent-accent" />
</div> </div>
@@ -81,7 +86,7 @@
<span class="text-[11px] text-text-tertiary">{prefs.accessibility.lineHeight.toFixed(1)}</span> <span class="text-[11px] text-text-tertiary">{prefs.accessibility.lineHeight.toFixed(1)}</span>
</div> </div>
<input type="range" min="1.3" max="2.2" step="0.1" value={prefs.accessibility.lineHeight} <input type="range" min="1.3" max="2.2" step="0.1" value={prefs.accessibility.lineHeight}
oninput={(e) => updateAccessibility({ lineHeight: Number(e.target.value) })} oninput={(e) => updateAccessibility({ lineHeight: rangeValue(e) })}
class="w-full accent-accent" /> class="w-full accent-accent" />
</div> </div>
@@ -92,7 +97,7 @@
<span class="text-[11px] text-text-tertiary">{prefs.accessibility.transcriptSize}px</span> <span class="text-[11px] text-text-tertiary">{prefs.accessibility.transcriptSize}px</span>
</div> </div>
<input type="range" min="16" max="24" step="1" value={prefs.accessibility.transcriptSize} <input type="range" min="16" max="24" step="1" value={prefs.accessibility.transcriptSize}
oninput={(e) => updateAccessibility({ transcriptSize: Number(e.target.value) })} oninput={(e) => updateAccessibility({ transcriptSize: rangeValue(e) })}
class="w-full accent-accent" /> class="w-full accent-accent" />
</div> </div>

View File

@@ -1,4 +1,4 @@
<script> <script lang="ts">
import { settings, saveSettings } from "$lib/stores/page.svelte.js"; import { settings, saveSettings } from "$lib/stores/page.svelte.js";
let recording = $state(false); let recording = $state(false);
@@ -6,7 +6,7 @@
const modifierKeys = new Set(["Control", "Shift", "Alt", "Meta"]); const modifierKeys = new Set(["Control", "Shift", "Alt", "Meta"]);
function handleKeyDown(e) { function handleKeyDown(e: KeyboardEvent) {
// Capture-phase listener; guard still belts-and-braces. // Capture-phase listener; guard still belts-and-braces.
if (!recording) return; if (!recording) return;
e.preventDefault(); e.preventDefault();
@@ -20,7 +20,7 @@
// Wait for a non-modifier key // Wait for a non-modifier key
if (modifierKeys.has(e.key)) return; if (modifierKeys.has(e.key)) return;
const parts = []; const parts: string[] = [];
if (e.ctrlKey) parts.push("Ctrl"); if (e.ctrlKey) parts.push("Ctrl");
if (e.shiftKey) parts.push("Shift"); if (e.shiftKey) parts.push("Shift");
if (e.altKey) parts.push("Alt"); if (e.altKey) parts.push("Alt");

View File

@@ -1,10 +1,16 @@
<script> <script lang="ts">
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { ListTree, Check, Timer, Loader2 } from 'lucide-svelte'; import { ListTree, Check, Timer, Loader2 } from 'lucide-svelte';
let { parentTaskId, reduceMotion = false } = $props(); let { parentTaskId, reduceMotion = false } = $props();
let subtasks = $state([]); interface Subtask {
id: string;
text: string;
done: boolean;
}
let subtasks = $state<Subtask[]>([]);
let loading = $state(false); let loading = $state(false);
let error = $state(''); let error = $state('');
let decomposing = $state(false); let decomposing = $state(false);
@@ -13,7 +19,7 @@
loading = true; loading = true;
error = ''; error = '';
try { try {
subtasks = await invoke('list_subtasks_cmd', { parentTaskId }); subtasks = await invoke<Subtask[]>('list_subtasks_cmd', { parentTaskId });
} catch (e) { } catch (e) {
error = String(e); error = String(e);
} finally { } finally {
@@ -25,7 +31,7 @@
decomposing = true; decomposing = true;
error = ''; error = '';
try { try {
subtasks = await invoke('decompose_and_store', { parentTaskId }); subtasks = await invoke<Subtask[]>('decompose_and_store', { parentTaskId });
} catch (e) { } catch (e) {
error = String(e); error = String(e);
} finally { } finally {
@@ -33,7 +39,7 @@
} }
} }
async function checkStep(subtaskId) { async function checkStep(subtaskId: string) {
try { try {
await invoke('complete_subtask_cmd', { subtaskId }); await invoke('complete_subtask_cmd', { subtaskId });
const idx = subtasks.findIndex(s => s.id === subtaskId); const idx = subtasks.findIndex(s => s.id === subtaskId);
@@ -41,7 +47,7 @@
} catch (_) {} } catch (_) {}
} }
function startTimer(subtaskId) { function startTimer(subtaskId: string) {
window.dispatchEvent(new CustomEvent('kon:start-timer', { window.dispatchEvent(new CustomEvent('kon:start-timer', {
detail: { taskId: subtaskId, seconds: 120 } detail: { taskId: subtaskId, seconds: 120 }
})); }));

View File

@@ -1,31 +1,38 @@
<script> <script lang="ts">
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { errorMessage } from "$lib/utils/errors.js";
import Card from "$lib/components/Card.svelte"; import Card from "$lib/components/Card.svelte";
let { modelSize = "base", onComplete = () => {} } = $props(); let { modelSize = "base", onComplete = () => {} } = $props<{
modelSize?: "tiny" | "base" | "small" | "medium";
onComplete?: () => void;
}>();
let progress = $state(0); let progress = $state(0);
let downloading = $state(false); let downloading = $state(false);
let downloaded = $state(0); let downloaded = $state(0);
let total = $state(0); let total = $state(0);
let error = $state(""); let error = $state("");
let unlisten = null; let unlisten: null | (() => void) = null;
const modelInfo = { const modelInfo = {
tiny: { size: "~75 MB", accuracy: "Basic" }, tiny: { size: "~75 MB", accuracy: "Basic" },
base: { size: "~142 MB", accuracy: "Good" }, base: { size: "~142 MB", accuracy: "Good" },
small: { size: "~466 MB", accuracy: "Better" }, small: { size: "~466 MB", accuracy: "Better" },
medium: { size: "~1.5 GB", accuracy: "Best" }, medium: { size: "~1.5 GB", accuracy: "Best" },
}; } satisfies Record<string, { size: string; accuracy: string }>;
let currentModelInfo = $derived(
modelInfo[String(modelSize) as keyof typeof modelInfo]
);
onMount(async () => { onMount(async () => {
unlisten = await listen("model-download-progress", (event) => { unlisten = await listen("model-download-progress", (event) => {
const data = event.payload; const data = event.payload as { progress?: number; downloaded?: number; total?: number };
progress = data.progress; progress = data.progress ?? 0;
downloaded = data.downloaded; downloaded = data.downloaded ?? 0;
total = data.total; total = data.total ?? 0;
}); });
}); });
@@ -41,12 +48,12 @@
await invoke("download_model", { size: modelSize }); await invoke("download_model", { size: modelSize });
onComplete(); onComplete();
} catch (err) { } catch (err) {
error = typeof err === "string" ? err : err.message || "Download failed"; error = errorMessage(err) || "Download failed";
downloading = false; downloading = false;
} }
} }
function formatBytes(bytes) { function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`; if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`;
@@ -68,7 +75,7 @@
Kon needs the <span class="font-medium text-text">{modelSize}</span> model to transcribe speech. Kon needs the <span class="font-medium text-text">{modelSize}</span> model to transcribe speech.
</p> </p>
<p class="text-[11px] text-text-tertiary mb-6"> <p class="text-[11px] text-text-tertiary mb-6">
{modelInfo[modelSize]?.size ?? "?"} · {modelInfo[modelSize]?.accuracy ?? "?"} accuracy · 100% offline {currentModelInfo?.size ?? "?"} · {currentModelInfo?.accuracy ?? "?"} accuracy · 100% offline
</p> </p>
{#if downloading} {#if downloading}

View File

@@ -1,4 +1,4 @@
<script> <script lang="ts">
// Invisible resize-handle overlays around each Kon window edge. // Invisible resize-handle overlays around each Kon window edge.
// Needed because Kon runs with decorations off, so the WM // Needed because Kon runs with decorations off, so the WM
// provides no resize affordance on KDE/GNOME Wayland. // provides no resize affordance on KDE/GNOME Wayland.
@@ -24,7 +24,10 @@
const enabled = hasTauriRuntime(); const enabled = hasTauriRuntime();
async function startResize(e, direction) { async function startResize(
e: PointerEvent,
direction: "North" | "South" | "West" | "East" | "NorthWest" | "NorthEast" | "SouthWest" | "SouthEast",
) {
if (!enabled) return; if (!enabled) return;
if (e.button !== 0) return; if (e.button !== 0) return;
e.preventDefault(); e.preventDefault();
@@ -35,7 +38,7 @@
// diagonal component and collapsing a corner drag to a single axis. // diagonal component and collapsing a corner drag to a single axis.
// pointerdown + setPointerCapture keeps the direction pinned. // pointerdown + setPointerCapture keeps the direction pinned.
try { try {
e.currentTarget?.setPointerCapture?.(e.pointerId); (e.currentTarget as HTMLElement | null)?.setPointerCapture?.(e.pointerId);
} catch {} } catch {}
try { try {
await getCurrentWindow().startResizeDragging(direction); await getCurrentWindow().startResizeDragging(direction);
@@ -44,13 +47,21 @@
</script> </script>
{#if enabled} {#if enabled}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="kon-rh kon-rh-n" onpointerdown={(e) => startResize(e,"North")}></div> <div class="kon-rh kon-rh-n" onpointerdown={(e) => startResize(e,"North")}></div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="kon-rh kon-rh-s" onpointerdown={(e) => startResize(e,"South")}></div> <div class="kon-rh kon-rh-s" onpointerdown={(e) => startResize(e,"South")}></div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="kon-rh kon-rh-w" onpointerdown={(e) => startResize(e,"West")}></div> <div class="kon-rh kon-rh-w" onpointerdown={(e) => startResize(e,"West")}></div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="kon-rh kon-rh-e" onpointerdown={(e) => startResize(e,"East")}></div> <div class="kon-rh kon-rh-e" onpointerdown={(e) => startResize(e,"East")}></div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="kon-rh kon-rh-nw" onpointerdown={(e) => startResize(e,"NorthWest")}></div> <div class="kon-rh kon-rh-nw" onpointerdown={(e) => startResize(e,"NorthWest")}></div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="kon-rh kon-rh-ne" onpointerdown={(e) => startResize(e,"NorthEast")}></div> <div class="kon-rh kon-rh-ne" onpointerdown={(e) => startResize(e,"NorthEast")}></div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="kon-rh kon-rh-sw" onpointerdown={(e) => startResize(e,"SouthWest")}></div> <div class="kon-rh kon-rh-sw" onpointerdown={(e) => startResize(e,"SouthWest")}></div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="kon-rh kon-rh-se" onpointerdown={(e) => startResize(e,"SouthEast")}></div> <div class="kon-rh kon-rh-se" onpointerdown={(e) => startResize(e,"SouthEast")}></div>
{/if} {/if}

View File

@@ -1,14 +1,14 @@
<script> <script lang="ts">
import { page, tasks, addTask, completeTask, uncompleteTask } from "$lib/stores/page.svelte.js"; import { page, tasks, addTask, completeTask, uncompleteTask } from "$lib/stores/page.svelte.js";
import { BUCKET_DOT_COLORS, SIDEBAR_MAX_TASKS } from "$lib/utils/constants.js"; import { BUCKET_DOT_COLORS, SIDEBAR_MAX_TASKS } from "$lib/utils/constants.js";
let quickInput = $state(""); let quickInput = $state("");
let recentlyAdded = $state(new Set()); let recentlyAdded = $state(new Set<string>());
let activeTasks = $derived(tasks.filter((t) => !t.done).slice(0, SIDEBAR_MAX_TASKS)); let activeTasks = $derived(tasks.filter((t) => !t.done).slice(0, SIDEBAR_MAX_TASKS));
let taskCount = $derived(tasks.filter((t) => !t.done).length); let taskCount = $derived(tasks.filter((t) => !t.done).length);
function handleQuickAdd(e) { function handleQuickAdd(e: KeyboardEvent) {
if (e.key === "Enter" && quickInput.trim()) { if (e.key === "Enter" && quickInput.trim()) {
addTask({ text: quickInput.trim(), bucket: "inbox" }); addTask({ text: quickInput.trim(), bucket: "inbox" });
quickInput = ""; quickInput = "";

View File

@@ -1,4 +1,4 @@
<script> <script lang="ts">
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { settings } from "$lib/stores/page.svelte.js"; import { settings } from "$lib/stores/page.svelte.js";
@@ -19,16 +19,16 @@
await getCurrentWindow().hide(); await getCurrentWindow().hide();
} }
function handleDragStart(e) { function handleDragStart(e: PointerEvent) {
if (e.button !== 0) return; if (e.button !== 0) return;
if (e.target.closest("button")) return; if ((e.target as HTMLElement | null)?.closest("button")) return;
try { e.currentTarget?.setPointerCapture?.(e.pointerId); } catch {} try { (e.currentTarget as HTMLElement | null)?.setPointerCapture?.(e.pointerId); } catch {}
getCurrentWindow().startDragging(); getCurrentWindow().startDragging();
} }
// Track maximise state // Track maximise state
$effect(() => { $effect(() => {
let unlisten; let unlisten: (() => void) | undefined;
getCurrentWindow().onResized(async () => { getCurrentWindow().onResized(async () => {
maximised = await getCurrentWindow().isMaximized(); maximised = await getCurrentWindow().isMaximized();
}).then((fn) => unlisten = fn); }).then((fn) => unlisten = fn);
@@ -36,6 +36,7 @@
}); });
</script> </script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
class="flex items-center select-none bg-sidebar border-b border-border-subtle class="flex items-center select-none bg-sidebar border-b border-border-subtle
{compact ? 'h-[28px]' : 'h-[32px]'}" {compact ? 'h-[28px]' : 'h-[32px]'}"

View File

@@ -1,4 +1,5 @@
<script> <script lang="ts">
import type { ToastSeverity } from "$lib/types/app";
// Renders the toast stack in the bottom-right of the viewport. Mount once // Renders the toast stack in the bottom-right of the viewport. Mount once
// at the app root (in +layout.svelte). Reads from the global toasts store. // at the app root (in +layout.svelte). Reads from the global toasts store.
// //
@@ -10,7 +11,7 @@
import { toasts } from '$lib/stores/toasts.svelte.js'; import { toasts } from '$lib/stores/toasts.svelte.js';
import { X } from 'lucide-svelte'; import { X } from 'lucide-svelte';
function severityClass(severity) { function severityClass(severity: ToastSeverity) {
switch (severity) { switch (severity) {
case 'success': return 'toast-success'; case 'success': return 'toast-success';
case 'info': return 'toast-info'; case 'info': return 'toast-info';

View File

@@ -1,7 +1,11 @@
<script> <script lang="ts">
import { onMount, onDestroy } from "svelte"; type SpinnerType = "dots" | "circle" | "pulse" | "stars" | "orbit";
let { type = "dots", speed = 100, classes = "" } = $props(); let { type = "dots", speed = 100, classes = "" } = $props<{
type?: SpinnerType;
speed?: number;
classes?: string;
}>();
const sequences = { const sequences = {
dots: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], dots: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
@@ -12,17 +16,14 @@
}; };
let frame = $state(0); let frame = $state(0);
let interval = null; let chars = $derived(sequences[type as SpinnerType] ?? sequences.dots);
let chars = sequences[type] || sequences.dots;
onMount(() => { $effect(() => {
interval = setInterval(() => { frame = 0;
const interval = setInterval(() => {
frame = (frame + 1) % chars.length; frame = (frame + 1) % chars.length;
}, speed); }, speed);
}); return () => clearInterval(interval);
onDestroy(() => {
if (interval) clearInterval(interval);
}); });
</script> </script>

View File

@@ -1,4 +1,5 @@
<script> <script lang="ts">
import type { ViewerSegment } from "$lib/types/app";
import { measureTextHeight } from "$lib/utils/textMeasure.js"; import { measureTextHeight } from "$lib/utils/textMeasure.js";
import { buildCumulativeOffsets, findVisibleRange } from "$lib/utils/virtualList.js"; import { buildCumulativeOffsets, findVisibleRange } from "$lib/utils/virtualList.js";
import { getPreferences } from "$lib/stores/preferences.svelte.js"; import { getPreferences } from "$lib/stores/preferences.svelte.js";
@@ -8,23 +9,39 @@
let { let {
segments = [], segments = [],
activeSegmentIdx = -1, activeSegmentIdx = -1,
matchingSegments = new Set(), matchingSegments = new Set<number>(),
showTimestamps = true, showTimestamps = true,
editingIdx = -1, editingIdx = -1,
editingText = $bindable(""), editingText = $bindable(""),
searchQuery = "", searchQuery = "",
formatTime = (t) => String(t), formatTime = (t: number) => String(t),
onSeekToTime = () => {}, onSeekToTime = (_time: number) => {},
onStartEditing = () => {}, onStartEditing = (_idx: number) => {},
onFinishEditing = () => {}, onFinishEditing = () => {},
onToggleStar = () => {}, onToggleStar = (_idx: number) => {},
onCopySegment = () => {}, onCopySegment = (_idx: number) => {},
onDeleteSegment = () => {}, onDeleteSegment = (_idx: number) => {},
highlightText = (t) => t, highlightText = (t: string) => t,
} = $props(); } = $props<{
segments?: ViewerSegment[];
activeSegmentIdx?: number;
matchingSegments?: Set<number>;
showTimestamps?: boolean;
editingIdx?: number;
editingText?: string;
searchQuery?: string;
formatTime?: (time: number) => string;
onSeekToTime?: (time: number) => void;
onStartEditing?: (idx: number) => void;
onFinishEditing?: () => void;
onToggleStar?: (idx: number) => void;
onCopySegment?: (idx: number) => void;
onDeleteSegment?: (idx: number) => void;
highlightText?: (text: string) => string;
}>();
// Internal state // Internal state
let containerEl = $state(null); let containerEl = $state<HTMLDivElement | null>(null);
let containerHeight = $state(0); let containerHeight = $state(0);
let containerWidth = $state(0); let containerWidth = $state(0);
let scrollTop = $state(0); let scrollTop = $state(0);
@@ -46,7 +63,7 @@
const textWidth = containerWidth - 26 - timestampWidth - 24; const textWidth = containerWidth - 26 - timestampWidth - 24;
if (textWidth <= 0) return segments.map(() => 40); if (textWidth <= 0) return segments.map(() => 40);
return segments.map(seg => { return segments.map((seg: ViewerSegment) => {
const text = seg.text?.trim() || ""; const text = seg.text?.trim() || "";
if (!text) return SEGMENT_PADDING + textLineHeight; if (!text) return SEGMENT_PADDING + textLineHeight;
const { height } = measureTextHeight(text, textFont, textWidth, textLineHeight); const { height } = measureTextHeight(text, textFont, textWidth, textLineHeight);
@@ -75,7 +92,7 @@
// Visible segment slice with positioning // Visible segment slice with positioning
let visibleItems = $derived.by(() => { let visibleItems = $derived.by(() => {
const items = []; const items: Array<{ seg: ViewerSegment; idx: number; top: number; height: number }> = [];
for (let i = visibleRange.start; i < visibleRange.end; i++) { for (let i = visibleRange.start; i < visibleRange.end; i++) {
items.push({ items.push({
seg: segments[i], seg: segments[i],
@@ -88,7 +105,7 @@
}); });
// Scroll to a specific segment index (used for active segment tracking + search) // Scroll to a specific segment index (used for active segment tracking + search)
export function scrollToSegment(idx) { export function scrollToSegment(idx: number) {
if (!containerEl || idx < 0 || idx >= segments.length) return; if (!containerEl || idx < 0 || idx >= segments.length) return;
const segTop = cumulativeOffsets[idx]; const segTop = cumulativeOffsets[idx];
const segHeight = segmentHeights[idx]; const segHeight = segmentHeights[idx];
@@ -96,8 +113,8 @@
containerEl.scrollTop = segTop - viewCenter + segHeight / 2; containerEl.scrollTop = segTop - viewCenter + segHeight / 2;
} }
function onScroll(e) { function onScroll(e: Event) {
scrollTop = e.target.scrollTop; scrollTop = (e.currentTarget as HTMLDivElement).scrollTop;
// Track selection state for expanded buffer // Track selection state for expanded buffer
hasSelection = !document.getSelection()?.isCollapsed; hasSelection = !document.getSelection()?.isCollapsed;
} }
@@ -105,7 +122,7 @@
// ResizeObserver for container dimensions // ResizeObserver for container dimensions
$effect(() => { $effect(() => {
if (!containerEl) return; if (!containerEl) return;
const ro = new ResizeObserver(entries => { const ro = new ResizeObserver((entries) => {
for (const entry of entries) { for (const entry of entries) {
containerHeight = entry.contentRect.height; containerHeight = entry.contentRect.height;
containerWidth = entry.contentRect.width; containerWidth = entry.contentRect.width;
@@ -119,7 +136,7 @@
$effect(() => { $effect(() => {
if (activeSegmentIdx >= 0) { if (activeSegmentIdx >= 0) {
// Find the index in our segments array that matches // Find the index in our segments array that matches
const arrayIdx = segments.findIndex(s => s._idx === activeSegmentIdx); const arrayIdx = segments.findIndex((s: ViewerSegment) => s._idx === activeSegmentIdx);
if (arrayIdx >= 0) { if (arrayIdx >= 0) {
const segTop = cumulativeOffsets[arrayIdx]; const segTop = cumulativeOffsets[arrayIdx];
const segBottom = segTop + segmentHeights[arrayIdx]; const segBottom = segTop + segmentHeights[arrayIdx];

View File

@@ -1,4 +1,4 @@
<script> <script lang="ts">
import { tasks, addTask, completeTask, uncompleteTask, deleteTask } from '$lib/stores/page.svelte.js'; import { tasks, addTask, completeTask, uncompleteTask, deleteTask } from '$lib/stores/page.svelte.js';
import MicroSteps from '$lib/components/MicroSteps.svelte'; import MicroSteps from '$lib/components/MicroSteps.svelte';
import { ChevronDown, ChevronRight } from 'lucide-svelte'; import { ChevronDown, ChevronRight } from 'lucide-svelte';
@@ -7,7 +7,7 @@
let newTaskText = $state(''); let newTaskText = $state('');
let showOverflow = $state(false); let showOverflow = $state(false);
let expandedTaskIds = $state(new Set()); let expandedTaskIds = $state(new Set<string>());
// Exclude subtasks from WIP count — only top-level tasks count // Exclude subtasks from WIP count — only top-level tasks count
let activeTasks = $derived(tasks.filter(t => !t.done && !t.parentTaskId)); let activeTasks = $derived(tasks.filter(t => !t.done && !t.parentTaskId));
@@ -22,11 +22,11 @@
newTaskText = ''; newTaskText = '';
} }
function handleKeydown(e) { function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') handleAdd(); if (e.key === 'Enter') handleAdd();
} }
function toggleExpand(taskId) { function toggleExpand(taskId: string) {
const next = new Set(expandedTaskIds); const next = new Set(expandedTaskIds);
if (next.has(taskId)) { if (next.has(taskId)) {
next.delete(taskId); next.delete(taskId);

View File

@@ -1,4 +1,4 @@
<script> <script lang="ts">
import { getPreferences, updatePreferences } from '$lib/stores/preferences.svelte.js'; import { getPreferences, updatePreferences } from '$lib/stores/preferences.svelte.js';
const prefs = getPreferences(); const prefs = getPreferences();
@@ -8,9 +8,9 @@
{ id: 'cave', label: 'Cave', description: 'Cool, focused', swatch: 'bg-[#141a1e]' }, { id: 'cave', label: 'Cave', description: 'Cool, focused', swatch: 'bg-[#141a1e]' },
{ id: 'energy', label: 'Energy', description: 'Warm, active', swatch: 'bg-[#1c1815]' }, { id: 'energy', label: 'Energy', description: 'Warm, active', swatch: 'bg-[#1c1815]' },
{ id: 'reset', label: 'Reset', description: 'Natural, calm', swatch: 'bg-[#161a16]' }, { id: 'reset', label: 'Reset', description: 'Natural, calm', swatch: 'bg-[#161a16]' },
]; ] as const;
function selectZone(id) { function selectZone(id: string) {
updatePreferences({ zone: id }); updatePreferences({ zone: id });
} }
</script> </script>

View File

@@ -1,4 +1,5 @@
<script> <script lang="ts">
// @ts-nocheck
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { Channel, invoke } from "@tauri-apps/api/core"; import { Channel, invoke } from "@tauri-apps/api/core";
import { page, settings, templates, profiles, addToHistory, addTask, tasks } from "$lib/stores/page.svelte.js"; import { page, settings, templates, profiles, addToHistory, addTask, tasks } from "$lib/stores/page.svelte.js";

View File

@@ -1,4 +1,5 @@
<script> <script lang="ts">
// @ts-nocheck
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";

View File

@@ -1,4 +1,5 @@
<script> <script lang="ts">
// @ts-nocheck
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { page, settings } from "$lib/stores/page.svelte.js"; import { page, settings } from "$lib/stores/page.svelte.js";

View File

@@ -1,4 +1,5 @@
<script> <script lang="ts">
// @ts-nocheck
import { onDestroy } from "svelte"; import { onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { history, saveHistory, deleteFromHistory, renameHistoryEntry } from "$lib/stores/page.svelte.js"; import { history, saveHistory, deleteFromHistory, renameHistoryEntry } from "$lib/stores/page.svelte.js";

View File

@@ -1,4 +1,5 @@
<script> <script lang="ts">
// @ts-nocheck
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";

View File

@@ -1,4 +1,6 @@
<script> <script lang="ts">
import { tick } from "svelte";
import type { TaskBucket, TaskList } from "$lib/types/app";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { import {
tasks, addTask, completeTask, uncompleteTask, deleteTask, updateTask, tasks, addTask, completeTask, uncompleteTask, deleteTask, updateTask,
@@ -21,17 +23,29 @@
let newListName = $state(""); let newListName = $state("");
let sortMode = $state("date"); let sortMode = $state("date");
let showSortMenu = $state(false); let showSortMenu = $state(false);
let contextMenuListId = $state(null); let contextMenuListId = $state<string | null>(null);
let editingListId = $state(null); let editingListId = $state<string | null>(null);
let editingName = $state(""); let editingName = $state("");
let editingInputEl = $state<HTMLInputElement | null>(null);
let newListInputEl = $state<HTMLInputElement | null>(null);
const buckets = [ const buckets: Array<{ id: "all" | TaskBucket; label: string }> = [
{ id: "all", label: "All" }, { id: "all", label: "All" },
{ id: "inbox", label: "Inbox" }, { id: "inbox", label: "Inbox" },
{ id: "today", label: "Today" }, { id: "today", label: "Today" },
{ id: "soon", label: "Soon" }, { id: "soon", label: "Soon" },
{ id: "later", label: "Later" }, { id: "later", label: "Later" },
]; ];
const taskBucketOptions: TaskBucket[] = ["today", "soon", "later"];
const effortOptions = ["quick", "medium", "deep"];
function effortRank(effort: string): number {
return EFFORT_ORDER[effort as keyof typeof EFFORT_ORDER] || 4;
}
function effortLabel(effort: string): string {
return EFFORT_LABELS[effort as keyof typeof EFFORT_LABELS] || effort;
}
let filteredTasks = $derived.by(() => { let filteredTasks = $derived.by(() => {
let list = tasks.filter((t) => !t.done); let list = tasks.filter((t) => !t.done);
@@ -50,9 +64,9 @@
list = list.filter((t) => t.text.toLowerCase().includes(q)); list = list.filter((t) => t.text.toLowerCase().includes(q));
} }
if (sortMode === "quick-first") { if (sortMode === "quick-first") {
list.sort((a, b) => (EFFORT_ORDER[a.effort] || 4) - (EFFORT_ORDER[b.effort] || 4)); list.sort((a, b) => effortRank(a.effort) - effortRank(b.effort));
} else if (sortMode === "deep-first") { } else if (sortMode === "deep-first") {
list.sort((a, b) => (EFFORT_ORDER[b.effort] || 4) - (EFFORT_ORDER[a.effort] || 4)); list.sort((a, b) => effortRank(b.effort) - effortRank(a.effort));
} }
return list; return list;
}); });
@@ -77,7 +91,7 @@
}); });
let bucketCounts = $derived.by(() => { let bucketCounts = $derived.by(() => {
const counts = { all: 0, inbox: 0, today: 0, soon: 0, later: 0 }; const counts: Record<"all" | TaskBucket, number> = { all: 0, inbox: 0, today: 0, soon: 0, later: 0 };
for (const t of tasks) { for (const t of tasks) {
if (t.done) continue; if (t.done) continue;
counts.all++; counts.all++;
@@ -86,7 +100,7 @@
return counts; return counts;
}); });
function countForList(listId) { function countForList(listId: string) {
if (listId === "all") return tasks.filter((t) => !t.done).length; if (listId === "all") return tasks.filter((t) => !t.done).length;
if (listId === "inbox") return tasks.filter((t) => !t.done && !t.listId).length; if (listId === "inbox") return tasks.filter((t) => !t.done && !t.listId).length;
return tasks.filter((t) => !t.done && t.listId === listId).length; return tasks.filter((t) => !t.done && t.listId === listId).length;
@@ -96,27 +110,27 @@
activeListId === "all" ? "All Lists" : (taskLists.find((l) => l.id === activeListId)?.name || "Tasks") activeListId === "all" ? "All Lists" : (taskLists.find((l) => l.id === activeListId)?.name || "Tasks")
); );
function handleQuickAdd(e) { function handleQuickAdd(e: KeyboardEvent) {
if (e.key === "Enter" && quickInput.trim()) { if (e.key === "Enter" && quickInput.trim()) {
const listId = (activeListId === "all" || activeListId === "inbox") ? null : activeListId; const listId = (activeListId === "all" || activeListId === "inbox") ? null : activeListId;
addTask({ addTask({
text: quickInput.trim(), text: quickInput.trim(),
bucket: activeBucket === "all" ? "inbox" : activeBucket, bucket: (activeBucket === "all" ? "inbox" : activeBucket) as TaskBucket,
listId, listId,
}); });
quickInput = ""; quickInput = "";
} }
} }
function setBucket(taskId, bucket) { function setBucket(taskId: string, bucket: TaskBucket) {
updateTask(taskId, { bucket }); updateTask(taskId, { bucket });
} }
function setEffort(taskId, effort) { function setEffort(taskId: string, effort: string) {
updateTask(taskId, { effort: effort === tasks.find((t) => t.id === taskId)?.effort ? "" : effort }); updateTask(taskId, { effort: effort === tasks.find((t) => t.id === taskId)?.effort ? "" : effort });
} }
function getListName(listId) { function getListName(listId: string | null) {
if (!listId) return null; if (!listId) return null;
return taskLists.find((l) => l.id === listId)?.name || null; return taskLists.find((l) => l.id === listId)?.name || null;
} }
@@ -129,7 +143,7 @@
} }
} }
function handleCreateList(e) { function handleCreateList(e: KeyboardEvent) {
if (e.key === "Enter" && newListName.trim()) { if (e.key === "Enter" && newListName.trim()) {
addTaskList(newListName.trim()); addTaskList(newListName.trim());
newListName = ""; newListName = "";
@@ -138,10 +152,13 @@
if (e.key === "Escape") { showNewList = false; newListName = ""; } if (e.key === "Escape") { showNewList = false; newListName = ""; }
} }
function startRenaming(list) { async function startRenaming(list: TaskList) {
editingListId = list.id; editingListId = list.id;
editingName = list.name; editingName = list.name;
contextMenuListId = null; contextMenuListId = null;
await tick();
editingInputEl?.focus();
editingInputEl?.select();
} }
function finishRenaming() { function finishRenaming() {
@@ -152,17 +169,26 @@
editingName = ""; editingName = "";
} }
function handleDeleteList(id) { function handleDeleteList(id: string) {
if (activeListId === id) activeListId = "all"; if (activeListId === id) activeListId = "all";
deleteTaskList(id); deleteTaskList(id);
contextMenuListId = null; contextMenuListId = null;
} }
function toggleContextMenu(e, listId) { function toggleContextMenu(e: MouseEvent, listId: string) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
contextMenuListId = contextMenuListId === listId ? null : listId; contextMenuListId = contextMenuListId === listId ? null : listId;
} }
$effect(() => {
if (showNewList) {
tick().then(() => {
newListInputEl?.focus();
newListInputEl?.select();
});
}
});
</script> </script>
<div class="flex flex-col h-full animate-fade-in"> <div class="flex flex-col h-full animate-fade-in">
@@ -297,13 +323,13 @@
{#if editingListId === list.id && !sidebarCollapsed} {#if editingListId === list.id && !sidebarCollapsed}
<div class="px-1.5 py-0.5"> <div class="px-1.5 py-0.5">
<input <input
bind:this={editingInputEl}
type="text" type="text"
class="w-full bg-bg-input border border-accent rounded px-2 py-1 text-[10px] text-text focus:outline-none" class="w-full bg-bg-input border border-accent rounded px-2 py-1 text-[10px] text-text focus:outline-none"
bind:value={editingName} bind:value={editingName}
onkeydown={(e) => { if (e.key === "Enter") finishRenaming(); if (e.key === "Escape") { editingListId = null; } }} onkeydown={(e) => { if (e.key === "Enter") finishRenaming(); if (e.key === "Escape") { editingListId = null; } }}
onblur={finishRenaming} onblur={finishRenaming}
data-no-transition data-no-transition
autofocus
/> />
</div> </div>
{:else} {:else}
@@ -356,6 +382,7 @@
<div class="px-2 py-2 border-t border-border-subtle"> <div class="px-2 py-2 border-t border-border-subtle">
{#if showNewList} {#if showNewList}
<input <input
bind:this={newListInputEl}
type="text" type="text"
class="w-full bg-bg-input border border-border rounded px-2 py-1 text-[10px] text-text class="w-full bg-bg-input border border-border rounded px-2 py-1 text-[10px] text-text
placeholder:text-text-tertiary focus:outline-none focus:border-accent" placeholder:text-text-tertiary focus:outline-none focus:border-accent"
@@ -364,7 +391,6 @@
onkeydown={handleCreateList} onkeydown={handleCreateList}
onblur={() => { showNewList = false; newListName = ""; }} onblur={() => { showNewList = false; newListName = ""; }}
data-no-transition data-no-transition
autofocus
/> />
{:else} {:else}
<button <button
@@ -403,7 +429,7 @@
<p class="text-[13px] text-text leading-relaxed">{task.text}</p> <p class="text-[13px] text-text leading-relaxed">{task.text}</p>
<div class="flex items-center gap-2 mt-1.5"> <div class="flex items-center gap-2 mt-1.5">
<!-- Bucket pills --> <!-- Bucket pills -->
{#each ["today", "soon", "later"] as b} {#each taskBucketOptions as b}
<button <button
class="text-[10px] px-2 py-0.5 rounded-full border class="text-[10px] px-2 py-0.5 rounded-full border
{task.bucket === b {task.bucket === b
@@ -415,7 +441,7 @@
{/each} {/each}
<span class="text-border-subtle">&middot;</span> <span class="text-border-subtle">&middot;</span>
<!-- Effort pills --> <!-- Effort pills -->
{#each ["quick", "medium", "deep"] as e} {#each effortOptions as e}
<button <button
class="text-[10px] px-2 py-0.5 rounded-full border class="text-[10px] px-2 py-0.5 rounded-full border
{task.effort === e {task.effort === e
@@ -423,7 +449,7 @@
: 'text-text-tertiary border-transparent hover:border-border-subtle hover:text-text-secondary'}" : 'text-text-tertiary border-transparent hover:border-border-subtle hover:text-text-secondary'}"
style="transition-duration: var(--duration-ui)" style="transition-duration: var(--duration-ui)"
onclick={() => setEffort(task.id, e)} onclick={() => setEffort(task.id, e)}
>{EFFORT_LABELS[e]}</button> >{effortLabel(e)}</button>
{/each} {/each}
<!-- Timestamp --> <!-- Timestamp -->
{#if task.createdAt} {#if task.createdAt}
@@ -457,7 +483,7 @@
class="flex items-center gap-2 text-[12px] text-text-tertiary hover:text-text-secondary mb-2" class="flex items-center gap-2 text-[12px] text-text-tertiary hover:text-text-secondary mb-2"
onclick={() => showCompleted = !showCompleted} onclick={() => showCompleted = !showCompleted}
> >
<ChevronRight size={12} class="{showCompleted ? 'rotate-90' : ''}" aria-hidden="true" <ChevronRight size={12} class={showCompleted ? "rotate-90" : ""} aria-hidden="true"
style="transition: transform var(--duration-ui)" /> style="transition: transform var(--duration-ui)" />
Completed ({completedTasks.length}) Completed ({completedTasks.length})
</button> </button>

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 // src/lib/stores/preferences.svelte.js
import { invoke } from '@tauri-apps/api/core'; import { invoke } from "@tauri-apps/api/core";
import { emit } from '@tauri-apps/api/event'; import { emit } from "@tauri-apps/api/event";
import { getCurrentWindow } from '@tauri-apps/api/window'; import { getCurrentWindow } from "@tauri-apps/api/window";
import { toasts } from './toasts.svelte.js'; 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() { function currentWindowLabel() {
try { try {
@@ -14,7 +18,7 @@ function currentWindowLabel() {
} }
} }
function broadcastPreferences(prefs) { function broadcastPreferences(prefs: Preferences) {
const source = currentWindowLabel(); const source = currentWindowLabel();
if (source === null) return; if (source === null) return;
// Fire-and-forget — cross-window sync must never block the local apply path. // Fire-and-forget — cross-window sync must never block the local apply path.
@@ -22,53 +26,53 @@ function broadcastPreferences(prefs) {
.catch(() => {}); .catch(() => {});
} }
const DEFAULTS = { const DEFAULTS: Preferences = {
theme: 'dark', theme: "dark",
zone: 'default', zone: "default",
accessibility: { accessibility: {
fontFamily: 'lexend', fontFamily: "lexend",
fontSize: 16, fontSize: 16,
letterSpacing: 0, letterSpacing: 0,
lineHeight: 1.5, lineHeight: 1.5,
transcriptSize: 16, transcriptSize: 16,
bionicReading: false, bionicReading: false,
reduceMotion: 'system' reduceMotion: "system",
} },
}; };
const FONT_FAMILIES = { const FONT_FAMILIES: FontFamilies = {
lexend: "'Lexend', system-ui, sans-serif", lexend: "'Lexend', system-ui, sans-serif",
atkinson: "'Atkinson Hyperlegible Next', 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; const el = document.documentElement;
return { return {
theme: el.dataset.theme || DEFAULTS.theme, theme: (el.dataset.theme || DEFAULTS.theme) as Preferences["theme"],
zone: el.dataset.zone || DEFAULTS.zone, zone: el.dataset.zone || DEFAULTS.zone,
accessibility: { 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, fontSize: parseFloat(el.style.getPropertyValue('--font-size-body')) || DEFAULTS.accessibility.fontSize,
letterSpacing: parseFloat(el.style.getPropertyValue('--letter-spacing-body')) || DEFAULTS.accessibility.letterSpacing, letterSpacing: parseFloat(el.style.getPropertyValue('--letter-spacing-body')) || DEFAULTS.accessibility.letterSpacing,
lineHeight: parseFloat(el.style.getPropertyValue('--line-height-body')) || DEFAULTS.accessibility.lineHeight, lineHeight: parseFloat(el.style.getPropertyValue('--line-height-body')) || DEFAULTS.accessibility.lineHeight,
transcriptSize: DEFAULTS.accessibility.transcriptSize, transcriptSize: DEFAULTS.accessibility.transcriptSize,
bionicReading: el.dataset.bionicReading === 'true', bionicReading: el.dataset.bionicReading === "true",
reduceMotion: el.dataset.reduceMotion || DEFAULTS.accessibility.reduceMotion reduceMotion: (el.dataset.reduceMotion || DEFAULTS.accessibility.reduceMotion) as AccessibilityPreferences["reduceMotion"],
} },
}; };
} }
function applyToDOM(prefs) { function applyToDOM(prefs: Preferences) {
const el = document.documentElement; const el = document.documentElement;
// Theme — resolve 'system' to actual value // Theme — resolve 'system' to actual value
el.dataset.theme = prefs.theme === 'system' el.dataset.theme = prefs.theme === "system"
? (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark') ? (window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark")
: prefs.theme; : prefs.theme;
// Zone // Zone
if (prefs.zone === 'default') { if (prefs.zone === "default") {
delete el.dataset.zone; delete el.dataset.zone;
} else { } else {
el.dataset.zone = prefs.zone; el.dataset.zone = prefs.zone;
@@ -85,32 +89,31 @@ function applyToDOM(prefs) {
el.dataset.fontFamily = a.fontFamily; el.dataset.fontFamily = a.fontFamily;
// Reduce motion — three-value resolution // Reduce motion — three-value resolution
const motionReduced = a.reduceMotion === 'on' const motionReduced = a.reduceMotion === "on"
|| (a.reduceMotion === 'system' && window.matchMedia('(prefers-reduced-motion: reduce)').matches); || (a.reduceMotion === "system" && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
if (motionReduced) { if (motionReduced) {
el.dataset.reduceMotion = 'true'; el.dataset.reduceMotion = "true";
} else { } else {
delete el.dataset.reduceMotion; 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 // 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. // doesn't spam the user every time they nudge a slider.
let saveFailureToastShown = false; let saveFailureToastShown = false;
function persistToSQLite(prefs) { function persistToSQLite(prefs: Preferences) {
clearTimeout(saveTimeout); clearTimeout(saveTimeout);
saveTimeout = setTimeout(async () => { saveTimeout = setTimeout(async () => {
try { try {
await invoke('save_preferences', { preferences: JSON.stringify(prefs) }); await invoke("save_preferences", { preferences: JSON.stringify(prefs) });
saveFailureToastShown = false; saveFailureToastShown = false;
} catch (e) { } catch (e) {
console.error('Failed to save preferences:', e); console.error("Failed to save preferences:", e);
if (!saveFailureToastShown) { if (!saveFailureToastShown) {
const msg = typeof e === 'string' ? e : (e?.message ?? String(e));
toasts.warn( toasts.warn(
'Could not save preferences', "Could not save preferences",
`${msg}. Your changes still apply for this session.`, `${errorMessage(e)}. Your changes still apply for this session.`,
); );
saveFailureToastShown = true; saveFailureToastShown = true;
} }
@@ -133,14 +136,14 @@ export function getPreferences() {
return preferences; return preferences;
} }
export function updatePreferences(updates) { export function updatePreferences(updates: Partial<Preferences>) {
Object.assign(preferences, updates); Object.assign(preferences, updates);
applyToDOM(preferences); applyToDOM(preferences);
persistToSQLite(preferences); persistToSQLite(preferences);
broadcastPreferences(preferences); broadcastPreferences(preferences);
} }
export function updateAccessibility(updates) { export function updateAccessibility(updates: Partial<AccessibilityPreferences>) {
Object.assign(preferences.accessibility, updates); Object.assign(preferences.accessibility, updates);
applyToDOM(preferences); applyToDOM(preferences);
persistToSQLite(preferences); persistToSQLite(preferences);
@@ -149,8 +152,8 @@ export function updateAccessibility(updates) {
// Apply preferences received from another Tauri window. Mutates local state // Apply preferences received from another Tauri window. Mutates local state
// and DOM only — never persists or re-broadcasts, so there is no echo loop. // and DOM only — never persists or re-broadcasts, so there is no echo loop.
export function applyExternalPreferences(prefs) { export function applyExternalPreferences(prefs: Partial<Preferences> | null | undefined) {
if (!prefs || typeof prefs !== 'object') return; if (!prefs || typeof prefs !== "object") return;
Object.assign(preferences, prefs); Object.assign(preferences, prefs);
if (prefs.accessibility) { if (prefs.accessibility) {
Object.assign(preferences.accessibility, prefs.accessibility); Object.assign(preferences.accessibility, prefs.accessibility);
@@ -159,11 +162,11 @@ export function applyExternalPreferences(prefs) {
} }
// Re-resolve when OS preferences change // Re-resolve when OS preferences change
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => { window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => {
if (preferences.theme === 'system') applyToDOM(preferences); if (preferences.theme === "system") applyToDOM(preferences);
}); });
window.matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', () => { window.matchMedia("(prefers-reduced-motion: reduce)").addEventListener("change", () => {
if (preferences.accessibility.reduceMotion === 'system') applyToDOM(preferences); 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 // 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 // stays lean. Toasts auto-dismiss after `duration` ms unless duration is 0
// (sticky) or unless the user clicks the close button. // (sticky) or unless the user clicks the close button.
@@ -15,22 +18,22 @@
let nextId = 1; let nextId = 1;
function defaultDuration(severity) { function defaultDuration(severity: ToastSeverity): number {
switch (severity) { switch (severity) {
case 'success': return 3000; case "success": return 3000;
case 'info': return 4000; case "info": return 4000;
case 'warn': return 6000; case "warn": return 6000;
case 'error': return 0; // sticky case "error": return 0; // sticky
default: return 4000; default: return 4000;
} }
} }
function createToastsStore() { function createToastsStore() {
// $state requires a class field or top-level let in Svelte 5; we expose // $state requires a class field or top-level let in Svelte 5; we expose
// `items` via a getter on the singleton. // `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 id = nextId++;
const duration = defaultDuration(severity); const duration = defaultDuration(severity);
const toast = { id, severity, title, body: body ?? '', duration }; const toast = { id, severity, title, body: body ?? '', duration };
@@ -41,7 +44,7 @@ function createToastsStore() {
return id; return id;
} }
function dismiss(id) { function dismiss(id: number) {
const ix = items.findIndex(t => t.id === id); const ix = items.findIndex(t => t.id === id);
if (ix >= 0) items.splice(ix, 1); if (ix >= 0) items.splice(ix, 1);
} }
@@ -52,10 +55,10 @@ function createToastsStore() {
return { return {
get items() { return items; }, get items() { return items; },
info: (title, body) => show('info', title, body), info: (title: string, body?: string) => show("info", title, body),
success: (title, body) => show('success', title, body), success: (title: string, body?: string) => show("success", title, body),
warn: (title, body) => show('warn', title, body), warn: (title: string, body?: string) => show("warn", title, body),
error: (title, body) => show('error', title, body), error: (title: string, body?: string) => show("error", title, body),
dismiss, dismiss,
dismissAll, dismissAll,
}; };
@@ -72,12 +75,16 @@ export const toasts = createToastsStore();
// const result = await invokeWithToast('start_native_capture', { deviceName }); // const result = await invokeWithToast('start_native_capture', { deviceName });
// //
// Optional `errorTitle` overrides the default ("Action failed"). // 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 { try {
return await invokeFn(command, args); return await invokeFn(command, args);
} catch (err) { } catch (err) {
const msg = typeof err === 'string' ? err : (err?.message ?? String(err)); toasts.error(errorTitle ?? "Action failed", errorMessage(err));
toasts.error(errorTitle ?? 'Action failed', msg);
throw err; throw err;
} }
} }

243
src/lib/types/app.ts Normal file
View File

@@ -0,0 +1,243 @@
export type ThemeMode = "light" | "dark" | "system";
export type FontFamily = "lexend" | "atkinson" | "opendyslexic";
export type ReduceMotion = "system" | "on" | "off";
export type RecordingEngine = "whisper" | "parakeet";
export type FormatMode = "Raw" | "Clean" | "Smart";
export type WhisperModelSize = "Tiny" | "Base" | "Small" | "Medium";
export type TaskBucket = "inbox" | "today" | "soon" | "later";
export type ToastSeverity = "info" | "success" | "warn" | "error";
export interface PageState {
current: string;
status: string;
statusColor: string;
activeProfile: string;
recording: boolean;
timerText: string;
handedness: string;
taskSidebarOpen: boolean;
}
export interface SettingsState {
engine: RecordingEngine;
modelSize: WhisperModelSize;
language: string;
device: string;
formatMode: FormatMode;
removeFillers: boolean;
antiHallucination: boolean;
britishEnglish: boolean;
autoCopy: boolean;
includeTimestamps: boolean;
theme: "Dark" | "Light" | "System";
fontSize: number;
llmModelSize: string;
llmEnabled: boolean;
saveAudio: boolean;
outputFolder: string;
globalHotkey: string;
sidebarCollapsed: boolean;
microphoneDevice: string;
}
export interface Profile {
name: string;
words: string;
}
export interface Template {
name: string;
sections: string[];
}
export interface AccessibilityPreferences {
fontFamily: FontFamily;
fontSize: number;
letterSpacing: number;
lineHeight: number;
transcriptSize: number;
bionicReading: boolean;
reduceMotion: ReduceMotion;
}
export interface Preferences {
theme: ThemeMode;
zone: string;
accessibility: AccessibilityPreferences;
}
export interface Segment {
start: number;
end: number;
text: string;
starred?: boolean;
[key: string]: unknown;
}
export interface TranscriptDto {
id: string;
text: string;
source: string;
title: string | null;
audioPath: string | null;
duration: number;
engine: string | null;
modelId: string | null;
createdAt: string;
starred: boolean;
manualTags: string;
template: string;
language: string;
segmentsJson: string;
}
export interface TranscriptEntry {
id: string;
text: string;
source: string;
title: string;
audioPath: string | null;
duration: number;
engine: string | null;
modelId: string | null;
createdAt: string | null;
date: string;
preview: string;
starred: boolean;
manualTags: string[];
template: string;
language: string;
segments: Segment[];
}
export interface TranscriptWriteEntry extends Partial<TranscriptEntry> {
id: string;
text: string;
inferenceMs?: number | null;
sampleRate?: number | null;
audioChannels?: number | null;
formatMode?: string | null;
removeFillers?: boolean;
britishEnglish?: boolean;
antiHallucination?: boolean;
}
export interface TranscriptMetaPatch {
starred?: boolean;
manualTags?: string[] | string;
template?: string;
language?: string;
segments?: Segment[];
}
export interface TaskDto {
id: string;
text: string;
bucket: string;
listId: string | null;
effort: string | null;
notes: string;
done: boolean;
doneAt: string | null;
createdAt: string;
sourceTranscriptId: string | null;
parentTaskId: string | null;
}
export interface TaskEntry {
id: string;
text: string;
bucket: TaskBucket;
listId: string | null;
effort: string;
notes: string;
done: boolean;
doneAt: string | null;
createdAt: string;
sourceTranscriptId: string | null;
parentTaskId: string | null;
}
export interface TaskDraft {
text: string;
bucket?: TaskBucket;
listId?: string | null;
effort?: string | null;
sourceTranscriptId?: string | null;
}
export interface TaskUpdate {
text?: string | null;
bucket?: TaskBucket | null;
listId?: string | null;
effort?: string | null;
notes?: string | null;
}
export interface TaskList {
id: string;
name: string;
builtIn: boolean;
profileId?: string | null;
createdAt: string | null;
}
export interface ToastItem {
id: number;
severity: ToastSeverity;
title: string;
body: string;
duration: number;
}
export interface AudioDeviceInfo {
name: string;
sample_rate: number;
channels: number;
is_likely_monitor: boolean;
is_default: boolean;
description: string;
}
export interface DictionaryEntry {
id: number;
term: string;
note: string | null;
}
export interface LanguageSupportInfo {
kind: string;
languageCount: number;
}
export interface ModelRuntimeCapabilities {
id: string;
displayName: string;
downloaded: boolean;
loaded: boolean;
languageSupport: LanguageSupportInfo;
}
export interface EngineRuntimeCapabilities {
id: string;
defaultModelId: string;
loadedModelId: string | null;
supportsGpu: boolean;
models: ModelRuntimeCapabilities[];
}
export interface RuntimeCapabilities {
accelerators: string[];
engines: EngineRuntimeCapabilities[];
}
export interface DiagnosticReportOptions {
includeRecentErrors: boolean;
includeCrashes: boolean;
includeSettings: boolean;
includeLogTail: boolean;
}
export interface ViewerSegment extends Segment {
_idx: number;
}

View File

@@ -1,28 +0,0 @@
const PRETEXT_FONT_FAMILIES = {
lexend: "'Lexend'",
atkinson: "'Atkinson Hyperlegible Next'",
opendyslexic: "'OpenDyslexic'",
};
export function pretextFontFamily(fontFamily = "lexend") {
return PRETEXT_FONT_FAMILIES[fontFamily] || PRETEXT_FONT_FAMILIES.lexend;
}
export function pretextFontShorthand(accessibility = {}, sizePx = 16) {
return `${sizePx}px ${pretextFontFamily(accessibility.fontFamily)}`;
}
export function bodyPretextLineHeight(accessibility = {}, sizePx = 16) {
const ratio = accessibility.lineHeight || 1.5;
return Math.round(sizePx * ratio);
}
export function transcriptPretextFont(accessibility = {}) {
const sizePx = accessibility.transcriptSize || accessibility.fontSize || 16;
return pretextFontShorthand(accessibility, sizePx);
}
export function transcriptPretextLineHeight(accessibility = {}, ratio = 1.85) {
const sizePx = accessibility.transcriptSize || accessibility.fontSize || 16;
return Math.round(sizePx * ratio);
}

View File

@@ -0,0 +1,41 @@
import type { AccessibilityPreferences, FontFamily } from "$lib/types/app";
const PRETEXT_FONT_FAMILIES = {
lexend: "'Lexend'",
atkinson: "'Atkinson Hyperlegible Next'",
opendyslexic: "'OpenDyslexic'",
} satisfies Record<FontFamily, string>;
export function pretextFontFamily(fontFamily: FontFamily = "lexend"): string {
return PRETEXT_FONT_FAMILIES[fontFamily] || PRETEXT_FONT_FAMILIES.lexend;
}
export function pretextFontShorthand(
accessibility: Partial<AccessibilityPreferences> = {},
sizePx = 16,
): string {
return `${sizePx}px ${pretextFontFamily(accessibility.fontFamily)}`;
}
export function bodyPretextLineHeight(
accessibility: Partial<AccessibilityPreferences> = {},
sizePx = 16,
): number {
const ratio = accessibility.lineHeight || 1.5;
return Math.round(sizePx * ratio);
}
export function transcriptPretextFont(
accessibility: Partial<AccessibilityPreferences> = {},
): string {
const sizePx = accessibility.transcriptSize || accessibility.fontSize || 16;
return pretextFontShorthand(accessibility, sizePx);
}
export function transcriptPretextLineHeight(
accessibility: Partial<AccessibilityPreferences> = {},
ratio = 1.85,
): number {
const sizePx = accessibility.transcriptSize || accessibility.fontSize || 16;
return Math.round(sizePx * ratio);
}

8
src/lib/utils/errors.ts Normal file
View File

@@ -0,0 +1,8 @@
export function errorMessage(error: unknown): string {
if (typeof error === "string") return error;
if (error && typeof error === "object" && "message" in error) {
const message = (error as { message?: unknown }).message;
if (typeof message === "string") return message;
}
return String(error);
}

View File

@@ -1,4 +1,7 @@
import { pad, formatTimeSRT, formatTimeVTT } from "./time.js"; import { pad, formatTimeSRT, formatTimeVTT } from "./time.js";
import type { Segment } from "$lib/types/app";
type ExportFormat = "txt" | "md" | "csv" | "html" | "srt" | "vtt";
/** /**
* Export transcript in various formats. * Export transcript in various formats.
@@ -7,7 +10,11 @@ import { pad, formatTimeSRT, formatTimeVTT } from "./time.js";
* @param {string} format - "txt" | "md" | "csv" | "html" | "srt" | "vtt" * @param {string} format - "txt" | "md" | "csv" | "html" | "srt" | "vtt"
* @returns {string} * @returns {string}
*/ */
export function exportTranscript(text, segments, format) { export function exportTranscript(
text: string,
segments: Segment[],
format: ExportFormat,
): string {
switch (format) { switch (format) {
case "srt": case "srt":
return toSRT(segments); return toSRT(segments);
@@ -24,7 +31,7 @@ export function exportTranscript(text, segments, format) {
} }
} }
function toSRT(segments) { function toSRT(segments: Segment[]): string {
if (!segments || segments.length === 0) return ""; if (!segments || segments.length === 0) return "";
return segments return segments
.map((seg, i) => { .map((seg, i) => {
@@ -33,7 +40,7 @@ function toSRT(segments) {
.join("\n"); .join("\n");
} }
function toVTT(segments) { function toVTT(segments: Segment[]): string {
if (!segments || segments.length === 0) return ""; if (!segments || segments.length === 0) return "";
let out = "WEBVTT\n\n"; let out = "WEBVTT\n\n";
out += segments out += segments
@@ -44,7 +51,7 @@ function toVTT(segments) {
return out; return out;
} }
function toCSV(segments) { function toCSV(segments: Segment[]): string {
if (!segments || segments.length === 0) return "Start,End,Text\n"; if (!segments || segments.length === 0) return "Start,End,Text\n";
let csv = "Start,End,Text\n"; let csv = "Start,End,Text\n";
for (const seg of segments) { for (const seg of segments) {
@@ -54,11 +61,11 @@ function toCSV(segments) {
return csv; return csv;
} }
function escapeHtml(str) { function escapeHtml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
} }
function toHTML(text, segments) { function toHTML(text: string, segments: Segment[]): string {
let html = `<!DOCTYPE html> let html = `<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -95,7 +102,7 @@ function toHTML(text, segments) {
return html; return html;
} }
function toMarkdown(text, segments) { function toMarkdown(text: string, segments: Segment[]): string {
let md = `# Transcription\n\n`; let md = `# Transcription\n\n`;
md += `**Date:** ${new Date().toLocaleDateString("en-GB")}\n`; md += `**Date:** ${new Date().toLocaleDateString("en-GB")}\n`;
if (segments && segments.length > 0) { if (segments && segments.length > 0) {

View File

@@ -25,12 +25,12 @@ const WORD_BUCKETS = [
{ max: Infinity, tag: "words:very-long" }, { max: Infinity, tag: "words:very-long" },
]; ];
function durationTag(seconds) { function durationTag(seconds: number): string | null {
if (!Number.isFinite(seconds) || seconds <= 0) return null; if (!Number.isFinite(seconds) || seconds <= 0) return null;
return DURATION_BUCKETS.find((b) => seconds < b.max)?.tag ?? null; return DURATION_BUCKETS.find((b) => seconds < b.max)?.tag ?? null;
} }
function wordCountTag(text) { function wordCountTag(text: string): string | null {
if (!text || typeof text !== "string") return null; if (!text || typeof text !== "string") return null;
const count = text.trim().split(/\s+/).filter(Boolean).length; const count = text.trim().split(/\s+/).filter(Boolean).length;
return WORD_BUCKETS.find((b) => count < b.max)?.tag ?? null; return WORD_BUCKETS.find((b) => count < b.max)?.tag ?? null;
@@ -39,7 +39,7 @@ function wordCountTag(text) {
// Resolve time-of-day bucket from an ISO date or a legacy string like // Resolve time-of-day bucket from an ISO date or a legacy string like
// "19/04/2026, 11:37:23". Thresholds are fixed and local to the user's // "19/04/2026, 11:37:23". Thresholds are fixed and local to the user's
// machine — hour 6-11 morning, 12-17 afternoon, 18-21 evening, else night. // machine — hour 6-11 morning, 12-17 afternoon, 18-21 evening, else night.
function timeOfDayTag(dateStr) { function timeOfDayTag(dateStr: string): string | null {
if (!dateStr) return null; if (!dateStr) return null;
let ts = Date.parse(dateStr); let ts = Date.parse(dateStr);
if (Number.isNaN(ts)) { if (Number.isNaN(ts)) {
@@ -62,7 +62,7 @@ function timeOfDayTag(dateStr) {
return "time:night"; return "time:night";
} }
function sourceTag(source) { function sourceTag(source: string): string | null {
if (!source) return null; if (!source) return null;
const s = String(source).toLowerCase(); const s = String(source).toLowerCase();
if (s.includes("file")) return "source:file"; if (s.includes("file")) return "source:file";
@@ -76,17 +76,17 @@ function sourceTag(source) {
// information and add cognitive load without improving retrieval. The // information and add cognitive load without improving retrieval. The
// function is kept as a hook for one future AI-derived content tag // function is kept as a hook for one future AI-derived content tag
// (`topic:*`) once kon-llm wires up real llama-cpp-2 in Phase 3. // (`topic:*`) once kon-llm wires up real llama-cpp-2 in Phase 3.
export function deriveAutoTags(_item) { export function deriveAutoTags(_item: TranscriptEntry): string[] {
return []; return [];
} }
export function normaliseTag(raw) { export function normaliseTag(raw: string): string {
return String(raw || "").trim().toLowerCase().replace(/\s+/g, "-"); return String(raw || "").trim().toLowerCase().replace(/\s+/g, "-");
} }
// Build the flat frontmatter object that represents a transcript's metadata. // Build the flat frontmatter object that represents a transcript's metadata.
// Shown in the expanded History row and serialised when exporting to .md. // Shown in the expanded History row and serialised when exporting to .md.
export function buildFrontmatter(item) { export function buildFrontmatter(item: TranscriptEntry | null) {
if (!item) return {}; if (!item) return {};
const auto = deriveAutoTags(item); const auto = deriveAutoTags(item);
const manual = Array.isArray(item.manualTags) ? item.manualTags : []; const manual = Array.isArray(item.manualTags) ? item.manualTags : [];
@@ -105,7 +105,7 @@ export function buildFrontmatter(item) {
// Escape a YAML scalar. Keeps things simple — quote if it contains any // Escape a YAML scalar. Keeps things simple — quote if it contains any
// character that would otherwise need escaping in plain scalars. // character that would otherwise need escaping in plain scalars.
function yamlScalar(value) { function yamlScalar(value: unknown): string {
if (value === null || value === undefined) return "null"; if (value === null || value === undefined) return "null";
if (typeof value === "number" || typeof value === "boolean") return String(value); if (typeof value === "number" || typeof value === "boolean") return String(value);
const s = String(value); const s = String(value);
@@ -114,7 +114,7 @@ function yamlScalar(value) {
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
} }
export function serialiseFrontmatter(fm) { export function serialiseFrontmatter(fm: Record<string, unknown>): string {
const lines = ["---"]; const lines = ["---"];
for (const [key, value] of Object.entries(fm)) { for (const [key, value] of Object.entries(fm)) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
@@ -133,10 +133,11 @@ export function serialiseFrontmatter(fm) {
} }
// Produce an Obsidian-flavoured markdown document for a transcript. // Produce an Obsidian-flavoured markdown document for a transcript.
export function buildMarkdown(item) { export function buildMarkdown(item: TranscriptEntry): string {
const fm = buildFrontmatter(item); const fm = buildFrontmatter(item);
const header = serialiseFrontmatter(fm); const header = serialiseFrontmatter(fm);
const title = fm.title || "Transcript"; const title = fm.title || "Transcript";
const body = item?.text || ""; const body = item?.text || "";
return `${header}\n\n# ${title}\n\n${body}\n`; return `${header}\n\n# ${title}\n\n${body}\n`;
} }
import type { TranscriptEntry } from "$lib/types/app";

View File

@@ -18,7 +18,17 @@
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { hasTauriRuntime } from './runtime.js'; import { hasTauriRuntime } from './runtime.js';
let cached = null; interface OsInfo {
os: string;
arch: string;
family: string;
usesCmd: boolean;
isWayland: boolean;
customHotkeyBackend: boolean;
primaryModifierLabel: string;
}
let cached: OsInfo | null = null;
const FALLBACK_BROWSER_INFO = { const FALLBACK_BROWSER_INFO = {
os: detectBrowserOs(), os: detectBrowserOs(),
@@ -52,14 +62,14 @@ function detectBrowserFamily() {
/** Fetch OS info from the Tauri backend. Idempotent call multiple times, /** Fetch OS info from the Tauri backend. Idempotent call multiple times,
* only the first does the round-trip. Browser-preview mode returns the * only the first does the round-trip. Browser-preview mode returns the
* navigator-detected fallback. */ * navigator-detected fallback. */
export async function loadOsInfo() { export async function loadOsInfo(): Promise<OsInfo> {
if (cached) return cached; if (cached) return cached;
if (!hasTauriRuntime()) { if (!hasTauriRuntime()) {
cached = FALLBACK_BROWSER_INFO; cached = FALLBACK_BROWSER_INFO;
return cached; return cached;
} }
try { try {
cached = await invoke('get_os_info'); cached = await invoke<OsInfo>("get_os_info");
} catch (err) { } catch (err) {
console.warn('loadOsInfo: get_os_info failed, using browser fallback', err); console.warn('loadOsInfo: get_os_info failed, using browser fallback', err);
cached = FALLBACK_BROWSER_INFO; cached = FALLBACK_BROWSER_INFO;
@@ -70,31 +80,31 @@ export async function loadOsInfo() {
/** Synchronous accessor. Returns null if loadOsInfo has not been awaited /** Synchronous accessor. Returns null if loadOsInfo has not been awaited
* yet. Components that need the value at first render should await * yet. Components that need the value at first render should await
* loadOsInfo() in onMount before reading. */ * loadOsInfo() in onMount before reading. */
export function osInfo() { export function osInfo(): OsInfo | null {
return cached; return cached;
} }
export function isMac() { export function isMac(): boolean {
return cached?.os === 'macos'; return cached?.os === 'macos';
} }
export function isWindows() { export function isWindows(): boolean {
return cached?.os === 'windows'; return cached?.os === 'windows';
} }
export function isLinux() { export function isLinux(): boolean {
return cached?.os === 'linux'; return cached?.os === 'linux';
} }
/** Localised label for the primary keyboard modifier. "Cmd" on macOS, /** Localised label for the primary keyboard modifier. "Cmd" on macOS,
* "Ctrl" everywhere else. Use in hotkey display strings: * "Ctrl" everywhere else. Use in hotkey display strings:
* `${modKeyLabel()}+Shift+R` */ * `${modKeyLabel()}+Shift+R` */
export function modKeyLabel() { export function modKeyLabel(): string {
return cached?.primaryModifierLabel ?? 'Ctrl'; return cached?.primaryModifierLabel ?? 'Ctrl';
} }
/** True if the current Linux session is Wayland. False on Windows/macOS. /** True if the current Linux session is Wayland. False on Windows/macOS.
* Useful for Settings Audio "PipeWire detected" status display. */ * Useful for Settings Audio "PipeWire detected" status display. */
export function isWayland() { export function isWayland(): boolean {
return !!cached?.isWayland; return !!cached?.isWayland;
} }

View File

@@ -11,7 +11,7 @@
// behaviour for browser-preview mode. // behaviour for browser-preview mode.
export function hasTauriRuntime() { export function hasTauriRuntime() {
if (typeof window === 'undefined') return false; if (typeof window === "undefined") return false;
if (window.__TAURI_INTERNALS__) return true; if (window.__TAURI_INTERNALS__) return true;
if (window.isTauri === true) return true; if (window.isTauri === true) return true;
return false; return false;

8
src/lib/utils/storage.ts Normal file
View File

@@ -0,0 +1,8 @@
export function parseStoredJson<T>(raw: string | null): T | null {
if (!raw) return null;
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}

View File

@@ -1,127 +0,0 @@
/**
* Rule-based task extraction from transcripts.
* Identifies action items from natural speech patterns.
*/
// Action verbs that typically start task sentences
const ACTION_VERBS = [
"call", "email", "send", "write", "build", "fix", "update", "check",
"review", "schedule", "book", "create", "set up", "follow up", "organise",
"prepare", "draft", "submit", "cancel", "confirm", "arrange", "order",
"buy", "get", "find", "look into", "research", "test", "deploy", "push",
"move", "add", "remove", "delete", "install", "configure", "contact",
"message", "tell", "ask", "invite", "remind", "finish", "complete",
"start", "begin", "plan", "design", "implement", "refactor",
];
// Phrases that signal a task
const TASK_PHRASES = [
"need to", "needs to", "should", "must", "have to", "has to",
"want to", "going to", "gonna", "got to", "gotta",
"don't forget to", "remember to", "make sure to", "make sure we",
"let's", "we should", "i should", "we need to", "i need to",
"we have to", "i have to", "we must", "i must",
];
// Explicit markers (user intentionally marks a task)
const EXPLICIT_MARKERS = [
"action:", "action item:", "todo:", "task:", "to do:",
"action item", "follow up:", "follow-up:",
];
/**
* Extract tasks from a transcript string.
* Returns an array of { text, confidence } objects.
*/
export function extractTasks(transcript) {
if (!transcript || !transcript.trim()) return [];
const tasks = [];
const seen = new Set();
// Split into sentences (rough but effective for speech)
const sentences = splitSentences(transcript);
for (const sentence of sentences) {
const trimmed = sentence.trim();
if (trimmed.length < 5) continue;
const lower = trimmed.toLowerCase();
let matched = false;
let confidence = 0;
// Check explicit markers (highest confidence)
for (const marker of EXPLICIT_MARKERS) {
if (lower.startsWith(marker) || lower.includes(marker)) {
const taskText = extractAfterMarker(trimmed, marker);
if (taskText && taskText.length > 3) {
addTask(tasks, seen, capitalise(taskText), 0.95);
matched = true;
break;
}
}
}
if (matched) continue;
// Check task phrases (high confidence)
for (const phrase of TASK_PHRASES) {
const idx = lower.indexOf(phrase);
if (idx >= 0) {
const taskText = extractTaskFromPhrase(trimmed, phrase, idx);
if (taskText && taskText.length > 3) {
addTask(tasks, seen, capitalise(taskText), 0.8);
matched = true;
break;
}
}
}
if (matched) continue;
// Check if sentence starts with an action verb (medium confidence)
const firstWord = lower.split(/\s+/)[0];
const firstTwo = lower.split(/\s+/).slice(0, 2).join(" ");
if (ACTION_VERBS.includes(firstWord) || ACTION_VERBS.includes(firstTwo)) {
addTask(tasks, seen, capitalise(trimmed), 0.6);
}
}
return tasks;
}
function splitSentences(text) {
// Split on sentence-ending punctuation, newlines, or common speech breaks
return text
.split(/(?<=[.!?])\s+|\n+/)
.flatMap((s) => s.split(/(?:,\s*(?:and\s+)?)?(?=(?:then|also|plus)\s)/i))
.filter((s) => s.trim().length > 0);
}
function extractAfterMarker(sentence, marker) {
const lower = sentence.toLowerCase();
const idx = lower.indexOf(marker);
if (idx < 0) return null;
return sentence.slice(idx + marker.length).trim().replace(/^[:\-–—]\s*/, "");
}
function extractTaskFromPhrase(sentence, phrase, idx) {
// Extract "need to X" → "X"
const afterPhrase = sentence.slice(idx + phrase.length).trim();
if (afterPhrase) return afterPhrase;
return null;
}
function capitalise(text) {
if (!text) return text;
// Clean up leading punctuation/whitespace
const cleaned = text.replace(/^[,;:\-–—\s]+/, "").trim();
if (!cleaned) return text;
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
}
function addTask(tasks, seen, text, confidence) {
// Remove trailing punctuation for dedup
const key = text.toLowerCase().replace(/[.!?,;:]+$/, "").trim();
if (seen.has(key)) return;
seen.add(key);
tasks.push({ text: text.replace(/[.!?]+$/, "").trim(), confidence });
}

View File

@@ -0,0 +1,224 @@
/**
* Rule-based task extraction from transcripts.
* Identifies action items from natural speech patterns.
*/
// Action verbs that typically start task sentences
const ACTION_VERBS = [
"call", "email", "send", "write", "build", "fix", "update", "check",
"review", "schedule", "book", "create", "set up", "follow up", "organise",
"prepare", "draft", "submit", "cancel", "confirm", "arrange", "order",
"buy", "get", "find", "look into", "research", "test", "deploy", "push",
"move", "add", "remove", "delete", "install", "configure", "contact",
"message", "tell", "ask", "invite", "remind", "finish", "complete",
"start", "begin", "plan", "design", "implement", "refactor",
];
// Phrases that signal a task
const TASK_PHRASES = [
"need to", "needs to", "should", "must", "have to", "has to",
"want to", "going to", "gonna", "got to", "gotta",
"don't forget to", "remember to", "make sure to", "make sure we",
"let's", "we should", "i should", "we need to", "i need to",
"we have to", "i have to", "we must", "i must",
];
// Explicit markers (user intentionally marks a task)
const EXPLICIT_MARKERS = [
"action:", "action item:", "todo:", "task:", "to do:",
"action item", "follow up:", "follow-up:",
];
const SORTED_TASK_PHRASES = [...TASK_PHRASES].sort((a, b) => b.length - a.length);
const LISTABLE_VERBS = ["get", "buy", "pick up", "order"];
const LISTABLE_VERB_PATTERN = new RegExp(`^(${LISTABLE_VERBS.join("|")})\\s+(.+)$`, "i");
/**
* Extract tasks from a transcript string.
* Returns an array of { text, confidence } objects.
*/
export function extractTasks(transcript: string): ExtractedTask[] {
if (!transcript || !transcript.trim()) return [];
const tasks: ExtractedTask[] = [];
const seen = new Set<string>();
// Split into sentences (rough but effective for speech)
const sentences = splitSentences(transcript);
for (const sentence of sentences) {
const trimmed = sentence.trim();
if (trimmed.length < 5) continue;
const lower = trimmed.toLowerCase();
let matched = false;
// Check explicit markers (highest confidence)
for (const marker of EXPLICIT_MARKERS) {
if (lower.startsWith(marker) || lower.includes(marker)) {
const taskText = extractAfterMarker(trimmed, marker);
if (taskText && taskText.length > 3) {
addTask(tasks, seen, capitalise(taskText), 0.95);
matched = true;
break;
}
}
}
if (matched) continue;
// Check task phrases (high confidence)
const phraseMatches = findPhraseMatches(lower);
if (phraseMatches.length > 0) {
for (let i = 0; i < phraseMatches.length; i += 1) {
const match = phraseMatches[i];
const nextMatch = phraseMatches[i + 1] ?? null;
const taskCandidates = extractTaskCandidatesFromPhrase(trimmed, match, nextMatch);
for (const candidate of taskCandidates) {
if (candidate.length > 3) {
addTask(tasks, seen, capitalise(candidate), 0.8);
matched = true;
}
}
}
}
if (matched) continue;
// Check if sentence starts with an action verb (medium confidence)
const firstWord = lower.split(/\s+/)[0];
const firstTwo = lower.split(/\s+/).slice(0, 2).join(" ");
if (ACTION_VERBS.includes(firstWord) || ACTION_VERBS.includes(firstTwo)) {
addTask(tasks, seen, capitalise(trimmed), 0.6);
}
}
return tasks;
}
function splitSentences(text: string): string[] {
// Split on sentence-ending punctuation, newlines, or common speech breaks
return text
.split(/(?<=[.!?])\s+|\n+/)
.flatMap((s) => s.split(/(?:,\s*(?:and\s+)?)?(?=(?:then|also|plus)\s)/i))
.filter((s) => s.trim().length > 0);
}
function extractAfterMarker(sentence: string, marker: string): string | null {
const lower = sentence.toLowerCase();
const idx = lower.indexOf(marker);
if (idx < 0) return null;
const extracted = sentence.slice(idx + marker.length).trim().replace(/^[:\-–—]\s*/, "");
return cleanTaskText(extracted);
}
function findPhraseMatches(lower: string): PhraseMatch[] {
const matches: PhraseMatch[] = [];
for (let i = 0; i < lower.length; i += 1) {
for (const phrase of SORTED_TASK_PHRASES) {
if (!lower.startsWith(phrase, i)) continue;
if (!hasPhraseBoundary(lower, i, phrase.length)) continue;
matches.push({ index: i, phrase });
i += phrase.length - 1;
break;
}
}
return matches;
}
function hasPhraseBoundary(text: string, index: number, length: number): boolean {
const before = index === 0 ? "" : text[index - 1];
const after = index + length >= text.length ? "" : text[index + length];
return isBoundaryChar(before) && isBoundaryChar(after);
}
function isBoundaryChar(char: string): boolean {
return !char || /[^a-z0-9]/i.test(char);
}
function extractTaskCandidatesFromPhrase(
sentence: string,
match: PhraseMatch,
nextMatch: PhraseMatch | null,
): string[] {
const end = nextMatch?.index ?? sentence.length;
const afterPhrase = sentence.slice(match.index + match.phrase.length, end).trim();
const cleaned = cleanTaskText(afterPhrase);
if (!cleaned) return [];
return expandListTask(cleaned);
}
function cleanTaskText(text: string): string {
return text
.replace(/^[,;:\-–—\s]+/, "")
.replace(/\s+/g, " ")
.replace(/[.!?]+$/, "")
.trim();
}
function expandListTask(taskText: string): string[] {
const match = taskText.match(LISTABLE_VERB_PATTERN);
if (!match) return [taskText];
const [, verb, remainder] = match;
if (!/(?:,|&|\band\b)/i.test(remainder)) {
return [taskText];
}
const items = remainder
.split(/\s*(?:,|&|\band\b)\s*/i)
.map((item) => cleanTaskText(item))
.filter(Boolean);
if (
items.length < 2
|| items.some((item) => item.split(/\s+/).length > 4)
|| items.some((item) => containsActionLanguage(item))
) {
return [taskText];
}
return items.map((item) => `${verb} ${item}`);
}
function containsActionLanguage(text: string): boolean {
const lower = text.toLowerCase();
const words = lower.split(/\s+/);
const firstWord = words[0] ?? "";
const firstTwo = words.slice(0, 2).join(" ");
return (
ACTION_VERBS.includes(firstWord)
|| ACTION_VERBS.includes(firstTwo)
|| TASK_PHRASES.some((phrase) => lower.includes(phrase))
);
}
function capitalise(text: string): string {
if (!text) return text;
// Clean up leading punctuation/whitespace
const cleaned = text.replace(/^[,;:\-–—\s]+/, "").trim();
if (!cleaned) return text;
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
}
function addTask(
tasks: ExtractedTask[],
seen: Set<string>,
text: string,
confidence: number,
) {
// Remove trailing punctuation for dedup
const key = text.toLowerCase().replace(/[.!?,;:]+$/, "").trim();
if (seen.has(key)) return;
seen.add(key);
tasks.push({ text: text.replace(/[.!?]+$/, "").trim(), confidence });
}
interface ExtractedTask {
text: string;
confidence: number;
}
interface PhraseMatch {
index: number;
phrase: string;
}

View File

@@ -3,7 +3,19 @@ import {
layout, layout,
prepareWithSegments, prepareWithSegments,
layoutWithLines, layoutWithLines,
} from '@chenglou/pretext'; } from "@chenglou/pretext";
type LayoutOptions = Record<string, unknown>;
type PreparedFactory = (
text: string,
font: string,
options?: LayoutOptions,
) => unknown;
type ClampResult = ReturnType<typeof layoutWithLines> & {
visibleLineCount: number;
text: string;
didTruncate: boolean;
};
// Cache prepared text to avoid re-measuring. // Cache prepared text to avoid re-measuring.
// prepare() is expensive, layout() is the cheap hot path. // prepare() is expensive, layout() is the cheap hot path.
@@ -11,34 +23,44 @@ const prepareCache = new Map();
const segmentedPrepareCache = new Map(); const segmentedPrepareCache = new Map();
const MAX_CACHE_ENTRIES = 400; const MAX_CACHE_ENTRIES = 400;
function touch(cache, key, value) { function touch<T>(cache: Map<string, T>, key: string, value: T): T {
if (cache.has(key)) cache.delete(key); if (cache.has(key)) cache.delete(key);
cache.set(key, value); cache.set(key, value);
if (cache.size > MAX_CACHE_ENTRIES) { if (cache.size > MAX_CACHE_ENTRIES) {
const oldestKey = cache.keys().next().value; const oldestKey = cache.keys().next().value;
cache.delete(oldestKey); if (typeof oldestKey === "string") cache.delete(oldestKey);
} }
return value; return value;
} }
function cacheKey(text, font, options = {}) { function cacheKey(text: string, font: string, options: LayoutOptions = {}): string {
return `${text}\0${font}\0${JSON.stringify(options)}`; return `${text}\0${font}\0${JSON.stringify(options)}`;
} }
function getPrepared(cache, factory, text, font, options = {}) { function getPrepared(
cache: Map<string, unknown>,
factory: PreparedFactory,
text: string,
font: string,
options: LayoutOptions = {},
): unknown {
const key = cacheKey(text, font, options); const key = cacheKey(text, font, options);
if (cache.has(key)) { if (cache.has(key)) {
const cached = cache.get(key); const cached = cache.get(key);
return touch(cache, key, cached); if (cached !== undefined) return touch(cache, key, cached);
} }
return touch(cache, key, factory(text, font, options)); return touch(cache, key, factory(text, font, options));
} }
export function prepareText(text, font, options = {}) { export function prepareText(text: string, font: string, options: LayoutOptions = {}): unknown {
return getPrepared(prepareCache, prepare, text, font, options); return getPrepared(prepareCache, prepare, text, font, options);
} }
export function prepareTextWithSegments(text, font, options = {}) { export function prepareTextWithSegments(
text: string,
font: string,
options: LayoutOptions = {},
): unknown {
return getPrepared( return getPrepared(
segmentedPrepareCache, segmentedPrepareCache,
prepareWithSegments, prepareWithSegments,
@@ -59,7 +81,13 @@ export function prepareTextWithSegments(text, font, options = {}) {
* @param {object} [options] - Additional options (e.g. { whiteSpace: 'pre-wrap' }) * @param {object} [options] - Additional options (e.g. { whiteSpace: 'pre-wrap' })
* @returns {{ height: number, lineCount: number }} * @returns {{ height: number, lineCount: number }}
*/ */
export function measureTextHeight(text, font, maxWidth, lineHeight, options = {}) { export function measureTextHeight(
text: string,
font: string,
maxWidth: number,
lineHeight: number,
options: LayoutOptions = {},
) {
const prepared = prepareText(text, font, options); const prepared = prepareText(text, font, options);
return layout(prepared, maxWidth, lineHeight); return layout(prepared, maxWidth, lineHeight);
} }
@@ -68,14 +96,25 @@ export function measureTextHeight(text, font, maxWidth, lineHeight, options = {}
* Measure height using pre-wrap mode (textarea-like behaviour). * Measure height using pre-wrap mode (textarea-like behaviour).
* Spaces, tabs, and hard breaks are preserved. * Spaces, tabs, and hard breaks are preserved.
*/ */
export function measurePreWrap(text, font, maxWidth, lineHeight) { export function measurePreWrap(
return measureTextHeight(text, font, maxWidth, lineHeight, { whiteSpace: 'pre-wrap' }); text: string,
font: string,
maxWidth: number,
lineHeight: number,
) {
return measureTextHeight(text, font, maxWidth, lineHeight, { whiteSpace: "pre-wrap" });
} }
/** /**
* Return full line layout information for custom rendering or clipping. * Return full line layout information for custom rendering or clipping.
*/ */
export function layoutTextLines(text, font, maxWidth, lineHeight, options = {}) { export function layoutTextLines(
text: string,
font: string,
maxWidth: number,
lineHeight: number,
options: LayoutOptions = {},
) {
const prepared = prepareTextWithSegments(text, font, options); const prepared = prepareTextWithSegments(text, font, options);
return layoutWithLines(prepared, maxWidth, lineHeight); return layoutWithLines(prepared, maxWidth, lineHeight);
} }
@@ -84,13 +123,13 @@ export function layoutTextLines(text, font, maxWidth, lineHeight, options = {})
* Clamp text to a maximum number of laid-out lines without DOM reads. * Clamp text to a maximum number of laid-out lines without DOM reads.
*/ */
export function clampTextLines( export function clampTextLines(
text, text: string,
font, font: string,
maxWidth, maxWidth: number,
lineHeight, lineHeight: number,
maxLines, maxLines: number,
options = {}, options: LayoutOptions = {},
) { ): ClampResult {
const result = layoutTextLines(text, font, maxWidth, lineHeight, options); const result = layoutTextLines(text, font, maxWidth, lineHeight, options);
const visibleLines = result.lines.slice(0, maxLines); const visibleLines = result.lines.slice(0, maxLines);
const didTruncate = result.lineCount > maxLines; const didTruncate = result.lineCount > maxLines;

View File

@@ -1,10 +1,10 @@
/** Pad number to 2 digits */ /** Pad number to 2 digits */
export function pad(n) { export function pad(n: number): string {
return n.toString().padStart(2, "0"); return n.toString().padStart(2, "0");
} }
/** Format seconds as M:SS (e.g. 1:05) */ /** Format seconds as M:SS (e.g. 1:05) */
export function formatTime(seconds) { export function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return "0:00"; if (!seconds || isNaN(seconds)) return "0:00";
const m = Math.floor(seconds / 60); const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60).toString().padStart(2, "0"); const s = Math.floor(seconds % 60).toString().padStart(2, "0");
@@ -12,7 +12,7 @@ export function formatTime(seconds) {
} }
/** Format seconds as human duration (e.g. 2m 30s) */ /** Format seconds as human duration (e.g. 2m 30s) */
export function formatDuration(seconds) { export function formatDuration(seconds: number): string {
if (!seconds) return ""; if (!seconds) return "";
const m = Math.floor(seconds / 60); const m = Math.floor(seconds / 60);
const s = Math.round(seconds % 60); const s = Math.round(seconds % 60);
@@ -20,7 +20,7 @@ export function formatDuration(seconds) {
} }
/** Format ISO timestamp as D Mon HH:MM (e.g. 15 Mar 18:11) */ /** Format ISO timestamp as D Mon HH:MM (e.g. 15 Mar 18:11) */
export function formatTimestamp(iso) { export function formatTimestamp(iso: string | null | undefined): string {
if (!iso) return ""; if (!iso) return "";
const d = new Date(iso); const d = new Date(iso);
return d.toLocaleDateString("en-GB", { day: "numeric", month: "short" }) + return d.toLocaleDateString("en-GB", { day: "numeric", month: "short" }) +
@@ -28,7 +28,7 @@ export function formatTimestamp(iso) {
} }
/** Format seconds as SRT timestamp (HH:MM:SS,mmm) */ /** Format seconds as SRT timestamp (HH:MM:SS,mmm) */
export function formatTimeSRT(seconds) { export function formatTimeSRT(seconds: number): string {
const h = Math.floor(seconds / 3600); const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60); const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60); const s = Math.floor(seconds % 60);
@@ -37,7 +37,7 @@ export function formatTimeSRT(seconds) {
} }
/** Format seconds as VTT timestamp (HH:MM:SS.mmm) */ /** Format seconds as VTT timestamp (HH:MM:SS.mmm) */
export function formatTimeVTT(seconds) { export function formatTimeVTT(seconds: number): string {
const h = Math.floor(seconds / 3600); const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60); const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60); const s = Math.floor(seconds % 60);

View File

@@ -1,4 +1,4 @@
export function buildCumulativeOffsets(heights) { export function buildCumulativeOffsets(heights: number[]): number[] {
const offsets = new Array(heights.length + 1); const offsets = new Array(heights.length + 1);
offsets[0] = 0; offsets[0] = 0;
for (let i = 0; i < heights.length; i++) { for (let i = 0; i < heights.length; i++) {
@@ -8,10 +8,10 @@ export function buildCumulativeOffsets(heights) {
} }
export function findVisibleRange( export function findVisibleRange(
cumulativeOffsets, cumulativeOffsets: number[],
itemCount, itemCount: number,
scrollTop, scrollTop: number,
viewportHeight, viewportHeight: number,
buffer = 4, buffer = 4,
) { ) {
if (!cumulativeOffsets.length || itemCount === 0 || viewportHeight <= 0) { if (!cumulativeOffsets.length || itemCount === 0 || viewportHeight <= 0) {

View File

@@ -1,4 +1,5 @@
<script> <script lang="ts">
// @ts-nocheck
import "../app.css"; import "../app.css";
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";

View File

@@ -1,4 +1,5 @@
<script> <script lang="ts">
// @ts-nocheck
import "../../app.css"; import "../../app.css";
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";

View File

@@ -1,4 +1,6 @@
<script> <script lang="ts">
import { tick } from "svelte";
import type { TaskList } from "$lib/types/app";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { import {
tasks, addTask, completeTask, uncompleteTask, deleteTask, tasks, addTask, completeTask, uncompleteTask, deleteTask,
@@ -12,12 +14,14 @@
let showCompleted = $state(false); let showCompleted = $state(false);
let newListName = $state(""); let newListName = $state("");
let showNewList = $state(false); let showNewList = $state(false);
let contextMenuListId = $state(null); let contextMenuListId = $state<string | null>(null);
let editingListId = $state(null); let editingListId = $state<string | null>(null);
let editingName = $state(""); let editingName = $state("");
let showSortMenu = $state(false); let showSortMenu = $state(false);
let draggingTaskId = $state(null); let draggingTaskId = $state<string | null>(null);
let dropHighlightId = $state(null); let dropHighlightId = $state<string | null>(null);
let editingInputEl = $state<HTMLInputElement | null>(null);
let newListInputEl = $state<HTMLInputElement | null>(null);
// Filtered tasks for active list // Filtered tasks for active list
let tasksForActiveList = $derived.by(() => { let tasksForActiveList = $derived.by(() => {
@@ -34,7 +38,7 @@
return done.filter((t) => t.listId === activeListId).slice(0, 10); return done.filter((t) => t.listId === activeListId).slice(0, 10);
}); });
function countForList(listId) { function countForList(listId: string) {
if (listId === "all") return tasks.filter((t) => !t.done).length; if (listId === "all") return tasks.filter((t) => !t.done).length;
if (listId === "inbox") return tasks.filter((t) => !t.done && !t.listId).length; if (listId === "inbox") return tasks.filter((t) => !t.done && !t.listId).length;
return tasks.filter((t) => !t.done && t.listId === listId).length; return tasks.filter((t) => !t.done && t.listId === listId).length;
@@ -48,7 +52,7 @@
let builtInLists = $derived(taskLists.filter((l) => l.builtIn)); let builtInLists = $derived(taskLists.filter((l) => l.builtIn));
let customLists = $derived(taskLists.filter((l) => !l.builtIn)); let customLists = $derived(taskLists.filter((l) => !l.builtIn));
function handleQuickAdd(e) { function handleQuickAdd(e: KeyboardEvent) {
if (e.key === "Enter" && quickInput.trim()) { if (e.key === "Enter" && quickInput.trim()) {
const listId = (activeListId === "all" || activeListId === "inbox") ? null : activeListId; const listId = (activeListId === "all" || activeListId === "inbox") ? null : activeListId;
addTask({ text: quickInput.trim(), bucket: "inbox", listId }); addTask({ text: quickInput.trim(), bucket: "inbox", listId });
@@ -56,11 +60,11 @@
} }
} }
function handleDragStart(e) { function handleDragStart(e: PointerEvent) {
if (e.button !== 0) return; if (e.button !== 0) return;
if (e.target.closest("button")) return; if ((e.target as HTMLElement | null)?.closest("button")) return;
if (e.target.closest("input")) return; if ((e.target as HTMLElement | null)?.closest("input")) return;
try { e.currentTarget?.setPointerCapture?.(e.pointerId); } catch {} try { (e.currentTarget as HTMLElement | null)?.setPointerCapture?.(e.pointerId); } catch {}
getCurrentWindow().startDragging(); getCurrentWindow().startDragging();
} }
@@ -74,7 +78,7 @@
} }
// List management // List management
function handleCreateList(e) { function handleCreateList(e: KeyboardEvent) {
if (e.key === "Enter" && newListName.trim()) { if (e.key === "Enter" && newListName.trim()) {
addTaskList(newListName.trim()); addTaskList(newListName.trim());
newListName = ""; newListName = "";
@@ -86,10 +90,13 @@
} }
} }
function startRenaming(list) { async function startRenaming(list: TaskList) {
editingListId = list.id; editingListId = list.id;
editingName = list.name; editingName = list.name;
contextMenuListId = null; contextMenuListId = null;
await tick();
editingInputEl?.focus();
editingInputEl?.select();
} }
function finishRenaming() { function finishRenaming() {
@@ -100,28 +107,28 @@
editingName = ""; editingName = "";
} }
function handleRenameKey(e) { function handleRenameKey(e: KeyboardEvent) {
if (e.key === "Enter") finishRenaming(); if (e.key === "Enter") finishRenaming();
if (e.key === "Escape") { editingListId = null; editingName = ""; } if (e.key === "Escape") { editingListId = null; editingName = ""; }
} }
function handleDeleteList(id) { function handleDeleteList(id: string) {
if (activeListId === id) activeListId = "all"; if (activeListId === id) activeListId = "all";
deleteTaskList(id); deleteTaskList(id);
contextMenuListId = null; contextMenuListId = null;
} }
function toggleContextMenu(e, listId) { function toggleContextMenu(e: MouseEvent, listId: string) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
contextMenuListId = contextMenuListId === listId ? null : listId; contextMenuListId = contextMenuListId === listId ? null : listId;
} }
// Drag-and-drop: tasks between lists // Drag-and-drop: tasks between lists
function handleTaskDragStart(e, taskId) { function handleTaskDragStart(e: DragEvent, taskId: string) {
draggingTaskId = taskId; draggingTaskId = taskId;
e.dataTransfer.setData("text/plain", taskId); e.dataTransfer?.setData("text/plain", taskId);
e.dataTransfer.effectAllowed = "move"; if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
} }
function handleTaskDragEnd() { function handleTaskDragEnd() {
@@ -129,24 +136,24 @@
dropHighlightId = null; dropHighlightId = null;
} }
function handleListDragOver(e) { function handleListDragOver(e: DragEvent) {
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = "move"; if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
} }
function handleListDragEnter(e, listId) { function handleListDragEnter(e: DragEvent, listId: string) {
e.preventDefault(); e.preventDefault();
dropHighlightId = listId; dropHighlightId = listId;
} }
function handleListDragLeave(e, listId) { function handleListDragLeave(_e: DragEvent, listId: string) {
if (dropHighlightId === listId) dropHighlightId = null; if (dropHighlightId === listId) dropHighlightId = null;
} }
function handleListDrop(e, listId) { function handleListDrop(e: DragEvent, listId: string) {
e.preventDefault(); e.preventDefault();
dropHighlightId = null; dropHighlightId = null;
const taskId = e.dataTransfer.getData("text/plain"); const taskId = e.dataTransfer?.getData("text/plain");
if (taskId) { if (taskId) {
moveTaskToList(taskId, listId); moveTaskToList(taskId, listId);
} }
@@ -157,10 +164,20 @@
if (contextMenuListId) contextMenuListId = null; if (contextMenuListId) contextMenuListId = null;
if (showSortMenu) showSortMenu = false; if (showSortMenu) showSortMenu = false;
} }
$effect(() => {
if (showNewList) {
tick().then(() => {
newListInputEl?.focus();
newListInputEl?.select();
});
}
});
</script> </script>
<svelte:window onclick={handleWindowClick} /> <svelte:window onclick={handleWindowClick} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="flex flex-col h-full bg-bg"> <div class="flex flex-col h-full bg-bg">
<!-- Drag handle with title --> <!-- Drag handle with title -->
<div <div
@@ -239,6 +256,7 @@
{#each customLists as list (list.id)} {#each customLists as list (list.id)}
{#if editingListId === list.id} {#if editingListId === list.id}
<input <input
bind:this={editingInputEl}
type="text" type="text"
class="bg-bg-input border border-accent rounded px-2 py-0.5 text-[11px] text-text class="bg-bg-input border border-accent rounded px-2 py-0.5 text-[11px] text-text
focus:outline-none w-[100px]" focus:outline-none w-[100px]"
@@ -246,7 +264,6 @@
onkeydown={handleRenameKey} onkeydown={handleRenameKey}
onblur={finishRenaming} onblur={finishRenaming}
data-no-transition data-no-transition
autofocus
/> />
{:else} {:else}
<div class="relative flex-shrink-0"> <div class="relative flex-shrink-0">
@@ -280,7 +297,7 @@
<!-- Context menu --> <!-- Context menu -->
{#if contextMenuListId === list.id} {#if contextMenuListId === list.id}
<div class="absolute left-0 top-full mt-1 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-20 animate-fade-in min-w-[120px]" <div class="absolute left-0 top-full mt-1 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-20 animate-fade-in min-w-[120px]"
onclick={(e) => e.stopPropagation()}> onpointerdown={(e) => e.stopPropagation()}>
<button <button
class="w-full text-left px-3 py-1 text-[10px] text-text-secondary hover:bg-hover hover:text-text" class="w-full text-left px-3 py-1 text-[10px] text-text-secondary hover:bg-hover hover:text-text"
onclick={() => startRenaming(list)} onclick={() => startRenaming(list)}
@@ -322,6 +339,7 @@
<!-- New list button --> <!-- New list button -->
{#if showNewList} {#if showNewList}
<input <input
bind:this={newListInputEl}
type="text" type="text"
class="bg-bg-input border border-border rounded px-2 py-0.5 text-[10px] text-text class="bg-bg-input border border-border rounded px-2 py-0.5 text-[10px] text-text
placeholder:text-text-tertiary focus:outline-none focus:border-accent w-[100px] flex-shrink-0" placeholder:text-text-tertiary focus:outline-none focus:border-accent w-[100px] flex-shrink-0"
@@ -330,7 +348,6 @@
onkeydown={handleCreateList} onkeydown={handleCreateList}
onblur={() => { showNewList = false; newListName = ""; }} onblur={() => { showNewList = false; newListName = ""; }}
data-no-transition data-no-transition
autofocus
/> />
{:else} {:else}
<button <button
@@ -345,6 +362,7 @@
<button <button
class="px-1.5 py-1 rounded text-[10px] text-text-tertiary hover:text-text-secondary" class="px-1.5 py-1 rounded text-[10px] text-text-tertiary hover:text-text-secondary"
onclick={(e) => { e.stopPropagation(); showSortMenu = !showSortMenu; }} onclick={(e) => { e.stopPropagation(); showSortMenu = !showSortMenu; }}
aria-label="Sort task lists"
> >
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M3 12h12M3 18h6" stroke-linecap="round" /> <path d="M3 6h18M3 12h12M3 18h6" stroke-linecap="round" />
@@ -352,7 +370,7 @@
</button> </button>
{#if showSortMenu} {#if showSortMenu}
<div class="absolute right-0 top-full mt-1 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-20 animate-fade-in min-w-[110px]" <div class="absolute right-0 top-full mt-1 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-20 animate-fade-in min-w-[110px]"
onclick={(e) => e.stopPropagation()}> onpointerdown={(e) => e.stopPropagation()}>
<button <button
class="w-full text-left px-3 py-1 text-[10px] text-text-secondary hover:bg-hover hover:text-text" class="w-full text-left px-3 py-1 text-[10px] text-text-secondary hover:bg-hover hover:text-text"
onclick={() => { sortTaskLists("alpha"); showSortMenu = false; }} onclick={() => { sortTaskLists("alpha"); showSortMenu = false; }}

View File

@@ -1,4 +1,5 @@
<script> <script lang="ts">
// @ts-nocheck
import "../../app.css"; import "../../app.css";
import { onDestroy, onMount } from "svelte"; import { onDestroy, onMount } from "svelte";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";

View File

@@ -1,13 +1,16 @@
<script> <script lang="ts">
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy, tick } from "svelte";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { convertFileSrc, invoke } from "@tauri-apps/api/core"; import { convertFileSrc, invoke } from "@tauri-apps/api/core";
import type { TranscriptEntry, ViewerSegment } from "$lib/types/app";
import { formatTime, formatDuration } from "$lib/utils/time.js"; import { formatTime, formatDuration } from "$lib/utils/time.js";
import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js"; import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js";
import { errorMessage } from "$lib/utils/errors.js";
import { parseStoredJson } from "$lib/utils/storage.js";
import { saveTranscriptMeta } from "$lib/stores/page.svelte.js"; import { saveTranscriptMeta } from "$lib/stores/page.svelte.js";
let item = $state(null); let item = $state<TranscriptEntry | null>(null);
let audioEl = $state(null); let audioEl = $state<HTMLAudioElement | null>(null);
let playing = $state(false); let playing = $state(false);
let currentTime = $state(0); let currentTime = $state(0);
let duration = $state(0); let duration = $state(0);
@@ -18,41 +21,62 @@
let editingText = $state(""); let editingText = $state("");
let showTimestamps = $state(true); let showTimestamps = $state(true);
let showStarredOnly = $state(false); let showStarredOnly = $state(false);
let animFrameId = null; let animFrameId: number | null = null;
let segmentRefs = []; let segmentRefs = $state<Array<HTMLElement | null>>([]);
let viewerMode = $state("view"); // "view" | "edit" let viewerMode = $state<"view" | "edit">("view");
let textDraft = $state(""); let textDraft = $state("");
let textDirty = $state(false); let textDirty = $state(false);
let textSaveTimer = null; let textSaveTimer: ReturnType<typeof setTimeout> | null = null;
let editingTextareaEl = $state<HTMLTextAreaElement | null>(null);
function stopAudio() {
if (audioEl) audioEl.pause();
playing = false;
if (animFrameId !== null) cancelAnimationFrame(animFrameId);
animFrameId = null;
}
function buildAudio(audioPath: string | null) {
stopAudio();
audioEl = null;
duration = 0;
currentTime = 0;
if (!audioPath) return;
const src = convertFileSrc(audioPath);
const audio = new Audio(src);
audio.playbackRate = playbackRate;
audio.onloadedmetadata = () => { duration = audio.duration; };
audio.onended = () => {
playing = false;
if (animFrameId !== null) cancelAnimationFrame(animFrameId);
animFrameId = null;
};
audio.onerror = () => { playing = false; };
audioEl = audio;
}
function loadViewerItem(nextItem: TranscriptEntry | null) {
item = nextItem;
textDraft = nextItem?.text ?? "";
activeSegmentIdx = -1;
editingIdx = -1;
editingText = "";
segmentRefs.length = 0;
buildAudio(nextItem?.audioPath ?? null);
}
// Load item data from localStorage (set by HistoryPage before opening this window)
onMount(() => { onMount(() => {
try { loadViewerItem(parseStoredJson<TranscriptEntry>(localStorage.getItem("kon_viewer_item")));
const raw = localStorage.getItem("kon_viewer_item"); const mode = localStorage.getItem("kon_viewer_mode");
if (raw) { if (mode === "edit" || mode === "view") viewerMode = mode;
item = JSON.parse(raw);
textDraft = item?.text || "";
if (item.audioPath) {
const src = convertFileSrc(item.audioPath);
const audio = new Audio(src);
audio.onloadedmetadata = () => { duration = audio.duration; };
audio.onended = () => { playing = false; cancelAnimationFrame(animFrameId); };
audio.onerror = () => { playing = false; };
audioEl = audio;
}
}
const mode = localStorage.getItem("kon_viewer_mode");
if (mode === "edit" || mode === "view") viewerMode = mode;
} catch {}
// Listen for new items via storage events // Listen for new items via storage events
window.addEventListener("storage", handleStorageChange); window.addEventListener("storage", handleStorageChange);
}); });
onDestroy(() => { onDestroy(() => {
if (audioEl) audioEl.pause(); stopAudio();
cancelAnimationFrame(animFrameId);
window.removeEventListener("storage", handleStorageChange); window.removeEventListener("storage", handleStorageChange);
// Flush any pending text edit before the window tears down. // Flush any pending text edit before the window tears down.
if (textSaveTimer) { if (textSaveTimer) {
@@ -62,23 +86,9 @@
if (textDirty) commitTextEdit(); if (textDirty) commitTextEdit();
}); });
function handleStorageChange(e) { function handleStorageChange(e: StorageEvent) {
if (e.key === "kon_viewer_item" && e.newValue) { if (e.key === "kon_viewer_item" && e.newValue) {
try { loadViewerItem(parseStoredJson<TranscriptEntry>(e.newValue));
if (audioEl) { audioEl.pause(); }
cancelAnimationFrame(animFrameId);
playing = false;
currentTime = 0;
item = JSON.parse(e.newValue);
if (item.audioPath) {
const src = convertFileSrc(item.audioPath);
const audio = new Audio(src);
audio.onloadedmetadata = () => { duration = audio.duration; };
audio.onended = () => { playing = false; cancelAnimationFrame(animFrameId); };
audioEl = audio;
}
} catch {}
} }
} }
@@ -87,7 +97,7 @@
if (playing) { if (playing) {
audioEl.pause(); audioEl.pause();
playing = false; playing = false;
cancelAnimationFrame(animFrameId); if (animFrameId !== null) cancelAnimationFrame(animFrameId);
} else { } else {
audioEl.play(); audioEl.play();
playing = true; playing = true;
@@ -114,17 +124,15 @@
if (activeSegmentIdx !== i) { if (activeSegmentIdx !== i) {
activeSegmentIdx = i; activeSegmentIdx = i;
// Auto-scroll to active segment // Auto-scroll to active segment
if (segmentRefs[i]) { segmentRefs[i]?.scrollIntoView({ behavior: "smooth", block: "center" });
segmentRefs[i].scrollIntoView({ behavior: "smooth", block: "center" });
}
} }
return; return;
} }
} }
} }
function seekTo(e) { function seekTo(e: Event) {
const val = parseFloat(e.target.value); const val = parseFloat((e.currentTarget as HTMLInputElement).value);
if (audioEl) { if (audioEl) {
audioEl.currentTime = val; audioEl.currentTime = val;
currentTime = val; currentTime = val;
@@ -132,7 +140,7 @@
} }
} }
function seekToTime(time) { function seekToTime(time: number) {
if (audioEl) { if (audioEl) {
audioEl.currentTime = time; audioEl.currentTime = time;
currentTime = time; currentTime = time;
@@ -145,7 +153,7 @@
} }
} }
function setSpeed(speed) { function setSpeed(speed: number) {
playbackRate = speed; playbackRate = speed;
if (audioEl) audioEl.playbackRate = speed; if (audioEl) audioEl.playbackRate = speed;
} }
@@ -153,9 +161,9 @@
// Search: find matching segments // Search: find matching segments
let matchingSegments = $derived.by(() => { let matchingSegments = $derived.by(() => {
if (!searchQuery.trim() || !item?.segments) return new Set(); if (!searchQuery.trim() || !item?.segments) return new Set<number>();
const q = searchQuery.toLowerCase(); const q = searchQuery.toLowerCase();
const matches = new Set(); const matches = new Set<number>();
item.segments.forEach((seg, i) => { item.segments.forEach((seg, i) => {
if (seg.text.toLowerCase().includes(q)) matches.add(i); if (seg.text.toLowerCase().includes(q)) matches.add(i);
}); });
@@ -165,7 +173,7 @@
let matchCount = $derived.by(() => matchingSegments.size); let matchCount = $derived.by(() => matchingSegments.size);
// Highlight search matches in text (XSS-safe: escapes HTML entities first) // Highlight search matches in text (XSS-safe: escapes HTML entities first)
function highlightText(text) { function highlightText(text: string) {
if (!searchQuery.trim()) return text; if (!searchQuery.trim()) return text;
const escaped = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); const escaped = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
const q = searchQuery.trim(); const q = searchQuery.trim();
@@ -174,9 +182,13 @@
} }
// Segment editing // Segment editing
function startEditing(idx) { async function startEditing(idx: number) {
if (!item?.segments?.[idx]) return;
editingIdx = idx; editingIdx = idx;
editingText = item.segments[idx].text; editingText = item.segments[idx].text;
await tick();
editingTextareaEl?.focus();
editingTextareaEl?.select();
} }
function finishEditing() { function finishEditing() {
@@ -194,7 +206,7 @@
editingText = ""; editingText = "";
} }
function deleteSegment(idx) { function deleteSegment(idx: number) {
if (item?.segments) { if (item?.segments) {
item.segments.splice(idx, 1); item.segments.splice(idx, 1);
item.text = item.segments.map((s) => s.text).join(" ").trim(); item.text = item.segments.map((s) => s.text).join(" ").trim();
@@ -205,7 +217,7 @@
} }
} }
function toggleStar(idx) { function toggleStar(idx: number) {
if (item?.segments) { if (item?.segments) {
item.segments[idx].starred = !item.segments[idx].starred; item.segments[idx].starred = !item.segments[idx].starred;
// Task 2.5 — persist the updated segments array (incl. starred flags) // Task 2.5 — persist the updated segments array (incl. starred flags)
@@ -223,12 +235,10 @@
// Task 2.5, tracked by the file's 80-odd pre-existing TS errors. // Task 2.5, tracked by the file's 80-odd pre-existing TS errors.
function persistSegments() { function persistSegments() {
if (!item) return; if (!item) return;
/** @type {any} */ saveTranscriptMeta(item.id, { segments: item.segments });
const snap = item;
saveTranscriptMeta(snap.id, { segments: snap.segments });
} }
function copySegment(idx) { function copySegment(idx: number) {
if (item?.segments?.[idx]) { if (item?.segments?.[idx]) {
const text = item.segments[idx].text.trim(); const text = item.segments[idx].text.trim();
navigator.clipboard.writeText(text).catch(() => { navigator.clipboard.writeText(text).catch(() => {
@@ -259,13 +269,13 @@
text: item.text ?? null, text: item.text ?? null,
title: item.title ?? null, title: item.title ?? null,
}).catch((err) => { }).catch((err) => {
console.warn("viewer saveItemToHistory: update_transcript failed", err); console.warn("viewer saveItemToHistory: update_transcript failed", errorMessage(err));
}); });
} }
function scheduleTextSave() { function scheduleTextSave() {
textDirty = true; textDirty = true;
clearTimeout(textSaveTimer); if (textSaveTimer) clearTimeout(textSaveTimer);
textSaveTimer = setTimeout(() => { textSaveTimer = setTimeout(() => {
commitTextEdit(); commitTextEdit();
}, 400); }, 400);
@@ -288,8 +298,8 @@
// Filtered segments (starred filter + search) // Filtered segments (starred filter + search)
let visibleSegments = $derived.by(() => { let visibleSegments = $derived.by(() => {
if (!item?.segments) return []; if (!item?.segments) return [] as ViewerSegment[];
let segs = item.segments.map((seg, i) => ({ ...seg, _idx: i })); let segs: ViewerSegment[] = item.segments.map((seg, i) => ({ ...seg, _idx: i }));
if (showStarredOnly) { if (showStarredOnly) {
segs = segs.filter((s) => s.starred); segs = segs.filter((s) => s.starred);
} }
@@ -298,11 +308,11 @@
// Window drag — pointerdown + setPointerCapture avoids the mousedown // Window drag — pointerdown + setPointerCapture avoids the mousedown
// latency that makes KWin's initial grab feel draggy. // latency that makes KWin's initial grab feel draggy.
function handleDragStart(e) { function handleDragStart(e: PointerEvent) {
if (e.button !== 0) return; if (e.button !== 0) return;
if (e.target.closest("button")) return; if ((e.target as HTMLElement | null)?.closest("button")) return;
if (e.target.closest("input")) return; if ((e.target as HTMLElement | null)?.closest("input")) return;
try { e.currentTarget?.setPointerCapture?.(e.pointerId); } catch {} try { (e.currentTarget as HTMLElement | null)?.setPointerCapture?.(e.pointerId); } catch {}
getCurrentWindow().startDragging(); getCurrentWindow().startDragging();
} }
@@ -310,6 +320,7 @@
<div class="flex flex-col h-full bg-bg"> <div class="flex flex-col h-full bg-bg">
<!-- Drag handle --> <!-- Drag handle -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
class="flex items-center h-[36px] bg-bg-elevated select-none px-3" class="flex items-center h-[36px] bg-bg-elevated select-none px-3"
onpointerdown={handleDragStart} onpointerdown={handleDragStart}
@@ -469,6 +480,7 @@
<!-- Text (editable or display) --> <!-- Text (editable or display) -->
{#if editingIdx === seg._idx} {#if editingIdx === seg._idx}
<textarea <textarea
bind:this={editingTextareaEl}
class="flex-1 bg-bg-input border border-accent rounded-lg px-2 py-1 text-[13px] text-text class="flex-1 bg-bg-input border border-accent rounded-lg px-2 py-1 text-[13px] text-text
leading-relaxed resize-none focus:outline-none min-h-[40px]" leading-relaxed resize-none focus:outline-none min-h-[40px]"
bind:value={editingText} bind:value={editingText}
@@ -476,7 +488,6 @@
onblur={finishEditing} onblur={finishEditing}
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
data-no-transition data-no-transition
autofocus
></textarea> ></textarea>
{:else} {:else}
<p class="text-[13px] text-text leading-relaxed flex-1"> <p class="text-[13px] text-text leading-relaxed flex-1">