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

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 { Mic, FileText, SquareCheck, Clock, Settings, ChevronRight, ChevronLeft } from 'lucide-svelte';
@@ -19,7 +19,7 @@
saveSettings();
}
function navigate(id) {
function navigate(id: string) {
page.current = id;
if (id !== "dictation") {
page.taskSidebarOpen = false;

View File

@@ -1,12 +1,15 @@
// src/lib/actions/bionicReading.js
function applyBionic(node) {
function applyBionic(node: HTMLElement) {
const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);
const textNodes = [];
let current;
while ((current = walker.nextNode())) textNodes.push(current);
const textNodes: Text[] = [];
let current: Node | null;
while ((current = walker.nextNode())) {
if (current.nodeType === Node.TEXT_NODE) textNodes.push(current as Text);
}
for (const textNode of textNodes) {
if (textNode.textContent == null) continue;
const words = textNode.textContent.split(/(\s+)/);
if (words.length <= 1 && !words[0].trim()) continue;
@@ -22,21 +25,21 @@ function applyBionic(node) {
fragment.appendChild(b);
fragment.appendChild(document.createTextNode(word.slice(boldLen)));
}
textNode.parentNode.replaceChild(fragment, textNode);
textNode.parentNode?.replaceChild(fragment, textNode);
}
}
function stripBionic(node) {
const bolds = node.querySelectorAll('b');
function stripBionic(node: HTMLElement) {
const bolds = node.querySelectorAll("b");
for (const b of bolds) {
const text = document.createTextNode(b.textContent);
b.parentNode.replaceChild(text, b);
const text = document.createTextNode(b.textContent ?? "");
b.parentNode?.replaceChild(text, b);
}
node.normalize(); // merge adjacent text nodes
}
export function bionicReading(node, enabled) {
let observer = null;
export function bionicReading(node: HTMLElement, enabled: boolean) {
let observer: MutationObserver | null = null;
function refresh() {
// Disconnect observer while we mutate, to avoid infinite loop
@@ -47,6 +50,7 @@ export function bionicReading(node, enabled) {
}
function observe() {
if (!observer) return;
observer.observe(node, { childList: true, characterData: true, subtree: true });
}
@@ -58,7 +62,7 @@ export function bionicReading(node, enabled) {
observe();
return {
update(newEnabled) {
update(newEnabled: boolean) {
enabled = newEnabled;
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 Toggle from '$lib/components/Toggle.svelte';
import SegmentedButton from '$lib/components/SegmentedButton.svelte';
@@ -9,7 +10,7 @@
{ id: 'lexend', label: 'Lexend' },
{ id: 'atkinson', label: 'Atkinson' },
{ id: 'opendyslexic', label: 'OpenDyslexic' },
];
] satisfies { id: FontFamily; label: string }[];
let bionicChecked = $state(prefs.accessibility.bionicReading || false);
let motionValue = $state(
@@ -25,11 +26,15 @@
});
$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) {
updateAccessibility({ reduceMotion: mapped });
}
});
function rangeValue(event: Event): number {
return Number((event.currentTarget as HTMLInputElement).value);
}
</script>
<div class="flex flex-col gap-5">
@@ -59,7 +64,7 @@
<span class="text-[11px] text-text-tertiary">{prefs.accessibility.fontSize}px</span>
</div>
<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" />
</div>
@@ -70,7 +75,7 @@
<span class="text-[11px] text-text-tertiary">{prefs.accessibility.letterSpacing.toFixed(2)}em</span>
</div>
<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" />
</div>
@@ -81,7 +86,7 @@
<span class="text-[11px] text-text-tertiary">{prefs.accessibility.lineHeight.toFixed(1)}</span>
</div>
<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" />
</div>
@@ -92,7 +97,7 @@
<span class="text-[11px] text-text-tertiary">{prefs.accessibility.transcriptSize}px</span>
</div>
<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" />
</div>

View File

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

View File

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

View File

@@ -1,31 +1,38 @@
<script>
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { errorMessage } from "$lib/utils/errors.js";
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 downloading = $state(false);
let downloaded = $state(0);
let total = $state(0);
let error = $state("");
let unlisten = null;
let unlisten: null | (() => void) = null;
const modelInfo = {
tiny: { size: "~75 MB", accuracy: "Basic" },
base: { size: "~142 MB", accuracy: "Good" },
small: { size: "~466 MB", accuracy: "Better" },
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 () => {
unlisten = await listen("model-download-progress", (event) => {
const data = event.payload;
progress = data.progress;
downloaded = data.downloaded;
total = data.total;
const data = event.payload as { progress?: number; downloaded?: number; total?: number };
progress = data.progress ?? 0;
downloaded = data.downloaded ?? 0;
total = data.total ?? 0;
});
});
@@ -41,12 +48,12 @@
await invoke("download_model", { size: modelSize });
onComplete();
} catch (err) {
error = typeof err === "string" ? err : err.message || "Download failed";
error = errorMessage(err) || "Download failed";
downloading = false;
}
}
function formatBytes(bytes) {
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
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.
</p>
<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>
{#if downloading}

View File

@@ -1,4 +1,4 @@
<script>
<script lang="ts">
// Invisible resize-handle overlays around each Kon window edge.
// Needed because Kon runs with decorations off, so the WM
// provides no resize affordance on KDE/GNOME Wayland.
@@ -24,7 +24,10 @@
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 (e.button !== 0) return;
e.preventDefault();
@@ -35,7 +38,7 @@
// diagonal component and collapsing a corner drag to a single axis.
// pointerdown + setPointerCapture keeps the direction pinned.
try {
e.currentTarget?.setPointerCapture?.(e.pointerId);
(e.currentTarget as HTMLElement | null)?.setPointerCapture?.(e.pointerId);
} catch {}
try {
await getCurrentWindow().startResizeDragging(direction);
@@ -44,13 +47,21 @@
</script>
{#if enabled}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<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>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<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>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<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>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<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>
{/if}

View File

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

View File

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

View File

@@ -1,7 +1,11 @@
<script>
import { onMount, onDestroy } from "svelte";
<script lang="ts">
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 = {
dots: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
@@ -12,17 +16,14 @@
};
let frame = $state(0);
let interval = null;
let chars = sequences[type] || sequences.dots;
let chars = $derived(sequences[type as SpinnerType] ?? sequences.dots);
onMount(() => {
interval = setInterval(() => {
$effect(() => {
frame = 0;
const interval = setInterval(() => {
frame = (frame + 1) % chars.length;
}, speed);
});
onDestroy(() => {
if (interval) clearInterval(interval);
return () => clearInterval(interval);
});
</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 { buildCumulativeOffsets, findVisibleRange } from "$lib/utils/virtualList.js";
import { getPreferences } from "$lib/stores/preferences.svelte.js";
@@ -8,23 +9,39 @@
let {
segments = [],
activeSegmentIdx = -1,
matchingSegments = new Set(),
matchingSegments = new Set<number>(),
showTimestamps = true,
editingIdx = -1,
editingText = $bindable(""),
searchQuery = "",
formatTime = (t) => String(t),
onSeekToTime = () => {},
onStartEditing = () => {},
formatTime = (t: number) => String(t),
onSeekToTime = (_time: number) => {},
onStartEditing = (_idx: number) => {},
onFinishEditing = () => {},
onToggleStar = () => {},
onCopySegment = () => {},
onDeleteSegment = () => {},
highlightText = (t) => t,
} = $props();
onToggleStar = (_idx: number) => {},
onCopySegment = (_idx: number) => {},
onDeleteSegment = (_idx: number) => {},
highlightText = (t: string) => t,
} = $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
let containerEl = $state(null);
let containerEl = $state<HTMLDivElement | null>(null);
let containerHeight = $state(0);
let containerWidth = $state(0);
let scrollTop = $state(0);
@@ -46,7 +63,7 @@
const textWidth = containerWidth - 26 - timestampWidth - 24;
if (textWidth <= 0) return segments.map(() => 40);
return segments.map(seg => {
return segments.map((seg: ViewerSegment) => {
const text = seg.text?.trim() || "";
if (!text) return SEGMENT_PADDING + textLineHeight;
const { height } = measureTextHeight(text, textFont, textWidth, textLineHeight);
@@ -75,7 +92,7 @@
// Visible segment slice with positioning
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++) {
items.push({
seg: segments[i],
@@ -88,7 +105,7 @@
});
// 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;
const segTop = cumulativeOffsets[idx];
const segHeight = segmentHeights[idx];
@@ -96,8 +113,8 @@
containerEl.scrollTop = segTop - viewCenter + segHeight / 2;
}
function onScroll(e) {
scrollTop = e.target.scrollTop;
function onScroll(e: Event) {
scrollTop = (e.currentTarget as HTMLDivElement).scrollTop;
// Track selection state for expanded buffer
hasSelection = !document.getSelection()?.isCollapsed;
}
@@ -105,7 +122,7 @@
// ResizeObserver for container dimensions
$effect(() => {
if (!containerEl) return;
const ro = new ResizeObserver(entries => {
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
containerHeight = entry.contentRect.height;
containerWidth = entry.contentRect.width;
@@ -119,7 +136,7 @@
$effect(() => {
if (activeSegmentIdx >= 0) {
// 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) {
const segTop = cumulativeOffsets[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 MicroSteps from '$lib/components/MicroSteps.svelte';
import { ChevronDown, ChevronRight } from 'lucide-svelte';
@@ -7,7 +7,7 @@
let newTaskText = $state('');
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
let activeTasks = $derived(tasks.filter(t => !t.done && !t.parentTaskId));
@@ -22,11 +22,11 @@
newTaskText = '';
}
function handleKeydown(e) {
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') handleAdd();
}
function toggleExpand(taskId) {
function toggleExpand(taskId: string) {
const next = new Set(expandedTaskIds);
if (next.has(taskId)) {
next.delete(taskId);

View File

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

View File

@@ -1,4 +1,5 @@
<script>
<script lang="ts">
// @ts-nocheck
import { onMount, onDestroy } from "svelte";
import { Channel, invoke } from "@tauri-apps/api/core";
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 { invoke } from "@tauri-apps/api/core";
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 { listen } from "@tauri-apps/api/event";
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 { invoke } from "@tauri-apps/api/core";
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 { invoke } from "@tauri-apps/api/core";
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 {
tasks, addTask, completeTask, uncompleteTask, deleteTask, updateTask,
@@ -21,17 +23,29 @@
let newListName = $state("");
let sortMode = $state("date");
let showSortMenu = $state(false);
let contextMenuListId = $state(null);
let editingListId = $state(null);
let contextMenuListId = $state<string | null>(null);
let editingListId = $state<string | null>(null);
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: "inbox", label: "Inbox" },
{ id: "today", label: "Today" },
{ id: "soon", label: "Soon" },
{ 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 list = tasks.filter((t) => !t.done);
@@ -50,9 +64,9 @@
list = list.filter((t) => t.text.toLowerCase().includes(q));
}
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") {
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;
});
@@ -77,7 +91,7 @@
});
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) {
if (t.done) continue;
counts.all++;
@@ -86,7 +100,7 @@
return counts;
});
function countForList(listId) {
function countForList(listId: string) {
if (listId === "all") return tasks.filter((t) => !t.done).length;
if (listId === "inbox") return tasks.filter((t) => !t.done && !t.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")
);
function handleQuickAdd(e) {
function handleQuickAdd(e: KeyboardEvent) {
if (e.key === "Enter" && quickInput.trim()) {
const listId = (activeListId === "all" || activeListId === "inbox") ? null : activeListId;
addTask({
text: quickInput.trim(),
bucket: activeBucket === "all" ? "inbox" : activeBucket,
bucket: (activeBucket === "all" ? "inbox" : activeBucket) as TaskBucket,
listId,
});
quickInput = "";
}
}
function setBucket(taskId, bucket) {
function setBucket(taskId: string, bucket: TaskBucket) {
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 });
}
function getListName(listId) {
function getListName(listId: string | null) {
if (!listId) return 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()) {
addTaskList(newListName.trim());
newListName = "";
@@ -138,10 +152,13 @@
if (e.key === "Escape") { showNewList = false; newListName = ""; }
}
function startRenaming(list) {
async function startRenaming(list: TaskList) {
editingListId = list.id;
editingName = list.name;
contextMenuListId = null;
await tick();
editingInputEl?.focus();
editingInputEl?.select();
}
function finishRenaming() {
@@ -152,17 +169,26 @@
editingName = "";
}
function handleDeleteList(id) {
function handleDeleteList(id: string) {
if (activeListId === id) activeListId = "all";
deleteTaskList(id);
contextMenuListId = null;
}
function toggleContextMenu(e, listId) {
function toggleContextMenu(e: MouseEvent, listId: string) {
e.preventDefault();
e.stopPropagation();
contextMenuListId = contextMenuListId === listId ? null : listId;
}
$effect(() => {
if (showNewList) {
tick().then(() => {
newListInputEl?.focus();
newListInputEl?.select();
});
}
});
</script>
<div class="flex flex-col h-full animate-fade-in">
@@ -297,13 +323,13 @@
{#if editingListId === list.id && !sidebarCollapsed}
<div class="px-1.5 py-0.5">
<input
bind:this={editingInputEl}
type="text"
class="w-full bg-bg-input border border-accent rounded px-2 py-1 text-[10px] text-text focus:outline-none"
bind:value={editingName}
onkeydown={(e) => { if (e.key === "Enter") finishRenaming(); if (e.key === "Escape") { editingListId = null; } }}
onblur={finishRenaming}
data-no-transition
autofocus
/>
</div>
{:else}
@@ -356,6 +382,7 @@
<div class="px-2 py-2 border-t border-border-subtle">
{#if showNewList}
<input
bind:this={newListInputEl}
type="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"
@@ -364,7 +391,6 @@
onkeydown={handleCreateList}
onblur={() => { showNewList = false; newListName = ""; }}
data-no-transition
autofocus
/>
{:else}
<button
@@ -403,7 +429,7 @@
<p class="text-[13px] text-text leading-relaxed">{task.text}</p>
<div class="flex items-center gap-2 mt-1.5">
<!-- Bucket pills -->
{#each ["today", "soon", "later"] as b}
{#each taskBucketOptions as b}
<button
class="text-[10px] px-2 py-0.5 rounded-full border
{task.bucket === b
@@ -415,7 +441,7 @@
{/each}
<span class="text-border-subtle">&middot;</span>
<!-- Effort pills -->
{#each ["quick", "medium", "deep"] as e}
{#each effortOptions as e}
<button
class="text-[10px] px-2 py-0.5 rounded-full border
{task.effort === e
@@ -423,7 +449,7 @@
: 'text-text-tertiary border-transparent hover:border-border-subtle hover:text-text-secondary'}"
style="transition-duration: var(--duration-ui)"
onclick={() => setEffort(task.id, e)}
>{EFFORT_LABELS[e]}</button>
>{effortLabel(e)}</button>
{/each}
<!-- Timestamp -->
{#if task.createdAt}
@@ -457,7 +483,7 @@
class="flex items-center gap-2 text-[12px] text-text-tertiary hover:text-text-secondary mb-2"
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)" />
Completed ({completedTasks.length})
</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
import { invoke } from '@tauri-apps/api/core';
import { emit } from '@tauri-apps/api/event';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { toasts } from './toasts.svelte.js';
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import type { AccessibilityPreferences, Preferences } from "$lib/types/app";
import { errorMessage } from "$lib/utils/errors.js";
import { toasts } from "./toasts.svelte.ts";
export const PREFERENCES_CHANGED_EVENT = 'kon:preferences-changed';
export const PREFERENCES_CHANGED_EVENT = "kon:preferences-changed";
type FontFamilies = Record<AccessibilityPreferences["fontFamily"], string>;
function currentWindowLabel() {
try {
@@ -14,7 +18,7 @@ function currentWindowLabel() {
}
}
function broadcastPreferences(prefs) {
function broadcastPreferences(prefs: Preferences) {
const source = currentWindowLabel();
if (source === null) return;
// Fire-and-forget — cross-window sync must never block the local apply path.
@@ -22,53 +26,53 @@ function broadcastPreferences(prefs) {
.catch(() => {});
}
const DEFAULTS = {
theme: 'dark',
zone: 'default',
const DEFAULTS: Preferences = {
theme: "dark",
zone: "default",
accessibility: {
fontFamily: 'lexend',
fontFamily: "lexend",
fontSize: 16,
letterSpacing: 0,
lineHeight: 1.5,
transcriptSize: 16,
bionicReading: false,
reduceMotion: 'system'
}
reduceMotion: "system",
},
};
const FONT_FAMILIES = {
const FONT_FAMILIES: FontFamilies = {
lexend: "'Lexend', system-ui, sans-serif",
atkinson: "'Atkinson Hyperlegible Next', system-ui, sans-serif",
opendyslexic: "'OpenDyslexic', system-ui, sans-serif"
opendyslexic: "'OpenDyslexic', system-ui, sans-serif",
};
function readFromDOM() {
function readFromDOM(): Preferences {
const el = document.documentElement;
return {
theme: el.dataset.theme || DEFAULTS.theme,
theme: (el.dataset.theme || DEFAULTS.theme) as Preferences["theme"],
zone: el.dataset.zone || DEFAULTS.zone,
accessibility: {
fontFamily: el.dataset.fontFamily || DEFAULTS.accessibility.fontFamily,
fontFamily: (el.dataset.fontFamily || DEFAULTS.accessibility.fontFamily) as AccessibilityPreferences["fontFamily"],
fontSize: parseFloat(el.style.getPropertyValue('--font-size-body')) || DEFAULTS.accessibility.fontSize,
letterSpacing: parseFloat(el.style.getPropertyValue('--letter-spacing-body')) || DEFAULTS.accessibility.letterSpacing,
lineHeight: parseFloat(el.style.getPropertyValue('--line-height-body')) || DEFAULTS.accessibility.lineHeight,
transcriptSize: DEFAULTS.accessibility.transcriptSize,
bionicReading: el.dataset.bionicReading === 'true',
reduceMotion: el.dataset.reduceMotion || DEFAULTS.accessibility.reduceMotion
}
bionicReading: el.dataset.bionicReading === "true",
reduceMotion: (el.dataset.reduceMotion || DEFAULTS.accessibility.reduceMotion) as AccessibilityPreferences["reduceMotion"],
},
};
}
function applyToDOM(prefs) {
function applyToDOM(prefs: Preferences) {
const el = document.documentElement;
// Theme — resolve 'system' to actual value
el.dataset.theme = prefs.theme === 'system'
? (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark')
el.dataset.theme = prefs.theme === "system"
? (window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark")
: prefs.theme;
// Zone
if (prefs.zone === 'default') {
if (prefs.zone === "default") {
delete el.dataset.zone;
} else {
el.dataset.zone = prefs.zone;
@@ -85,32 +89,31 @@ function applyToDOM(prefs) {
el.dataset.fontFamily = a.fontFamily;
// Reduce motion — three-value resolution
const motionReduced = a.reduceMotion === 'on'
|| (a.reduceMotion === 'system' && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
const motionReduced = a.reduceMotion === "on"
|| (a.reduceMotion === "system" && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
if (motionReduced) {
el.dataset.reduceMotion = 'true';
el.dataset.reduceMotion = "true";
} else {
delete el.dataset.reduceMotion;
}
}
let saveTimeout = null;
let saveTimeout: ReturnType<typeof setTimeout> | undefined;
// Show the failure toast at most once per process so a stuck SQLite path
// doesn't spam the user every time they nudge a slider.
let saveFailureToastShown = false;
function persistToSQLite(prefs) {
function persistToSQLite(prefs: Preferences) {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(async () => {
try {
await invoke('save_preferences', { preferences: JSON.stringify(prefs) });
await invoke("save_preferences", { preferences: JSON.stringify(prefs) });
saveFailureToastShown = false;
} catch (e) {
console.error('Failed to save preferences:', e);
console.error("Failed to save preferences:", e);
if (!saveFailureToastShown) {
const msg = typeof e === 'string' ? e : (e?.message ?? String(e));
toasts.warn(
'Could not save preferences',
`${msg}. Your changes still apply for this session.`,
"Could not save preferences",
`${errorMessage(e)}. Your changes still apply for this session.`,
);
saveFailureToastShown = true;
}
@@ -133,14 +136,14 @@ export function getPreferences() {
return preferences;
}
export function updatePreferences(updates) {
export function updatePreferences(updates: Partial<Preferences>) {
Object.assign(preferences, updates);
applyToDOM(preferences);
persistToSQLite(preferences);
broadcastPreferences(preferences);
}
export function updateAccessibility(updates) {
export function updateAccessibility(updates: Partial<AccessibilityPreferences>) {
Object.assign(preferences.accessibility, updates);
applyToDOM(preferences);
persistToSQLite(preferences);
@@ -149,8 +152,8 @@ export function updateAccessibility(updates) {
// Apply preferences received from another Tauri window. Mutates local state
// and DOM only — never persists or re-broadcasts, so there is no echo loop.
export function applyExternalPreferences(prefs) {
if (!prefs || typeof prefs !== 'object') return;
export function applyExternalPreferences(prefs: Partial<Preferences> | null | undefined) {
if (!prefs || typeof prefs !== "object") return;
Object.assign(preferences, prefs);
if (prefs.accessibility) {
Object.assign(preferences.accessibility, prefs.accessibility);
@@ -159,11 +162,11 @@ export function applyExternalPreferences(prefs) {
}
// Re-resolve when OS preferences change
if (typeof window !== 'undefined') {
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => {
if (preferences.theme === 'system') applyToDOM(preferences);
if (typeof window !== "undefined") {
window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => {
if (preferences.theme === "system") applyToDOM(preferences);
});
window.matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', () => {
if (preferences.accessibility.reduceMotion === 'system') applyToDOM(preferences);
window.matchMedia("(prefers-reduced-motion: reduce)").addEventListener("change", () => {
if (preferences.accessibility.reduceMotion === "system") applyToDOM(preferences);
});
}

View File

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

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 type { Segment } from "$lib/types/app";
type ExportFormat = "txt" | "md" | "csv" | "html" | "srt" | "vtt";
/**
* 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"
* @returns {string}
*/
export function exportTranscript(text, segments, format) {
export function exportTranscript(
text: string,
segments: Segment[],
format: ExportFormat,
): string {
switch (format) {
case "srt":
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 "";
return segments
.map((seg, i) => {
@@ -33,7 +40,7 @@ function toSRT(segments) {
.join("\n");
}
function toVTT(segments) {
function toVTT(segments: Segment[]): string {
if (!segments || segments.length === 0) return "";
let out = "WEBVTT\n\n";
out += segments
@@ -44,7 +51,7 @@ function toVTT(segments) {
return out;
}
function toCSV(segments) {
function toCSV(segments: Segment[]): string {
if (!segments || segments.length === 0) return "Start,End,Text\n";
let csv = "Start,End,Text\n";
for (const seg of segments) {
@@ -54,11 +61,11 @@ function toCSV(segments) {
return csv;
}
function escapeHtml(str) {
function escapeHtml(str: string): string {
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>
<html lang="en">
<head>
@@ -95,7 +102,7 @@ function toHTML(text, segments) {
return html;
}
function toMarkdown(text, segments) {
function toMarkdown(text: string, segments: Segment[]): string {
let md = `# Transcription\n\n`;
md += `**Date:** ${new Date().toLocaleDateString("en-GB")}\n`;
if (segments && segments.length > 0) {

View File

@@ -25,12 +25,12 @@ const WORD_BUCKETS = [
{ max: Infinity, tag: "words:very-long" },
];
function durationTag(seconds) {
function durationTag(seconds: number): string | null {
if (!Number.isFinite(seconds) || seconds <= 0) return 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;
const count = text.trim().split(/\s+/).filter(Boolean).length;
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
// "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.
function timeOfDayTag(dateStr) {
function timeOfDayTag(dateStr: string): string | null {
if (!dateStr) return null;
let ts = Date.parse(dateStr);
if (Number.isNaN(ts)) {
@@ -62,7 +62,7 @@ function timeOfDayTag(dateStr) {
return "time:night";
}
function sourceTag(source) {
function sourceTag(source: string): string | null {
if (!source) return null;
const s = String(source).toLowerCase();
if (s.includes("file")) return "source:file";
@@ -76,17 +76,17 @@ function sourceTag(source) {
// information and add cognitive load without improving retrieval. The
// 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.
export function deriveAutoTags(_item) {
export function deriveAutoTags(_item: TranscriptEntry): string[] {
return [];
}
export function normaliseTag(raw) {
export function normaliseTag(raw: string): string {
return String(raw || "").trim().toLowerCase().replace(/\s+/g, "-");
}
// Build the flat frontmatter object that represents a transcript's metadata.
// 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 {};
const auto = deriveAutoTags(item);
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
// 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 (typeof value === "number" || typeof value === "boolean") return String(value);
const s = String(value);
@@ -114,7 +114,7 @@ function yamlScalar(value) {
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
export function serialiseFrontmatter(fm) {
export function serialiseFrontmatter(fm: Record<string, unknown>): string {
const lines = ["---"];
for (const [key, value] of Object.entries(fm)) {
if (Array.isArray(value)) {
@@ -133,10 +133,11 @@ export function serialiseFrontmatter(fm) {
}
// Produce an Obsidian-flavoured markdown document for a transcript.
export function buildMarkdown(item) {
export function buildMarkdown(item: TranscriptEntry): string {
const fm = buildFrontmatter(item);
const header = serialiseFrontmatter(fm);
const title = fm.title || "Transcript";
const body = item?.text || "";
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 { 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 = {
os: detectBrowserOs(),
@@ -52,14 +62,14 @@ function detectBrowserFamily() {
/** Fetch OS info from the Tauri backend. Idempotent call multiple times,
* only the first does the round-trip. Browser-preview mode returns the
* navigator-detected fallback. */
export async function loadOsInfo() {
export async function loadOsInfo(): Promise<OsInfo> {
if (cached) return cached;
if (!hasTauriRuntime()) {
cached = FALLBACK_BROWSER_INFO;
return cached;
}
try {
cached = await invoke('get_os_info');
cached = await invoke<OsInfo>("get_os_info");
} catch (err) {
console.warn('loadOsInfo: get_os_info failed, using browser fallback', err);
cached = FALLBACK_BROWSER_INFO;
@@ -70,31 +80,31 @@ export async function loadOsInfo() {
/** Synchronous accessor. Returns null if loadOsInfo has not been awaited
* yet. Components that need the value at first render should await
* loadOsInfo() in onMount before reading. */
export function osInfo() {
export function osInfo(): OsInfo | null {
return cached;
}
export function isMac() {
export function isMac(): boolean {
return cached?.os === 'macos';
}
export function isWindows() {
export function isWindows(): boolean {
return cached?.os === 'windows';
}
export function isLinux() {
export function isLinux(): boolean {
return cached?.os === 'linux';
}
/** Localised label for the primary keyboard modifier. "Cmd" on macOS,
* "Ctrl" everywhere else. Use in hotkey display strings:
* `${modKeyLabel()}+Shift+R` */
export function modKeyLabel() {
export function modKeyLabel(): string {
return cached?.primaryModifierLabel ?? 'Ctrl';
}
/** True if the current Linux session is Wayland. False on Windows/macOS.
* Useful for Settings Audio "PipeWire detected" status display. */
export function isWayland() {
export function isWayland(): boolean {
return !!cached?.isWayland;
}

View File

@@ -11,7 +11,7 @@
// behaviour for browser-preview mode.
export function hasTauriRuntime() {
if (typeof window === 'undefined') return false;
if (typeof window === "undefined") return false;
if (window.__TAURI_INTERNALS__) return true;
if (window.isTauri === true) return true;
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,
prepareWithSegments,
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.
// prepare() is expensive, layout() is the cheap hot path.
@@ -11,34 +23,44 @@ const prepareCache = new Map();
const segmentedPrepareCache = new Map();
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);
cache.set(key, value);
if (cache.size > MAX_CACHE_ENTRIES) {
const oldestKey = cache.keys().next().value;
cache.delete(oldestKey);
if (typeof oldestKey === "string") cache.delete(oldestKey);
}
return value;
}
function cacheKey(text, font, options = {}) {
function cacheKey(text: string, font: string, options: LayoutOptions = {}): string {
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);
if (cache.has(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));
}
export function prepareText(text, font, options = {}) {
export function prepareText(text: string, font: string, options: LayoutOptions = {}): unknown {
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(
segmentedPrepareCache,
prepareWithSegments,
@@ -59,7 +81,13 @@ export function prepareTextWithSegments(text, font, options = {}) {
* @param {object} [options] - Additional options (e.g. { whiteSpace: 'pre-wrap' })
* @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);
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).
* Spaces, tabs, and hard breaks are preserved.
*/
export function measurePreWrap(text, font, maxWidth, lineHeight) {
return measureTextHeight(text, font, maxWidth, lineHeight, { whiteSpace: 'pre-wrap' });
export function measurePreWrap(
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.
*/
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);
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.
*/
export function clampTextLines(
text,
font,
maxWidth,
lineHeight,
maxLines,
options = {},
) {
text: string,
font: string,
maxWidth: number,
lineHeight: number,
maxLines: number,
options: LayoutOptions = {},
): ClampResult {
const result = layoutTextLines(text, font, maxWidth, lineHeight, options);
const visibleLines = result.lines.slice(0, maxLines);
const didTruncate = result.lineCount > maxLines;

View File

@@ -1,10 +1,10 @@
/** Pad number to 2 digits */
export function pad(n) {
export function pad(n: number): string {
return n.toString().padStart(2, "0");
}
/** 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";
const m = Math.floor(seconds / 60);
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) */
export function formatDuration(seconds) {
export function formatDuration(seconds: number): string {
if (!seconds) return "";
const m = Math.floor(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) */
export function formatTimestamp(iso) {
export function formatTimestamp(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
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) */
export function formatTimeSRT(seconds) {
export function formatTimeSRT(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
@@ -37,7 +37,7 @@ export function formatTimeSRT(seconds) {
}
/** 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 m = Math.floor((seconds % 3600) / 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);
offsets[0] = 0;
for (let i = 0; i < heights.length; i++) {
@@ -8,10 +8,10 @@ export function buildCumulativeOffsets(heights) {
}
export function findVisibleRange(
cumulativeOffsets,
itemCount,
scrollTop,
viewportHeight,
cumulativeOffsets: number[],
itemCount: number,
scrollTop: number,
viewportHeight: number,
buffer = 4,
) {
if (!cumulativeOffsets.length || itemCount === 0 || viewportHeight <= 0) {

View File

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

View File

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

View File

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

View File

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