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:
@@ -3,6 +3,7 @@
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"description": "Kon — Think out loud",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"dev": "npm run dev:frontend",
|
||||
"dev:frontend": "svelte-kit sync && vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
|
||||
|
||||
8
run.sh
8
run.sh
@@ -8,19 +8,15 @@ cd "$(dirname "$0")"
|
||||
export LIBCLANG_PATH=/usr/lib64/llvm21/lib64
|
||||
|
||||
printf 'Starting Vite dev server...\n' >&2
|
||||
npm run dev &
|
||||
npm run dev:frontend &
|
||||
VITE_PID=$!
|
||||
|
||||
until curl -sf http://localhost:1420 > /dev/null 2>&1; do sleep 0.5; done
|
||||
printf 'Vite ready. Launching Tauri...\n' >&2
|
||||
|
||||
# Blank beforeDevCommand so Tauri doesn't spawn a second Vite
|
||||
sed -i 's/"beforeDevCommand": "npm run dev"/"beforeDevCommand": ""/' src-tauri/tauri.conf.json
|
||||
|
||||
cleanup() {
|
||||
sed -i 's/"beforeDevCommand": ""/"beforeDevCommand": "npm run dev"/' src-tauri/tauri.conf.json
|
||||
kill "$VITE_PID" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
npx tauri dev
|
||||
npx tauri dev --config '{"build":{"beforeDevCommand":""}}'
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"version": "0.1.0",
|
||||
"identifier": "uk.co.corbel.kon",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeDevCommand": "npm run dev:frontend",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../build"
|
||||
|
||||
44
src/app.d.ts
vendored
Normal file
44
src/app.d.ts
vendored
Normal 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 {};
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 }
|
||||
}));
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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]'}"
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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">·</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>
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
599
src/lib/stores/page.svelte.ts
Normal file
599
src/lib/stores/page.svelte.ts
Normal 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 {}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,12 +18,12 @@
|
||||
|
||||
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
|
||||
case "success": return 3000;
|
||||
case "info": return 4000;
|
||||
case "warn": return 6000;
|
||||
case "error": return 0; // sticky
|
||||
default: return 4000;
|
||||
}
|
||||
}
|
||||
@@ -28,9 +31,9 @@ function defaultDuration(severity) {
|
||||
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
243
src/lib/types/app.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
41
src/lib/utils/accessibilityTypography.ts
Normal file
41
src/lib/utils/accessibilityTypography.ts
Normal 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
8
src/lib/utils/errors.ts
Normal 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);
|
||||
}
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
8
src/lib/utils/storage.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
224
src/lib/utils/taskExtractor.ts
Normal file
224
src/lib/utils/taskExtractor.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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) {
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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; }}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
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; cancelAnimationFrame(animFrameId); };
|
||||
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);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadViewerItem(parseStoredJson<TranscriptEntry>(localStorage.getItem("kon_viewer_item")));
|
||||
const mode = localStorage.getItem("kon_viewer_mode");
|
||||
if (mode === "edit" || mode === "view") viewerMode = mode;
|
||||
} catch {}
|
||||
|
||||
// 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
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">
|
||||
|
||||
Reference in New Issue
Block a user