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": {
|
"compilerOptions": {
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"checkJs": true,
|
"checkJs": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"description": "Kon — Think out loud",
|
"description": "Kon — Think out loud",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "npm run dev:frontend",
|
||||||
|
"dev:frontend": "svelte-kit sync && vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
|
||||||
|
|||||||
8
run.sh
8
run.sh
@@ -8,19 +8,15 @@ cd "$(dirname "$0")"
|
|||||||
export LIBCLANG_PATH=/usr/lib64/llvm21/lib64
|
export LIBCLANG_PATH=/usr/lib64/llvm21/lib64
|
||||||
|
|
||||||
printf 'Starting Vite dev server...\n' >&2
|
printf 'Starting Vite dev server...\n' >&2
|
||||||
npm run dev &
|
npm run dev:frontend &
|
||||||
VITE_PID=$!
|
VITE_PID=$!
|
||||||
|
|
||||||
until curl -sf http://localhost:1420 > /dev/null 2>&1; do sleep 0.5; done
|
until curl -sf http://localhost:1420 > /dev/null 2>&1; do sleep 0.5; done
|
||||||
printf 'Vite ready. Launching Tauri...\n' >&2
|
printf 'Vite ready. Launching Tauri...\n' >&2
|
||||||
|
|
||||||
# Blank beforeDevCommand so Tauri doesn't spawn a second Vite
|
|
||||||
sed -i 's/"beforeDevCommand": "npm run dev"/"beforeDevCommand": ""/' src-tauri/tauri.conf.json
|
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
sed -i 's/"beforeDevCommand": ""/"beforeDevCommand": "npm run dev"/' src-tauri/tauri.conf.json
|
|
||||||
kill "$VITE_PID" 2>/dev/null || true
|
kill "$VITE_PID" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
trap cleanup EXIT INT TERM
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
npx tauri dev
|
npx tauri dev --config '{"build":{"beforeDevCommand":""}}'
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"identifier": "uk.co.corbel.kon",
|
"identifier": "uk.co.corbel.kon",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev:frontend",
|
||||||
"devUrl": "http://localhost:1420",
|
"devUrl": "http://localhost:1420",
|
||||||
"beforeBuildCommand": "npm run build",
|
"beforeBuildCommand": "npm run build",
|
||||||
"frontendDist": "../build"
|
"frontendDist": "../build"
|
||||||
|
|||||||
44
src/app.d.ts
vendored
Normal file
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 { page, settings, tasks, saveSettings } from "$lib/stores/page.svelte.js";
|
||||||
import { Mic, FileText, SquareCheck, Clock, Settings, ChevronRight, ChevronLeft } from 'lucide-svelte';
|
import { Mic, FileText, SquareCheck, Clock, Settings, ChevronRight, ChevronLeft } from 'lucide-svelte';
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
saveSettings();
|
saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigate(id) {
|
function navigate(id: string) {
|
||||||
page.current = id;
|
page.current = id;
|
||||||
if (id !== "dictation") {
|
if (id !== "dictation") {
|
||||||
page.taskSidebarOpen = false;
|
page.taskSidebarOpen = false;
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
// src/lib/actions/bionicReading.js
|
// src/lib/actions/bionicReading.js
|
||||||
|
|
||||||
function applyBionic(node) {
|
function applyBionic(node: HTMLElement) {
|
||||||
const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);
|
const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);
|
||||||
const textNodes = [];
|
const textNodes: Text[] = [];
|
||||||
let current;
|
let current: Node | null;
|
||||||
while ((current = walker.nextNode())) textNodes.push(current);
|
while ((current = walker.nextNode())) {
|
||||||
|
if (current.nodeType === Node.TEXT_NODE) textNodes.push(current as Text);
|
||||||
|
}
|
||||||
|
|
||||||
for (const textNode of textNodes) {
|
for (const textNode of textNodes) {
|
||||||
|
if (textNode.textContent == null) continue;
|
||||||
const words = textNode.textContent.split(/(\s+)/);
|
const words = textNode.textContent.split(/(\s+)/);
|
||||||
if (words.length <= 1 && !words[0].trim()) continue;
|
if (words.length <= 1 && !words[0].trim()) continue;
|
||||||
|
|
||||||
@@ -22,21 +25,21 @@ function applyBionic(node) {
|
|||||||
fragment.appendChild(b);
|
fragment.appendChild(b);
|
||||||
fragment.appendChild(document.createTextNode(word.slice(boldLen)));
|
fragment.appendChild(document.createTextNode(word.slice(boldLen)));
|
||||||
}
|
}
|
||||||
textNode.parentNode.replaceChild(fragment, textNode);
|
textNode.parentNode?.replaceChild(fragment, textNode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripBionic(node) {
|
function stripBionic(node: HTMLElement) {
|
||||||
const bolds = node.querySelectorAll('b');
|
const bolds = node.querySelectorAll("b");
|
||||||
for (const b of bolds) {
|
for (const b of bolds) {
|
||||||
const text = document.createTextNode(b.textContent);
|
const text = document.createTextNode(b.textContent ?? "");
|
||||||
b.parentNode.replaceChild(text, b);
|
b.parentNode?.replaceChild(text, b);
|
||||||
}
|
}
|
||||||
node.normalize(); // merge adjacent text nodes
|
node.normalize(); // merge adjacent text nodes
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bionicReading(node, enabled) {
|
export function bionicReading(node: HTMLElement, enabled: boolean) {
|
||||||
let observer = null;
|
let observer: MutationObserver | null = null;
|
||||||
|
|
||||||
function refresh() {
|
function refresh() {
|
||||||
// Disconnect observer while we mutate, to avoid infinite loop
|
// Disconnect observer while we mutate, to avoid infinite loop
|
||||||
@@ -47,6 +50,7 @@ export function bionicReading(node, enabled) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function observe() {
|
function observe() {
|
||||||
|
if (!observer) return;
|
||||||
observer.observe(node, { childList: true, characterData: true, subtree: true });
|
observer.observe(node, { childList: true, characterData: true, subtree: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +62,7 @@ export function bionicReading(node, enabled) {
|
|||||||
observe();
|
observe();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
update(newEnabled) {
|
update(newEnabled: boolean) {
|
||||||
enabled = newEnabled;
|
enabled = newEnabled;
|
||||||
refresh();
|
refresh();
|
||||||
},
|
},
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
|
import type { FontFamily, ReduceMotion } from "$lib/types/app";
|
||||||
import { getPreferences, updateAccessibility } from '$lib/stores/preferences.svelte.js';
|
import { getPreferences, updateAccessibility } from '$lib/stores/preferences.svelte.js';
|
||||||
import Toggle from '$lib/components/Toggle.svelte';
|
import Toggle from '$lib/components/Toggle.svelte';
|
||||||
import SegmentedButton from '$lib/components/SegmentedButton.svelte';
|
import SegmentedButton from '$lib/components/SegmentedButton.svelte';
|
||||||
@@ -9,7 +10,7 @@
|
|||||||
{ id: 'lexend', label: 'Lexend' },
|
{ id: 'lexend', label: 'Lexend' },
|
||||||
{ id: 'atkinson', label: 'Atkinson' },
|
{ id: 'atkinson', label: 'Atkinson' },
|
||||||
{ id: 'opendyslexic', label: 'OpenDyslexic' },
|
{ id: 'opendyslexic', label: 'OpenDyslexic' },
|
||||||
];
|
] satisfies { id: FontFamily; label: string }[];
|
||||||
|
|
||||||
let bionicChecked = $state(prefs.accessibility.bionicReading || false);
|
let bionicChecked = $state(prefs.accessibility.bionicReading || false);
|
||||||
let motionValue = $state(
|
let motionValue = $state(
|
||||||
@@ -25,11 +26,15 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const mapped = motionValue === 'On' ? 'on' : motionValue === 'Off' ? 'off' : 'system';
|
const mapped: ReduceMotion = motionValue === 'On' ? 'on' : motionValue === 'Off' ? 'off' : 'system';
|
||||||
if (mapped !== prefs.accessibility.reduceMotion) {
|
if (mapped !== prefs.accessibility.reduceMotion) {
|
||||||
updateAccessibility({ reduceMotion: mapped });
|
updateAccessibility({ reduceMotion: mapped });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function rangeValue(event: Event): number {
|
||||||
|
return Number((event.currentTarget as HTMLInputElement).value);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-5">
|
<div class="flex flex-col gap-5">
|
||||||
@@ -59,7 +64,7 @@
|
|||||||
<span class="text-[11px] text-text-tertiary">{prefs.accessibility.fontSize}px</span>
|
<span class="text-[11px] text-text-tertiary">{prefs.accessibility.fontSize}px</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="range" min="16" max="24" step="1" value={prefs.accessibility.fontSize}
|
<input type="range" min="16" max="24" step="1" value={prefs.accessibility.fontSize}
|
||||||
oninput={(e) => updateAccessibility({ fontSize: Number(e.target.value) })}
|
oninput={(e) => updateAccessibility({ fontSize: rangeValue(e) })}
|
||||||
class="w-full accent-accent" />
|
class="w-full accent-accent" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -70,7 +75,7 @@
|
|||||||
<span class="text-[11px] text-text-tertiary">{prefs.accessibility.letterSpacing.toFixed(2)}em</span>
|
<span class="text-[11px] text-text-tertiary">{prefs.accessibility.letterSpacing.toFixed(2)}em</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="range" min="0" max="0.15" step="0.01" value={prefs.accessibility.letterSpacing}
|
<input type="range" min="0" max="0.15" step="0.01" value={prefs.accessibility.letterSpacing}
|
||||||
oninput={(e) => updateAccessibility({ letterSpacing: Number(e.target.value) })}
|
oninput={(e) => updateAccessibility({ letterSpacing: rangeValue(e) })}
|
||||||
class="w-full accent-accent" />
|
class="w-full accent-accent" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -81,7 +86,7 @@
|
|||||||
<span class="text-[11px] text-text-tertiary">{prefs.accessibility.lineHeight.toFixed(1)}</span>
|
<span class="text-[11px] text-text-tertiary">{prefs.accessibility.lineHeight.toFixed(1)}</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="range" min="1.3" max="2.2" step="0.1" value={prefs.accessibility.lineHeight}
|
<input type="range" min="1.3" max="2.2" step="0.1" value={prefs.accessibility.lineHeight}
|
||||||
oninput={(e) => updateAccessibility({ lineHeight: Number(e.target.value) })}
|
oninput={(e) => updateAccessibility({ lineHeight: rangeValue(e) })}
|
||||||
class="w-full accent-accent" />
|
class="w-full accent-accent" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -92,7 +97,7 @@
|
|||||||
<span class="text-[11px] text-text-tertiary">{prefs.accessibility.transcriptSize}px</span>
|
<span class="text-[11px] text-text-tertiary">{prefs.accessibility.transcriptSize}px</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="range" min="16" max="24" step="1" value={prefs.accessibility.transcriptSize}
|
<input type="range" min="16" max="24" step="1" value={prefs.accessibility.transcriptSize}
|
||||||
oninput={(e) => updateAccessibility({ transcriptSize: Number(e.target.value) })}
|
oninput={(e) => updateAccessibility({ transcriptSize: rangeValue(e) })}
|
||||||
class="w-full accent-accent" />
|
class="w-full accent-accent" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import { settings, saveSettings } from "$lib/stores/page.svelte.js";
|
import { settings, saveSettings } from "$lib/stores/page.svelte.js";
|
||||||
|
|
||||||
let recording = $state(false);
|
let recording = $state(false);
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
const modifierKeys = new Set(["Control", "Shift", "Alt", "Meta"]);
|
const modifierKeys = new Set(["Control", "Shift", "Alt", "Meta"]);
|
||||||
|
|
||||||
function handleKeyDown(e) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
// Capture-phase listener; guard still belts-and-braces.
|
// Capture-phase listener; guard still belts-and-braces.
|
||||||
if (!recording) return;
|
if (!recording) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
// Wait for a non-modifier key
|
// Wait for a non-modifier key
|
||||||
if (modifierKeys.has(e.key)) return;
|
if (modifierKeys.has(e.key)) return;
|
||||||
|
|
||||||
const parts = [];
|
const parts: string[] = [];
|
||||||
if (e.ctrlKey) parts.push("Ctrl");
|
if (e.ctrlKey) parts.push("Ctrl");
|
||||||
if (e.shiftKey) parts.push("Shift");
|
if (e.shiftKey) parts.push("Shift");
|
||||||
if (e.altKey) parts.push("Alt");
|
if (e.altKey) parts.push("Alt");
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { ListTree, Check, Timer, Loader2 } from 'lucide-svelte';
|
import { ListTree, Check, Timer, Loader2 } from 'lucide-svelte';
|
||||||
|
|
||||||
let { parentTaskId, reduceMotion = false } = $props();
|
let { parentTaskId, reduceMotion = false } = $props();
|
||||||
|
|
||||||
let subtasks = $state([]);
|
interface Subtask {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
done: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let subtasks = $state<Subtask[]>([]);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let decomposing = $state(false);
|
let decomposing = $state(false);
|
||||||
@@ -13,7 +19,7 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
subtasks = await invoke('list_subtasks_cmd', { parentTaskId });
|
subtasks = await invoke<Subtask[]>('list_subtasks_cmd', { parentTaskId });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = String(e);
|
error = String(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -25,7 +31,7 @@
|
|||||||
decomposing = true;
|
decomposing = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
subtasks = await invoke('decompose_and_store', { parentTaskId });
|
subtasks = await invoke<Subtask[]>('decompose_and_store', { parentTaskId });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = String(e);
|
error = String(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -33,7 +39,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkStep(subtaskId) {
|
async function checkStep(subtaskId: string) {
|
||||||
try {
|
try {
|
||||||
await invoke('complete_subtask_cmd', { subtaskId });
|
await invoke('complete_subtask_cmd', { subtaskId });
|
||||||
const idx = subtasks.findIndex(s => s.id === subtaskId);
|
const idx = subtasks.findIndex(s => s.id === subtaskId);
|
||||||
@@ -41,7 +47,7 @@
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startTimer(subtaskId) {
|
function startTimer(subtaskId: string) {
|
||||||
window.dispatchEvent(new CustomEvent('kon:start-timer', {
|
window.dispatchEvent(new CustomEvent('kon:start-timer', {
|
||||||
detail: { taskId: subtaskId, seconds: 120 }
|
detail: { taskId: subtaskId, seconds: 120 }
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,31 +1,38 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { errorMessage } from "$lib/utils/errors.js";
|
||||||
import Card from "$lib/components/Card.svelte";
|
import Card from "$lib/components/Card.svelte";
|
||||||
|
|
||||||
let { modelSize = "base", onComplete = () => {} } = $props();
|
let { modelSize = "base", onComplete = () => {} } = $props<{
|
||||||
|
modelSize?: "tiny" | "base" | "small" | "medium";
|
||||||
|
onComplete?: () => void;
|
||||||
|
}>();
|
||||||
|
|
||||||
let progress = $state(0);
|
let progress = $state(0);
|
||||||
let downloading = $state(false);
|
let downloading = $state(false);
|
||||||
let downloaded = $state(0);
|
let downloaded = $state(0);
|
||||||
let total = $state(0);
|
let total = $state(0);
|
||||||
let error = $state("");
|
let error = $state("");
|
||||||
let unlisten = null;
|
let unlisten: null | (() => void) = null;
|
||||||
|
|
||||||
const modelInfo = {
|
const modelInfo = {
|
||||||
tiny: { size: "~75 MB", accuracy: "Basic" },
|
tiny: { size: "~75 MB", accuracy: "Basic" },
|
||||||
base: { size: "~142 MB", accuracy: "Good" },
|
base: { size: "~142 MB", accuracy: "Good" },
|
||||||
small: { size: "~466 MB", accuracy: "Better" },
|
small: { size: "~466 MB", accuracy: "Better" },
|
||||||
medium: { size: "~1.5 GB", accuracy: "Best" },
|
medium: { size: "~1.5 GB", accuracy: "Best" },
|
||||||
};
|
} satisfies Record<string, { size: string; accuracy: string }>;
|
||||||
|
let currentModelInfo = $derived(
|
||||||
|
modelInfo[String(modelSize) as keyof typeof modelInfo]
|
||||||
|
);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
unlisten = await listen("model-download-progress", (event) => {
|
unlisten = await listen("model-download-progress", (event) => {
|
||||||
const data = event.payload;
|
const data = event.payload as { progress?: number; downloaded?: number; total?: number };
|
||||||
progress = data.progress;
|
progress = data.progress ?? 0;
|
||||||
downloaded = data.downloaded;
|
downloaded = data.downloaded ?? 0;
|
||||||
total = data.total;
|
total = data.total ?? 0;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,12 +48,12 @@
|
|||||||
await invoke("download_model", { size: modelSize });
|
await invoke("download_model", { size: modelSize });
|
||||||
onComplete();
|
onComplete();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = typeof err === "string" ? err : err.message || "Download failed";
|
error = errorMessage(err) || "Download failed";
|
||||||
downloading = false;
|
downloading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(bytes) {
|
function formatBytes(bytes: number) {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`;
|
if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||||
@@ -68,7 +75,7 @@
|
|||||||
Kon needs the <span class="font-medium text-text">{modelSize}</span> model to transcribe speech.
|
Kon needs the <span class="font-medium text-text">{modelSize}</span> model to transcribe speech.
|
||||||
</p>
|
</p>
|
||||||
<p class="text-[11px] text-text-tertiary mb-6">
|
<p class="text-[11px] text-text-tertiary mb-6">
|
||||||
{modelInfo[modelSize]?.size ?? "?"} · {modelInfo[modelSize]?.accuracy ?? "?"} accuracy · 100% offline
|
{currentModelInfo?.size ?? "?"} · {currentModelInfo?.accuracy ?? "?"} accuracy · 100% offline
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{#if downloading}
|
{#if downloading}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
// Invisible resize-handle overlays around each Kon window edge.
|
// Invisible resize-handle overlays around each Kon window edge.
|
||||||
// Needed because Kon runs with decorations off, so the WM
|
// Needed because Kon runs with decorations off, so the WM
|
||||||
// provides no resize affordance on KDE/GNOME Wayland.
|
// provides no resize affordance on KDE/GNOME Wayland.
|
||||||
@@ -24,7 +24,10 @@
|
|||||||
|
|
||||||
const enabled = hasTauriRuntime();
|
const enabled = hasTauriRuntime();
|
||||||
|
|
||||||
async function startResize(e, direction) {
|
async function startResize(
|
||||||
|
e: PointerEvent,
|
||||||
|
direction: "North" | "South" | "West" | "East" | "NorthWest" | "NorthEast" | "SouthWest" | "SouthEast",
|
||||||
|
) {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -35,7 +38,7 @@
|
|||||||
// diagonal component and collapsing a corner drag to a single axis.
|
// diagonal component and collapsing a corner drag to a single axis.
|
||||||
// pointerdown + setPointerCapture keeps the direction pinned.
|
// pointerdown + setPointerCapture keeps the direction pinned.
|
||||||
try {
|
try {
|
||||||
e.currentTarget?.setPointerCapture?.(e.pointerId);
|
(e.currentTarget as HTMLElement | null)?.setPointerCapture?.(e.pointerId);
|
||||||
} catch {}
|
} catch {}
|
||||||
try {
|
try {
|
||||||
await getCurrentWindow().startResizeDragging(direction);
|
await getCurrentWindow().startResizeDragging(direction);
|
||||||
@@ -44,13 +47,21 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if enabled}
|
{#if enabled}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="kon-rh kon-rh-n" onpointerdown={(e) => startResize(e,"North")}></div>
|
<div class="kon-rh kon-rh-n" onpointerdown={(e) => startResize(e,"North")}></div>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="kon-rh kon-rh-s" onpointerdown={(e) => startResize(e,"South")}></div>
|
<div class="kon-rh kon-rh-s" onpointerdown={(e) => startResize(e,"South")}></div>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="kon-rh kon-rh-w" onpointerdown={(e) => startResize(e,"West")}></div>
|
<div class="kon-rh kon-rh-w" onpointerdown={(e) => startResize(e,"West")}></div>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="kon-rh kon-rh-e" onpointerdown={(e) => startResize(e,"East")}></div>
|
<div class="kon-rh kon-rh-e" onpointerdown={(e) => startResize(e,"East")}></div>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="kon-rh kon-rh-nw" onpointerdown={(e) => startResize(e,"NorthWest")}></div>
|
<div class="kon-rh kon-rh-nw" onpointerdown={(e) => startResize(e,"NorthWest")}></div>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="kon-rh kon-rh-ne" onpointerdown={(e) => startResize(e,"NorthEast")}></div>
|
<div class="kon-rh kon-rh-ne" onpointerdown={(e) => startResize(e,"NorthEast")}></div>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="kon-rh kon-rh-sw" onpointerdown={(e) => startResize(e,"SouthWest")}></div>
|
<div class="kon-rh kon-rh-sw" onpointerdown={(e) => startResize(e,"SouthWest")}></div>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="kon-rh kon-rh-se" onpointerdown={(e) => startResize(e,"SouthEast")}></div>
|
<div class="kon-rh kon-rh-se" onpointerdown={(e) => startResize(e,"SouthEast")}></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import { page, tasks, addTask, completeTask, uncompleteTask } from "$lib/stores/page.svelte.js";
|
import { page, tasks, addTask, completeTask, uncompleteTask } from "$lib/stores/page.svelte.js";
|
||||||
import { BUCKET_DOT_COLORS, SIDEBAR_MAX_TASKS } from "$lib/utils/constants.js";
|
import { BUCKET_DOT_COLORS, SIDEBAR_MAX_TASKS } from "$lib/utils/constants.js";
|
||||||
|
|
||||||
let quickInput = $state("");
|
let quickInput = $state("");
|
||||||
let recentlyAdded = $state(new Set());
|
let recentlyAdded = $state(new Set<string>());
|
||||||
|
|
||||||
let activeTasks = $derived(tasks.filter((t) => !t.done).slice(0, SIDEBAR_MAX_TASKS));
|
let activeTasks = $derived(tasks.filter((t) => !t.done).slice(0, SIDEBAR_MAX_TASKS));
|
||||||
let taskCount = $derived(tasks.filter((t) => !t.done).length);
|
let taskCount = $derived(tasks.filter((t) => !t.done).length);
|
||||||
|
|
||||||
function handleQuickAdd(e) {
|
function handleQuickAdd(e: KeyboardEvent) {
|
||||||
if (e.key === "Enter" && quickInput.trim()) {
|
if (e.key === "Enter" && quickInput.trim()) {
|
||||||
addTask({ text: quickInput.trim(), bucket: "inbox" });
|
addTask({ text: quickInput.trim(), bucket: "inbox" });
|
||||||
quickInput = "";
|
quickInput = "";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { settings } from "$lib/stores/page.svelte.js";
|
import { settings } from "$lib/stores/page.svelte.js";
|
||||||
|
|
||||||
@@ -19,16 +19,16 @@
|
|||||||
await getCurrentWindow().hide();
|
await getCurrentWindow().hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragStart(e) {
|
function handleDragStart(e: PointerEvent) {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
if (e.target.closest("button")) return;
|
if ((e.target as HTMLElement | null)?.closest("button")) return;
|
||||||
try { e.currentTarget?.setPointerCapture?.(e.pointerId); } catch {}
|
try { (e.currentTarget as HTMLElement | null)?.setPointerCapture?.(e.pointerId); } catch {}
|
||||||
getCurrentWindow().startDragging();
|
getCurrentWindow().startDragging();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track maximise state
|
// Track maximise state
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
let unlisten;
|
let unlisten: (() => void) | undefined;
|
||||||
getCurrentWindow().onResized(async () => {
|
getCurrentWindow().onResized(async () => {
|
||||||
maximised = await getCurrentWindow().isMaximized();
|
maximised = await getCurrentWindow().isMaximized();
|
||||||
}).then((fn) => unlisten = fn);
|
}).then((fn) => unlisten = fn);
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="flex items-center select-none bg-sidebar border-b border-border-subtle
|
class="flex items-center select-none bg-sidebar border-b border-border-subtle
|
||||||
{compact ? 'h-[28px]' : 'h-[32px]'}"
|
{compact ? 'h-[28px]' : 'h-[32px]'}"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
|
import type { ToastSeverity } from "$lib/types/app";
|
||||||
// Renders the toast stack in the bottom-right of the viewport. Mount once
|
// Renders the toast stack in the bottom-right of the viewport. Mount once
|
||||||
// at the app root (in +layout.svelte). Reads from the global toasts store.
|
// at the app root (in +layout.svelte). Reads from the global toasts store.
|
||||||
//
|
//
|
||||||
@@ -10,7 +11,7 @@
|
|||||||
import { toasts } from '$lib/stores/toasts.svelte.js';
|
import { toasts } from '$lib/stores/toasts.svelte.js';
|
||||||
import { X } from 'lucide-svelte';
|
import { X } from 'lucide-svelte';
|
||||||
|
|
||||||
function severityClass(severity) {
|
function severityClass(severity: ToastSeverity) {
|
||||||
switch (severity) {
|
switch (severity) {
|
||||||
case 'success': return 'toast-success';
|
case 'success': return 'toast-success';
|
||||||
case 'info': return 'toast-info';
|
case 'info': return 'toast-info';
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from "svelte";
|
type SpinnerType = "dots" | "circle" | "pulse" | "stars" | "orbit";
|
||||||
|
|
||||||
let { type = "dots", speed = 100, classes = "" } = $props();
|
let { type = "dots", speed = 100, classes = "" } = $props<{
|
||||||
|
type?: SpinnerType;
|
||||||
|
speed?: number;
|
||||||
|
classes?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
const sequences = {
|
const sequences = {
|
||||||
dots: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
|
dots: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
|
||||||
@@ -12,17 +16,14 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
let frame = $state(0);
|
let frame = $state(0);
|
||||||
let interval = null;
|
let chars = $derived(sequences[type as SpinnerType] ?? sequences.dots);
|
||||||
let chars = sequences[type] || sequences.dots;
|
|
||||||
|
|
||||||
onMount(() => {
|
$effect(() => {
|
||||||
interval = setInterval(() => {
|
frame = 0;
|
||||||
|
const interval = setInterval(() => {
|
||||||
frame = (frame + 1) % chars.length;
|
frame = (frame + 1) % chars.length;
|
||||||
}, speed);
|
}, speed);
|
||||||
});
|
return () => clearInterval(interval);
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
if (interval) clearInterval(interval);
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
|
import type { ViewerSegment } from "$lib/types/app";
|
||||||
import { measureTextHeight } from "$lib/utils/textMeasure.js";
|
import { measureTextHeight } from "$lib/utils/textMeasure.js";
|
||||||
import { buildCumulativeOffsets, findVisibleRange } from "$lib/utils/virtualList.js";
|
import { buildCumulativeOffsets, findVisibleRange } from "$lib/utils/virtualList.js";
|
||||||
import { getPreferences } from "$lib/stores/preferences.svelte.js";
|
import { getPreferences } from "$lib/stores/preferences.svelte.js";
|
||||||
@@ -8,23 +9,39 @@
|
|||||||
let {
|
let {
|
||||||
segments = [],
|
segments = [],
|
||||||
activeSegmentIdx = -1,
|
activeSegmentIdx = -1,
|
||||||
matchingSegments = new Set(),
|
matchingSegments = new Set<number>(),
|
||||||
showTimestamps = true,
|
showTimestamps = true,
|
||||||
editingIdx = -1,
|
editingIdx = -1,
|
||||||
editingText = $bindable(""),
|
editingText = $bindable(""),
|
||||||
searchQuery = "",
|
searchQuery = "",
|
||||||
formatTime = (t) => String(t),
|
formatTime = (t: number) => String(t),
|
||||||
onSeekToTime = () => {},
|
onSeekToTime = (_time: number) => {},
|
||||||
onStartEditing = () => {},
|
onStartEditing = (_idx: number) => {},
|
||||||
onFinishEditing = () => {},
|
onFinishEditing = () => {},
|
||||||
onToggleStar = () => {},
|
onToggleStar = (_idx: number) => {},
|
||||||
onCopySegment = () => {},
|
onCopySegment = (_idx: number) => {},
|
||||||
onDeleteSegment = () => {},
|
onDeleteSegment = (_idx: number) => {},
|
||||||
highlightText = (t) => t,
|
highlightText = (t: string) => t,
|
||||||
} = $props();
|
} = $props<{
|
||||||
|
segments?: ViewerSegment[];
|
||||||
|
activeSegmentIdx?: number;
|
||||||
|
matchingSegments?: Set<number>;
|
||||||
|
showTimestamps?: boolean;
|
||||||
|
editingIdx?: number;
|
||||||
|
editingText?: string;
|
||||||
|
searchQuery?: string;
|
||||||
|
formatTime?: (time: number) => string;
|
||||||
|
onSeekToTime?: (time: number) => void;
|
||||||
|
onStartEditing?: (idx: number) => void;
|
||||||
|
onFinishEditing?: () => void;
|
||||||
|
onToggleStar?: (idx: number) => void;
|
||||||
|
onCopySegment?: (idx: number) => void;
|
||||||
|
onDeleteSegment?: (idx: number) => void;
|
||||||
|
highlightText?: (text: string) => string;
|
||||||
|
}>();
|
||||||
|
|
||||||
// Internal state
|
// Internal state
|
||||||
let containerEl = $state(null);
|
let containerEl = $state<HTMLDivElement | null>(null);
|
||||||
let containerHeight = $state(0);
|
let containerHeight = $state(0);
|
||||||
let containerWidth = $state(0);
|
let containerWidth = $state(0);
|
||||||
let scrollTop = $state(0);
|
let scrollTop = $state(0);
|
||||||
@@ -46,7 +63,7 @@
|
|||||||
const textWidth = containerWidth - 26 - timestampWidth - 24;
|
const textWidth = containerWidth - 26 - timestampWidth - 24;
|
||||||
if (textWidth <= 0) return segments.map(() => 40);
|
if (textWidth <= 0) return segments.map(() => 40);
|
||||||
|
|
||||||
return segments.map(seg => {
|
return segments.map((seg: ViewerSegment) => {
|
||||||
const text = seg.text?.trim() || "";
|
const text = seg.text?.trim() || "";
|
||||||
if (!text) return SEGMENT_PADDING + textLineHeight;
|
if (!text) return SEGMENT_PADDING + textLineHeight;
|
||||||
const { height } = measureTextHeight(text, textFont, textWidth, textLineHeight);
|
const { height } = measureTextHeight(text, textFont, textWidth, textLineHeight);
|
||||||
@@ -75,7 +92,7 @@
|
|||||||
|
|
||||||
// Visible segment slice with positioning
|
// Visible segment slice with positioning
|
||||||
let visibleItems = $derived.by(() => {
|
let visibleItems = $derived.by(() => {
|
||||||
const items = [];
|
const items: Array<{ seg: ViewerSegment; idx: number; top: number; height: number }> = [];
|
||||||
for (let i = visibleRange.start; i < visibleRange.end; i++) {
|
for (let i = visibleRange.start; i < visibleRange.end; i++) {
|
||||||
items.push({
|
items.push({
|
||||||
seg: segments[i],
|
seg: segments[i],
|
||||||
@@ -88,7 +105,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Scroll to a specific segment index (used for active segment tracking + search)
|
// Scroll to a specific segment index (used for active segment tracking + search)
|
||||||
export function scrollToSegment(idx) {
|
export function scrollToSegment(idx: number) {
|
||||||
if (!containerEl || idx < 0 || idx >= segments.length) return;
|
if (!containerEl || idx < 0 || idx >= segments.length) return;
|
||||||
const segTop = cumulativeOffsets[idx];
|
const segTop = cumulativeOffsets[idx];
|
||||||
const segHeight = segmentHeights[idx];
|
const segHeight = segmentHeights[idx];
|
||||||
@@ -96,8 +113,8 @@
|
|||||||
containerEl.scrollTop = segTop - viewCenter + segHeight / 2;
|
containerEl.scrollTop = segTop - viewCenter + segHeight / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onScroll(e) {
|
function onScroll(e: Event) {
|
||||||
scrollTop = e.target.scrollTop;
|
scrollTop = (e.currentTarget as HTMLDivElement).scrollTop;
|
||||||
// Track selection state for expanded buffer
|
// Track selection state for expanded buffer
|
||||||
hasSelection = !document.getSelection()?.isCollapsed;
|
hasSelection = !document.getSelection()?.isCollapsed;
|
||||||
}
|
}
|
||||||
@@ -105,7 +122,7 @@
|
|||||||
// ResizeObserver for container dimensions
|
// ResizeObserver for container dimensions
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!containerEl) return;
|
if (!containerEl) return;
|
||||||
const ro = new ResizeObserver(entries => {
|
const ro = new ResizeObserver((entries) => {
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
containerHeight = entry.contentRect.height;
|
containerHeight = entry.contentRect.height;
|
||||||
containerWidth = entry.contentRect.width;
|
containerWidth = entry.contentRect.width;
|
||||||
@@ -119,7 +136,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (activeSegmentIdx >= 0) {
|
if (activeSegmentIdx >= 0) {
|
||||||
// Find the index in our segments array that matches
|
// Find the index in our segments array that matches
|
||||||
const arrayIdx = segments.findIndex(s => s._idx === activeSegmentIdx);
|
const arrayIdx = segments.findIndex((s: ViewerSegment) => s._idx === activeSegmentIdx);
|
||||||
if (arrayIdx >= 0) {
|
if (arrayIdx >= 0) {
|
||||||
const segTop = cumulativeOffsets[arrayIdx];
|
const segTop = cumulativeOffsets[arrayIdx];
|
||||||
const segBottom = segTop + segmentHeights[arrayIdx];
|
const segBottom = segTop + segmentHeights[arrayIdx];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import { tasks, addTask, completeTask, uncompleteTask, deleteTask } from '$lib/stores/page.svelte.js';
|
import { tasks, addTask, completeTask, uncompleteTask, deleteTask } from '$lib/stores/page.svelte.js';
|
||||||
import MicroSteps from '$lib/components/MicroSteps.svelte';
|
import MicroSteps from '$lib/components/MicroSteps.svelte';
|
||||||
import { ChevronDown, ChevronRight } from 'lucide-svelte';
|
import { ChevronDown, ChevronRight } from 'lucide-svelte';
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
let newTaskText = $state('');
|
let newTaskText = $state('');
|
||||||
let showOverflow = $state(false);
|
let showOverflow = $state(false);
|
||||||
let expandedTaskIds = $state(new Set());
|
let expandedTaskIds = $state(new Set<string>());
|
||||||
|
|
||||||
// Exclude subtasks from WIP count — only top-level tasks count
|
// Exclude subtasks from WIP count — only top-level tasks count
|
||||||
let activeTasks = $derived(tasks.filter(t => !t.done && !t.parentTaskId));
|
let activeTasks = $derived(tasks.filter(t => !t.done && !t.parentTaskId));
|
||||||
@@ -22,11 +22,11 @@
|
|||||||
newTaskText = '';
|
newTaskText = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(e) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Enter') handleAdd();
|
if (e.key === 'Enter') handleAdd();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleExpand(taskId) {
|
function toggleExpand(taskId: string) {
|
||||||
const next = new Set(expandedTaskIds);
|
const next = new Set(expandedTaskIds);
|
||||||
if (next.has(taskId)) {
|
if (next.has(taskId)) {
|
||||||
next.delete(taskId);
|
next.delete(taskId);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import { getPreferences, updatePreferences } from '$lib/stores/preferences.svelte.js';
|
import { getPreferences, updatePreferences } from '$lib/stores/preferences.svelte.js';
|
||||||
|
|
||||||
const prefs = getPreferences();
|
const prefs = getPreferences();
|
||||||
@@ -8,9 +8,9 @@
|
|||||||
{ id: 'cave', label: 'Cave', description: 'Cool, focused', swatch: 'bg-[#141a1e]' },
|
{ id: 'cave', label: 'Cave', description: 'Cool, focused', swatch: 'bg-[#141a1e]' },
|
||||||
{ id: 'energy', label: 'Energy', description: 'Warm, active', swatch: 'bg-[#1c1815]' },
|
{ id: 'energy', label: 'Energy', description: 'Warm, active', swatch: 'bg-[#1c1815]' },
|
||||||
{ id: 'reset', label: 'Reset', description: 'Natural, calm', swatch: 'bg-[#161a16]' },
|
{ id: 'reset', label: 'Reset', description: 'Natural, calm', swatch: 'bg-[#161a16]' },
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
function selectZone(id) {
|
function selectZone(id: string) {
|
||||||
updatePreferences({ zone: id });
|
updatePreferences({ zone: id });
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
|
// @ts-nocheck
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import { Channel, invoke } from "@tauri-apps/api/core";
|
import { Channel, invoke } from "@tauri-apps/api/core";
|
||||||
import { page, settings, templates, profiles, addToHistory, addTask, tasks } from "$lib/stores/page.svelte.js";
|
import { page, settings, templates, profiles, addToHistory, addTask, tasks } from "$lib/stores/page.svelte.js";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
|
// @ts-nocheck
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
|
// @ts-nocheck
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { page, settings } from "$lib/stores/page.svelte.js";
|
import { page, settings } from "$lib/stores/page.svelte.js";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
|
// @ts-nocheck
|
||||||
import { onDestroy } from "svelte";
|
import { onDestroy } from "svelte";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { history, saveHistory, deleteFromHistory, renameHistoryEntry } from "$lib/stores/page.svelte.js";
|
import { history, saveHistory, deleteFromHistory, renameHistoryEntry } from "$lib/stores/page.svelte.js";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
|
// @ts-nocheck
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
|
import { tick } from "svelte";
|
||||||
|
import type { TaskBucket, TaskList } from "$lib/types/app";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import {
|
import {
|
||||||
tasks, addTask, completeTask, uncompleteTask, deleteTask, updateTask,
|
tasks, addTask, completeTask, uncompleteTask, deleteTask, updateTask,
|
||||||
@@ -21,17 +23,29 @@
|
|||||||
let newListName = $state("");
|
let newListName = $state("");
|
||||||
let sortMode = $state("date");
|
let sortMode = $state("date");
|
||||||
let showSortMenu = $state(false);
|
let showSortMenu = $state(false);
|
||||||
let contextMenuListId = $state(null);
|
let contextMenuListId = $state<string | null>(null);
|
||||||
let editingListId = $state(null);
|
let editingListId = $state<string | null>(null);
|
||||||
let editingName = $state("");
|
let editingName = $state("");
|
||||||
|
let editingInputEl = $state<HTMLInputElement | null>(null);
|
||||||
|
let newListInputEl = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const buckets = [
|
const buckets: Array<{ id: "all" | TaskBucket; label: string }> = [
|
||||||
{ id: "all", label: "All" },
|
{ id: "all", label: "All" },
|
||||||
{ id: "inbox", label: "Inbox" },
|
{ id: "inbox", label: "Inbox" },
|
||||||
{ id: "today", label: "Today" },
|
{ id: "today", label: "Today" },
|
||||||
{ id: "soon", label: "Soon" },
|
{ id: "soon", label: "Soon" },
|
||||||
{ id: "later", label: "Later" },
|
{ id: "later", label: "Later" },
|
||||||
];
|
];
|
||||||
|
const taskBucketOptions: TaskBucket[] = ["today", "soon", "later"];
|
||||||
|
const effortOptions = ["quick", "medium", "deep"];
|
||||||
|
|
||||||
|
function effortRank(effort: string): number {
|
||||||
|
return EFFORT_ORDER[effort as keyof typeof EFFORT_ORDER] || 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
function effortLabel(effort: string): string {
|
||||||
|
return EFFORT_LABELS[effort as keyof typeof EFFORT_LABELS] || effort;
|
||||||
|
}
|
||||||
|
|
||||||
let filteredTasks = $derived.by(() => {
|
let filteredTasks = $derived.by(() => {
|
||||||
let list = tasks.filter((t) => !t.done);
|
let list = tasks.filter((t) => !t.done);
|
||||||
@@ -50,9 +64,9 @@
|
|||||||
list = list.filter((t) => t.text.toLowerCase().includes(q));
|
list = list.filter((t) => t.text.toLowerCase().includes(q));
|
||||||
}
|
}
|
||||||
if (sortMode === "quick-first") {
|
if (sortMode === "quick-first") {
|
||||||
list.sort((a, b) => (EFFORT_ORDER[a.effort] || 4) - (EFFORT_ORDER[b.effort] || 4));
|
list.sort((a, b) => effortRank(a.effort) - effortRank(b.effort));
|
||||||
} else if (sortMode === "deep-first") {
|
} else if (sortMode === "deep-first") {
|
||||||
list.sort((a, b) => (EFFORT_ORDER[b.effort] || 4) - (EFFORT_ORDER[a.effort] || 4));
|
list.sort((a, b) => effortRank(b.effort) - effortRank(a.effort));
|
||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
});
|
});
|
||||||
@@ -77,7 +91,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
let bucketCounts = $derived.by(() => {
|
let bucketCounts = $derived.by(() => {
|
||||||
const counts = { all: 0, inbox: 0, today: 0, soon: 0, later: 0 };
|
const counts: Record<"all" | TaskBucket, number> = { all: 0, inbox: 0, today: 0, soon: 0, later: 0 };
|
||||||
for (const t of tasks) {
|
for (const t of tasks) {
|
||||||
if (t.done) continue;
|
if (t.done) continue;
|
||||||
counts.all++;
|
counts.all++;
|
||||||
@@ -86,7 +100,7 @@
|
|||||||
return counts;
|
return counts;
|
||||||
});
|
});
|
||||||
|
|
||||||
function countForList(listId) {
|
function countForList(listId: string) {
|
||||||
if (listId === "all") return tasks.filter((t) => !t.done).length;
|
if (listId === "all") return tasks.filter((t) => !t.done).length;
|
||||||
if (listId === "inbox") return tasks.filter((t) => !t.done && !t.listId).length;
|
if (listId === "inbox") return tasks.filter((t) => !t.done && !t.listId).length;
|
||||||
return tasks.filter((t) => !t.done && t.listId === listId).length;
|
return tasks.filter((t) => !t.done && t.listId === listId).length;
|
||||||
@@ -96,27 +110,27 @@
|
|||||||
activeListId === "all" ? "All Lists" : (taskLists.find((l) => l.id === activeListId)?.name || "Tasks")
|
activeListId === "all" ? "All Lists" : (taskLists.find((l) => l.id === activeListId)?.name || "Tasks")
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleQuickAdd(e) {
|
function handleQuickAdd(e: KeyboardEvent) {
|
||||||
if (e.key === "Enter" && quickInput.trim()) {
|
if (e.key === "Enter" && quickInput.trim()) {
|
||||||
const listId = (activeListId === "all" || activeListId === "inbox") ? null : activeListId;
|
const listId = (activeListId === "all" || activeListId === "inbox") ? null : activeListId;
|
||||||
addTask({
|
addTask({
|
||||||
text: quickInput.trim(),
|
text: quickInput.trim(),
|
||||||
bucket: activeBucket === "all" ? "inbox" : activeBucket,
|
bucket: (activeBucket === "all" ? "inbox" : activeBucket) as TaskBucket,
|
||||||
listId,
|
listId,
|
||||||
});
|
});
|
||||||
quickInput = "";
|
quickInput = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setBucket(taskId, bucket) {
|
function setBucket(taskId: string, bucket: TaskBucket) {
|
||||||
updateTask(taskId, { bucket });
|
updateTask(taskId, { bucket });
|
||||||
}
|
}
|
||||||
|
|
||||||
function setEffort(taskId, effort) {
|
function setEffort(taskId: string, effort: string) {
|
||||||
updateTask(taskId, { effort: effort === tasks.find((t) => t.id === taskId)?.effort ? "" : effort });
|
updateTask(taskId, { effort: effort === tasks.find((t) => t.id === taskId)?.effort ? "" : effort });
|
||||||
}
|
}
|
||||||
|
|
||||||
function getListName(listId) {
|
function getListName(listId: string | null) {
|
||||||
if (!listId) return null;
|
if (!listId) return null;
|
||||||
return taskLists.find((l) => l.id === listId)?.name || null;
|
return taskLists.find((l) => l.id === listId)?.name || null;
|
||||||
}
|
}
|
||||||
@@ -129,7 +143,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCreateList(e) {
|
function handleCreateList(e: KeyboardEvent) {
|
||||||
if (e.key === "Enter" && newListName.trim()) {
|
if (e.key === "Enter" && newListName.trim()) {
|
||||||
addTaskList(newListName.trim());
|
addTaskList(newListName.trim());
|
||||||
newListName = "";
|
newListName = "";
|
||||||
@@ -138,10 +152,13 @@
|
|||||||
if (e.key === "Escape") { showNewList = false; newListName = ""; }
|
if (e.key === "Escape") { showNewList = false; newListName = ""; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function startRenaming(list) {
|
async function startRenaming(list: TaskList) {
|
||||||
editingListId = list.id;
|
editingListId = list.id;
|
||||||
editingName = list.name;
|
editingName = list.name;
|
||||||
contextMenuListId = null;
|
contextMenuListId = null;
|
||||||
|
await tick();
|
||||||
|
editingInputEl?.focus();
|
||||||
|
editingInputEl?.select();
|
||||||
}
|
}
|
||||||
|
|
||||||
function finishRenaming() {
|
function finishRenaming() {
|
||||||
@@ -152,17 +169,26 @@
|
|||||||
editingName = "";
|
editingName = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDeleteList(id) {
|
function handleDeleteList(id: string) {
|
||||||
if (activeListId === id) activeListId = "all";
|
if (activeListId === id) activeListId = "all";
|
||||||
deleteTaskList(id);
|
deleteTaskList(id);
|
||||||
contextMenuListId = null;
|
contextMenuListId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleContextMenu(e, listId) {
|
function toggleContextMenu(e: MouseEvent, listId: string) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
contextMenuListId = contextMenuListId === listId ? null : listId;
|
contextMenuListId = contextMenuListId === listId ? null : listId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (showNewList) {
|
||||||
|
tick().then(() => {
|
||||||
|
newListInputEl?.focus();
|
||||||
|
newListInputEl?.select();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col h-full animate-fade-in">
|
<div class="flex flex-col h-full animate-fade-in">
|
||||||
@@ -297,13 +323,13 @@
|
|||||||
{#if editingListId === list.id && !sidebarCollapsed}
|
{#if editingListId === list.id && !sidebarCollapsed}
|
||||||
<div class="px-1.5 py-0.5">
|
<div class="px-1.5 py-0.5">
|
||||||
<input
|
<input
|
||||||
|
bind:this={editingInputEl}
|
||||||
type="text"
|
type="text"
|
||||||
class="w-full bg-bg-input border border-accent rounded px-2 py-1 text-[10px] text-text focus:outline-none"
|
class="w-full bg-bg-input border border-accent rounded px-2 py-1 text-[10px] text-text focus:outline-none"
|
||||||
bind:value={editingName}
|
bind:value={editingName}
|
||||||
onkeydown={(e) => { if (e.key === "Enter") finishRenaming(); if (e.key === "Escape") { editingListId = null; } }}
|
onkeydown={(e) => { if (e.key === "Enter") finishRenaming(); if (e.key === "Escape") { editingListId = null; } }}
|
||||||
onblur={finishRenaming}
|
onblur={finishRenaming}
|
||||||
data-no-transition
|
data-no-transition
|
||||||
autofocus
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -356,6 +382,7 @@
|
|||||||
<div class="px-2 py-2 border-t border-border-subtle">
|
<div class="px-2 py-2 border-t border-border-subtle">
|
||||||
{#if showNewList}
|
{#if showNewList}
|
||||||
<input
|
<input
|
||||||
|
bind:this={newListInputEl}
|
||||||
type="text"
|
type="text"
|
||||||
class="w-full bg-bg-input border border-border rounded px-2 py-1 text-[10px] text-text
|
class="w-full bg-bg-input border border-border rounded px-2 py-1 text-[10px] text-text
|
||||||
placeholder:text-text-tertiary focus:outline-none focus:border-accent"
|
placeholder:text-text-tertiary focus:outline-none focus:border-accent"
|
||||||
@@ -364,7 +391,6 @@
|
|||||||
onkeydown={handleCreateList}
|
onkeydown={handleCreateList}
|
||||||
onblur={() => { showNewList = false; newListName = ""; }}
|
onblur={() => { showNewList = false; newListName = ""; }}
|
||||||
data-no-transition
|
data-no-transition
|
||||||
autofocus
|
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<button
|
||||||
@@ -403,7 +429,7 @@
|
|||||||
<p class="text-[13px] text-text leading-relaxed">{task.text}</p>
|
<p class="text-[13px] text-text leading-relaxed">{task.text}</p>
|
||||||
<div class="flex items-center gap-2 mt-1.5">
|
<div class="flex items-center gap-2 mt-1.5">
|
||||||
<!-- Bucket pills -->
|
<!-- Bucket pills -->
|
||||||
{#each ["today", "soon", "later"] as b}
|
{#each taskBucketOptions as b}
|
||||||
<button
|
<button
|
||||||
class="text-[10px] px-2 py-0.5 rounded-full border
|
class="text-[10px] px-2 py-0.5 rounded-full border
|
||||||
{task.bucket === b
|
{task.bucket === b
|
||||||
@@ -415,7 +441,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
<span class="text-border-subtle">·</span>
|
<span class="text-border-subtle">·</span>
|
||||||
<!-- Effort pills -->
|
<!-- Effort pills -->
|
||||||
{#each ["quick", "medium", "deep"] as e}
|
{#each effortOptions as e}
|
||||||
<button
|
<button
|
||||||
class="text-[10px] px-2 py-0.5 rounded-full border
|
class="text-[10px] px-2 py-0.5 rounded-full border
|
||||||
{task.effort === e
|
{task.effort === e
|
||||||
@@ -423,7 +449,7 @@
|
|||||||
: 'text-text-tertiary border-transparent hover:border-border-subtle hover:text-text-secondary'}"
|
: 'text-text-tertiary border-transparent hover:border-border-subtle hover:text-text-secondary'}"
|
||||||
style="transition-duration: var(--duration-ui)"
|
style="transition-duration: var(--duration-ui)"
|
||||||
onclick={() => setEffort(task.id, e)}
|
onclick={() => setEffort(task.id, e)}
|
||||||
>{EFFORT_LABELS[e]}</button>
|
>{effortLabel(e)}</button>
|
||||||
{/each}
|
{/each}
|
||||||
<!-- Timestamp -->
|
<!-- Timestamp -->
|
||||||
{#if task.createdAt}
|
{#if task.createdAt}
|
||||||
@@ -457,7 +483,7 @@
|
|||||||
class="flex items-center gap-2 text-[12px] text-text-tertiary hover:text-text-secondary mb-2"
|
class="flex items-center gap-2 text-[12px] text-text-tertiary hover:text-text-secondary mb-2"
|
||||||
onclick={() => showCompleted = !showCompleted}
|
onclick={() => showCompleted = !showCompleted}
|
||||||
>
|
>
|
||||||
<ChevronRight size={12} class="{showCompleted ? 'rotate-90' : ''}" aria-hidden="true"
|
<ChevronRight size={12} class={showCompleted ? "rotate-90" : ""} aria-hidden="true"
|
||||||
style="transition: transform var(--duration-ui)" />
|
style="transition: transform var(--duration-ui)" />
|
||||||
Completed ({completedTasks.length})
|
Completed ({completedTasks.length})
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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
|
// src/lib/stores/preferences.svelte.js
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { emit } from '@tauri-apps/api/event';
|
import { emit } from "@tauri-apps/api/event";
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { toasts } from './toasts.svelte.js';
|
import type { AccessibilityPreferences, Preferences } from "$lib/types/app";
|
||||||
|
import { errorMessage } from "$lib/utils/errors.js";
|
||||||
|
import { toasts } from "./toasts.svelte.ts";
|
||||||
|
|
||||||
export const PREFERENCES_CHANGED_EVENT = 'kon:preferences-changed';
|
export const PREFERENCES_CHANGED_EVENT = "kon:preferences-changed";
|
||||||
|
|
||||||
|
type FontFamilies = Record<AccessibilityPreferences["fontFamily"], string>;
|
||||||
|
|
||||||
function currentWindowLabel() {
|
function currentWindowLabel() {
|
||||||
try {
|
try {
|
||||||
@@ -14,7 +18,7 @@ function currentWindowLabel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function broadcastPreferences(prefs) {
|
function broadcastPreferences(prefs: Preferences) {
|
||||||
const source = currentWindowLabel();
|
const source = currentWindowLabel();
|
||||||
if (source === null) return;
|
if (source === null) return;
|
||||||
// Fire-and-forget — cross-window sync must never block the local apply path.
|
// Fire-and-forget — cross-window sync must never block the local apply path.
|
||||||
@@ -22,53 +26,53 @@ function broadcastPreferences(prefs) {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULTS = {
|
const DEFAULTS: Preferences = {
|
||||||
theme: 'dark',
|
theme: "dark",
|
||||||
zone: 'default',
|
zone: "default",
|
||||||
accessibility: {
|
accessibility: {
|
||||||
fontFamily: 'lexend',
|
fontFamily: "lexend",
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
letterSpacing: 0,
|
letterSpacing: 0,
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
transcriptSize: 16,
|
transcriptSize: 16,
|
||||||
bionicReading: false,
|
bionicReading: false,
|
||||||
reduceMotion: 'system'
|
reduceMotion: "system",
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const FONT_FAMILIES = {
|
const FONT_FAMILIES: FontFamilies = {
|
||||||
lexend: "'Lexend', system-ui, sans-serif",
|
lexend: "'Lexend', system-ui, sans-serif",
|
||||||
atkinson: "'Atkinson Hyperlegible Next', system-ui, sans-serif",
|
atkinson: "'Atkinson Hyperlegible Next', system-ui, sans-serif",
|
||||||
opendyslexic: "'OpenDyslexic', system-ui, sans-serif"
|
opendyslexic: "'OpenDyslexic', system-ui, sans-serif",
|
||||||
};
|
};
|
||||||
|
|
||||||
function readFromDOM() {
|
function readFromDOM(): Preferences {
|
||||||
const el = document.documentElement;
|
const el = document.documentElement;
|
||||||
return {
|
return {
|
||||||
theme: el.dataset.theme || DEFAULTS.theme,
|
theme: (el.dataset.theme || DEFAULTS.theme) as Preferences["theme"],
|
||||||
zone: el.dataset.zone || DEFAULTS.zone,
|
zone: el.dataset.zone || DEFAULTS.zone,
|
||||||
accessibility: {
|
accessibility: {
|
||||||
fontFamily: el.dataset.fontFamily || DEFAULTS.accessibility.fontFamily,
|
fontFamily: (el.dataset.fontFamily || DEFAULTS.accessibility.fontFamily) as AccessibilityPreferences["fontFamily"],
|
||||||
fontSize: parseFloat(el.style.getPropertyValue('--font-size-body')) || DEFAULTS.accessibility.fontSize,
|
fontSize: parseFloat(el.style.getPropertyValue('--font-size-body')) || DEFAULTS.accessibility.fontSize,
|
||||||
letterSpacing: parseFloat(el.style.getPropertyValue('--letter-spacing-body')) || DEFAULTS.accessibility.letterSpacing,
|
letterSpacing: parseFloat(el.style.getPropertyValue('--letter-spacing-body')) || DEFAULTS.accessibility.letterSpacing,
|
||||||
lineHeight: parseFloat(el.style.getPropertyValue('--line-height-body')) || DEFAULTS.accessibility.lineHeight,
|
lineHeight: parseFloat(el.style.getPropertyValue('--line-height-body')) || DEFAULTS.accessibility.lineHeight,
|
||||||
transcriptSize: DEFAULTS.accessibility.transcriptSize,
|
transcriptSize: DEFAULTS.accessibility.transcriptSize,
|
||||||
bionicReading: el.dataset.bionicReading === 'true',
|
bionicReading: el.dataset.bionicReading === "true",
|
||||||
reduceMotion: el.dataset.reduceMotion || DEFAULTS.accessibility.reduceMotion
|
reduceMotion: (el.dataset.reduceMotion || DEFAULTS.accessibility.reduceMotion) as AccessibilityPreferences["reduceMotion"],
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyToDOM(prefs) {
|
function applyToDOM(prefs: Preferences) {
|
||||||
const el = document.documentElement;
|
const el = document.documentElement;
|
||||||
|
|
||||||
// Theme — resolve 'system' to actual value
|
// Theme — resolve 'system' to actual value
|
||||||
el.dataset.theme = prefs.theme === 'system'
|
el.dataset.theme = prefs.theme === "system"
|
||||||
? (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark')
|
? (window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark")
|
||||||
: prefs.theme;
|
: prefs.theme;
|
||||||
|
|
||||||
// Zone
|
// Zone
|
||||||
if (prefs.zone === 'default') {
|
if (prefs.zone === "default") {
|
||||||
delete el.dataset.zone;
|
delete el.dataset.zone;
|
||||||
} else {
|
} else {
|
||||||
el.dataset.zone = prefs.zone;
|
el.dataset.zone = prefs.zone;
|
||||||
@@ -85,32 +89,31 @@ function applyToDOM(prefs) {
|
|||||||
el.dataset.fontFamily = a.fontFamily;
|
el.dataset.fontFamily = a.fontFamily;
|
||||||
|
|
||||||
// Reduce motion — three-value resolution
|
// Reduce motion — three-value resolution
|
||||||
const motionReduced = a.reduceMotion === 'on'
|
const motionReduced = a.reduceMotion === "on"
|
||||||
|| (a.reduceMotion === 'system' && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
|| (a.reduceMotion === "system" && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
|
||||||
if (motionReduced) {
|
if (motionReduced) {
|
||||||
el.dataset.reduceMotion = 'true';
|
el.dataset.reduceMotion = "true";
|
||||||
} else {
|
} else {
|
||||||
delete el.dataset.reduceMotion;
|
delete el.dataset.reduceMotion;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let saveTimeout = null;
|
let saveTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
// Show the failure toast at most once per process so a stuck SQLite path
|
// Show the failure toast at most once per process so a stuck SQLite path
|
||||||
// doesn't spam the user every time they nudge a slider.
|
// doesn't spam the user every time they nudge a slider.
|
||||||
let saveFailureToastShown = false;
|
let saveFailureToastShown = false;
|
||||||
function persistToSQLite(prefs) {
|
function persistToSQLite(prefs: Preferences) {
|
||||||
clearTimeout(saveTimeout);
|
clearTimeout(saveTimeout);
|
||||||
saveTimeout = setTimeout(async () => {
|
saveTimeout = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
await invoke('save_preferences', { preferences: JSON.stringify(prefs) });
|
await invoke("save_preferences", { preferences: JSON.stringify(prefs) });
|
||||||
saveFailureToastShown = false;
|
saveFailureToastShown = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to save preferences:', e);
|
console.error("Failed to save preferences:", e);
|
||||||
if (!saveFailureToastShown) {
|
if (!saveFailureToastShown) {
|
||||||
const msg = typeof e === 'string' ? e : (e?.message ?? String(e));
|
|
||||||
toasts.warn(
|
toasts.warn(
|
||||||
'Could not save preferences',
|
"Could not save preferences",
|
||||||
`${msg}. Your changes still apply for this session.`,
|
`${errorMessage(e)}. Your changes still apply for this session.`,
|
||||||
);
|
);
|
||||||
saveFailureToastShown = true;
|
saveFailureToastShown = true;
|
||||||
}
|
}
|
||||||
@@ -133,14 +136,14 @@ export function getPreferences() {
|
|||||||
return preferences;
|
return preferences;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updatePreferences(updates) {
|
export function updatePreferences(updates: Partial<Preferences>) {
|
||||||
Object.assign(preferences, updates);
|
Object.assign(preferences, updates);
|
||||||
applyToDOM(preferences);
|
applyToDOM(preferences);
|
||||||
persistToSQLite(preferences);
|
persistToSQLite(preferences);
|
||||||
broadcastPreferences(preferences);
|
broadcastPreferences(preferences);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateAccessibility(updates) {
|
export function updateAccessibility(updates: Partial<AccessibilityPreferences>) {
|
||||||
Object.assign(preferences.accessibility, updates);
|
Object.assign(preferences.accessibility, updates);
|
||||||
applyToDOM(preferences);
|
applyToDOM(preferences);
|
||||||
persistToSQLite(preferences);
|
persistToSQLite(preferences);
|
||||||
@@ -149,8 +152,8 @@ export function updateAccessibility(updates) {
|
|||||||
|
|
||||||
// Apply preferences received from another Tauri window. Mutates local state
|
// Apply preferences received from another Tauri window. Mutates local state
|
||||||
// and DOM only — never persists or re-broadcasts, so there is no echo loop.
|
// and DOM only — never persists or re-broadcasts, so there is no echo loop.
|
||||||
export function applyExternalPreferences(prefs) {
|
export function applyExternalPreferences(prefs: Partial<Preferences> | null | undefined) {
|
||||||
if (!prefs || typeof prefs !== 'object') return;
|
if (!prefs || typeof prefs !== "object") return;
|
||||||
Object.assign(preferences, prefs);
|
Object.assign(preferences, prefs);
|
||||||
if (prefs.accessibility) {
|
if (prefs.accessibility) {
|
||||||
Object.assign(preferences.accessibility, prefs.accessibility);
|
Object.assign(preferences.accessibility, prefs.accessibility);
|
||||||
@@ -159,11 +162,11 @@ export function applyExternalPreferences(prefs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Re-resolve when OS preferences change
|
// Re-resolve when OS preferences change
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== "undefined") {
|
||||||
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => {
|
window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => {
|
||||||
if (preferences.theme === 'system') applyToDOM(preferences);
|
if (preferences.theme === "system") applyToDOM(preferences);
|
||||||
});
|
});
|
||||||
window.matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', () => {
|
window.matchMedia("(prefers-reduced-motion: reduce)").addEventListener("change", () => {
|
||||||
if (preferences.accessibility.reduceMotion === 'system') applyToDOM(preferences);
|
if (preferences.accessibility.reduceMotion === "system") applyToDOM(preferences);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import type { ToastItem, ToastSeverity } from "$lib/types/app";
|
||||||
|
import { errorMessage } from "$lib/utils/errors.js";
|
||||||
|
|
||||||
// Minimal toast store. Roll-our-own (no svelte-french-toast etc) so Kon
|
// Minimal toast store. Roll-our-own (no svelte-french-toast etc) so Kon
|
||||||
// stays lean. Toasts auto-dismiss after `duration` ms unless duration is 0
|
// stays lean. Toasts auto-dismiss after `duration` ms unless duration is 0
|
||||||
// (sticky) or unless the user clicks the close button.
|
// (sticky) or unless the user clicks the close button.
|
||||||
@@ -15,22 +18,22 @@
|
|||||||
|
|
||||||
let nextId = 1;
|
let nextId = 1;
|
||||||
|
|
||||||
function defaultDuration(severity) {
|
function defaultDuration(severity: ToastSeverity): number {
|
||||||
switch (severity) {
|
switch (severity) {
|
||||||
case 'success': return 3000;
|
case "success": return 3000;
|
||||||
case 'info': return 4000;
|
case "info": return 4000;
|
||||||
case 'warn': return 6000;
|
case "warn": return 6000;
|
||||||
case 'error': return 0; // sticky
|
case "error": return 0; // sticky
|
||||||
default: return 4000;
|
default: return 4000;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createToastsStore() {
|
function createToastsStore() {
|
||||||
// $state requires a class field or top-level let in Svelte 5; we expose
|
// $state requires a class field or top-level let in Svelte 5; we expose
|
||||||
// `items` via a getter on the singleton.
|
// `items` via a getter on the singleton.
|
||||||
let items = $state([]);
|
let items = $state<ToastItem[]>([]);
|
||||||
|
|
||||||
function show(severity, title, body) {
|
function show(severity: ToastSeverity, title: string, body?: string) {
|
||||||
const id = nextId++;
|
const id = nextId++;
|
||||||
const duration = defaultDuration(severity);
|
const duration = defaultDuration(severity);
|
||||||
const toast = { id, severity, title, body: body ?? '', duration };
|
const toast = { id, severity, title, body: body ?? '', duration };
|
||||||
@@ -41,7 +44,7 @@ function createToastsStore() {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dismiss(id) {
|
function dismiss(id: number) {
|
||||||
const ix = items.findIndex(t => t.id === id);
|
const ix = items.findIndex(t => t.id === id);
|
||||||
if (ix >= 0) items.splice(ix, 1);
|
if (ix >= 0) items.splice(ix, 1);
|
||||||
}
|
}
|
||||||
@@ -52,10 +55,10 @@ function createToastsStore() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
get items() { return items; },
|
get items() { return items; },
|
||||||
info: (title, body) => show('info', title, body),
|
info: (title: string, body?: string) => show("info", title, body),
|
||||||
success: (title, body) => show('success', title, body),
|
success: (title: string, body?: string) => show("success", title, body),
|
||||||
warn: (title, body) => show('warn', title, body),
|
warn: (title: string, body?: string) => show("warn", title, body),
|
||||||
error: (title, body) => show('error', title, body),
|
error: (title: string, body?: string) => show("error", title, body),
|
||||||
dismiss,
|
dismiss,
|
||||||
dismissAll,
|
dismissAll,
|
||||||
};
|
};
|
||||||
@@ -72,12 +75,16 @@ export const toasts = createToastsStore();
|
|||||||
// const result = await invokeWithToast('start_native_capture', { deviceName });
|
// const result = await invokeWithToast('start_native_capture', { deviceName });
|
||||||
//
|
//
|
||||||
// Optional `errorTitle` overrides the default ("Action failed").
|
// Optional `errorTitle` overrides the default ("Action failed").
|
||||||
export async function invokeWithToast(invokeFn, command, args, errorTitle) {
|
export async function invokeWithToast<TArgs, TResult>(
|
||||||
|
invokeFn: (command: string, args?: TArgs) => Promise<TResult>,
|
||||||
|
command: string,
|
||||||
|
args?: TArgs,
|
||||||
|
errorTitle?: string,
|
||||||
|
): Promise<TResult> {
|
||||||
try {
|
try {
|
||||||
return await invokeFn(command, args);
|
return await invokeFn(command, args);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = typeof err === 'string' ? err : (err?.message ?? String(err));
|
toasts.error(errorTitle ?? "Action failed", errorMessage(err));
|
||||||
toasts.error(errorTitle ?? 'Action failed', msg);
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
243
src/lib/types/app.ts
Normal file
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 { pad, formatTimeSRT, formatTimeVTT } from "./time.js";
|
||||||
|
import type { Segment } from "$lib/types/app";
|
||||||
|
|
||||||
|
type ExportFormat = "txt" | "md" | "csv" | "html" | "srt" | "vtt";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export transcript in various formats.
|
* Export transcript in various formats.
|
||||||
@@ -7,7 +10,11 @@ import { pad, formatTimeSRT, formatTimeVTT } from "./time.js";
|
|||||||
* @param {string} format - "txt" | "md" | "csv" | "html" | "srt" | "vtt"
|
* @param {string} format - "txt" | "md" | "csv" | "html" | "srt" | "vtt"
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function exportTranscript(text, segments, format) {
|
export function exportTranscript(
|
||||||
|
text: string,
|
||||||
|
segments: Segment[],
|
||||||
|
format: ExportFormat,
|
||||||
|
): string {
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case "srt":
|
case "srt":
|
||||||
return toSRT(segments);
|
return toSRT(segments);
|
||||||
@@ -24,7 +31,7 @@ export function exportTranscript(text, segments, format) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toSRT(segments) {
|
function toSRT(segments: Segment[]): string {
|
||||||
if (!segments || segments.length === 0) return "";
|
if (!segments || segments.length === 0) return "";
|
||||||
return segments
|
return segments
|
||||||
.map((seg, i) => {
|
.map((seg, i) => {
|
||||||
@@ -33,7 +40,7 @@ function toSRT(segments) {
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function toVTT(segments) {
|
function toVTT(segments: Segment[]): string {
|
||||||
if (!segments || segments.length === 0) return "";
|
if (!segments || segments.length === 0) return "";
|
||||||
let out = "WEBVTT\n\n";
|
let out = "WEBVTT\n\n";
|
||||||
out += segments
|
out += segments
|
||||||
@@ -44,7 +51,7 @@ function toVTT(segments) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toCSV(segments) {
|
function toCSV(segments: Segment[]): string {
|
||||||
if (!segments || segments.length === 0) return "Start,End,Text\n";
|
if (!segments || segments.length === 0) return "Start,End,Text\n";
|
||||||
let csv = "Start,End,Text\n";
|
let csv = "Start,End,Text\n";
|
||||||
for (const seg of segments) {
|
for (const seg of segments) {
|
||||||
@@ -54,11 +61,11 @@ function toCSV(segments) {
|
|||||||
return csv;
|
return csv;
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str: string): string {
|
||||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
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>
|
let html = `<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -95,7 +102,7 @@ function toHTML(text, segments) {
|
|||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toMarkdown(text, segments) {
|
function toMarkdown(text: string, segments: Segment[]): string {
|
||||||
let md = `# Transcription\n\n`;
|
let md = `# Transcription\n\n`;
|
||||||
md += `**Date:** ${new Date().toLocaleDateString("en-GB")}\n`;
|
md += `**Date:** ${new Date().toLocaleDateString("en-GB")}\n`;
|
||||||
if (segments && segments.length > 0) {
|
if (segments && segments.length > 0) {
|
||||||
@@ -25,12 +25,12 @@ const WORD_BUCKETS = [
|
|||||||
{ max: Infinity, tag: "words:very-long" },
|
{ max: Infinity, tag: "words:very-long" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function durationTag(seconds) {
|
function durationTag(seconds: number): string | null {
|
||||||
if (!Number.isFinite(seconds) || seconds <= 0) return null;
|
if (!Number.isFinite(seconds) || seconds <= 0) return null;
|
||||||
return DURATION_BUCKETS.find((b) => seconds < b.max)?.tag ?? null;
|
return DURATION_BUCKETS.find((b) => seconds < b.max)?.tag ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function wordCountTag(text) {
|
function wordCountTag(text: string): string | null {
|
||||||
if (!text || typeof text !== "string") return null;
|
if (!text || typeof text !== "string") return null;
|
||||||
const count = text.trim().split(/\s+/).filter(Boolean).length;
|
const count = text.trim().split(/\s+/).filter(Boolean).length;
|
||||||
return WORD_BUCKETS.find((b) => count < b.max)?.tag ?? null;
|
return WORD_BUCKETS.find((b) => count < b.max)?.tag ?? null;
|
||||||
@@ -39,7 +39,7 @@ function wordCountTag(text) {
|
|||||||
// Resolve time-of-day bucket from an ISO date or a legacy string like
|
// Resolve time-of-day bucket from an ISO date or a legacy string like
|
||||||
// "19/04/2026, 11:37:23". Thresholds are fixed and local to the user's
|
// "19/04/2026, 11:37:23". Thresholds are fixed and local to the user's
|
||||||
// machine — hour 6-11 morning, 12-17 afternoon, 18-21 evening, else night.
|
// machine — hour 6-11 morning, 12-17 afternoon, 18-21 evening, else night.
|
||||||
function timeOfDayTag(dateStr) {
|
function timeOfDayTag(dateStr: string): string | null {
|
||||||
if (!dateStr) return null;
|
if (!dateStr) return null;
|
||||||
let ts = Date.parse(dateStr);
|
let ts = Date.parse(dateStr);
|
||||||
if (Number.isNaN(ts)) {
|
if (Number.isNaN(ts)) {
|
||||||
@@ -62,7 +62,7 @@ function timeOfDayTag(dateStr) {
|
|||||||
return "time:night";
|
return "time:night";
|
||||||
}
|
}
|
||||||
|
|
||||||
function sourceTag(source) {
|
function sourceTag(source: string): string | null {
|
||||||
if (!source) return null;
|
if (!source) return null;
|
||||||
const s = String(source).toLowerCase();
|
const s = String(source).toLowerCase();
|
||||||
if (s.includes("file")) return "source:file";
|
if (s.includes("file")) return "source:file";
|
||||||
@@ -76,17 +76,17 @@ function sourceTag(source) {
|
|||||||
// information and add cognitive load without improving retrieval. The
|
// information and add cognitive load without improving retrieval. The
|
||||||
// function is kept as a hook for one future AI-derived content tag
|
// function is kept as a hook for one future AI-derived content tag
|
||||||
// (`topic:*`) once kon-llm wires up real llama-cpp-2 in Phase 3.
|
// (`topic:*`) once kon-llm wires up real llama-cpp-2 in Phase 3.
|
||||||
export function deriveAutoTags(_item) {
|
export function deriveAutoTags(_item: TranscriptEntry): string[] {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normaliseTag(raw) {
|
export function normaliseTag(raw: string): string {
|
||||||
return String(raw || "").trim().toLowerCase().replace(/\s+/g, "-");
|
return String(raw || "").trim().toLowerCase().replace(/\s+/g, "-");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the flat frontmatter object that represents a transcript's metadata.
|
// Build the flat frontmatter object that represents a transcript's metadata.
|
||||||
// Shown in the expanded History row and serialised when exporting to .md.
|
// Shown in the expanded History row and serialised when exporting to .md.
|
||||||
export function buildFrontmatter(item) {
|
export function buildFrontmatter(item: TranscriptEntry | null) {
|
||||||
if (!item) return {};
|
if (!item) return {};
|
||||||
const auto = deriveAutoTags(item);
|
const auto = deriveAutoTags(item);
|
||||||
const manual = Array.isArray(item.manualTags) ? item.manualTags : [];
|
const manual = Array.isArray(item.manualTags) ? item.manualTags : [];
|
||||||
@@ -105,7 +105,7 @@ export function buildFrontmatter(item) {
|
|||||||
|
|
||||||
// Escape a YAML scalar. Keeps things simple — quote if it contains any
|
// Escape a YAML scalar. Keeps things simple — quote if it contains any
|
||||||
// character that would otherwise need escaping in plain scalars.
|
// character that would otherwise need escaping in plain scalars.
|
||||||
function yamlScalar(value) {
|
function yamlScalar(value: unknown): string {
|
||||||
if (value === null || value === undefined) return "null";
|
if (value === null || value === undefined) return "null";
|
||||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||||
const s = String(value);
|
const s = String(value);
|
||||||
@@ -114,7 +114,7 @@ function yamlScalar(value) {
|
|||||||
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serialiseFrontmatter(fm) {
|
export function serialiseFrontmatter(fm: Record<string, unknown>): string {
|
||||||
const lines = ["---"];
|
const lines = ["---"];
|
||||||
for (const [key, value] of Object.entries(fm)) {
|
for (const [key, value] of Object.entries(fm)) {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
@@ -133,10 +133,11 @@ export function serialiseFrontmatter(fm) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Produce an Obsidian-flavoured markdown document for a transcript.
|
// Produce an Obsidian-flavoured markdown document for a transcript.
|
||||||
export function buildMarkdown(item) {
|
export function buildMarkdown(item: TranscriptEntry): string {
|
||||||
const fm = buildFrontmatter(item);
|
const fm = buildFrontmatter(item);
|
||||||
const header = serialiseFrontmatter(fm);
|
const header = serialiseFrontmatter(fm);
|
||||||
const title = fm.title || "Transcript";
|
const title = fm.title || "Transcript";
|
||||||
const body = item?.text || "";
|
const body = item?.text || "";
|
||||||
return `${header}\n\n# ${title}\n\n${body}\n`;
|
return `${header}\n\n# ${title}\n\n${body}\n`;
|
||||||
}
|
}
|
||||||
|
import type { TranscriptEntry } from "$lib/types/app";
|
||||||
@@ -18,7 +18,17 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { hasTauriRuntime } from './runtime.js';
|
import { hasTauriRuntime } from './runtime.js';
|
||||||
|
|
||||||
let cached = null;
|
interface OsInfo {
|
||||||
|
os: string;
|
||||||
|
arch: string;
|
||||||
|
family: string;
|
||||||
|
usesCmd: boolean;
|
||||||
|
isWayland: boolean;
|
||||||
|
customHotkeyBackend: boolean;
|
||||||
|
primaryModifierLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cached: OsInfo | null = null;
|
||||||
|
|
||||||
const FALLBACK_BROWSER_INFO = {
|
const FALLBACK_BROWSER_INFO = {
|
||||||
os: detectBrowserOs(),
|
os: detectBrowserOs(),
|
||||||
@@ -52,14 +62,14 @@ function detectBrowserFamily() {
|
|||||||
/** Fetch OS info from the Tauri backend. Idempotent — call multiple times,
|
/** Fetch OS info from the Tauri backend. Idempotent — call multiple times,
|
||||||
* only the first does the round-trip. Browser-preview mode returns the
|
* only the first does the round-trip. Browser-preview mode returns the
|
||||||
* navigator-detected fallback. */
|
* navigator-detected fallback. */
|
||||||
export async function loadOsInfo() {
|
export async function loadOsInfo(): Promise<OsInfo> {
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
if (!hasTauriRuntime()) {
|
if (!hasTauriRuntime()) {
|
||||||
cached = FALLBACK_BROWSER_INFO;
|
cached = FALLBACK_BROWSER_INFO;
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
cached = await invoke('get_os_info');
|
cached = await invoke<OsInfo>("get_os_info");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('loadOsInfo: get_os_info failed, using browser fallback', err);
|
console.warn('loadOsInfo: get_os_info failed, using browser fallback', err);
|
||||||
cached = FALLBACK_BROWSER_INFO;
|
cached = FALLBACK_BROWSER_INFO;
|
||||||
@@ -70,31 +80,31 @@ export async function loadOsInfo() {
|
|||||||
/** Synchronous accessor. Returns null if loadOsInfo has not been awaited
|
/** Synchronous accessor. Returns null if loadOsInfo has not been awaited
|
||||||
* yet. Components that need the value at first render should await
|
* yet. Components that need the value at first render should await
|
||||||
* loadOsInfo() in onMount before reading. */
|
* loadOsInfo() in onMount before reading. */
|
||||||
export function osInfo() {
|
export function osInfo(): OsInfo | null {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isMac() {
|
export function isMac(): boolean {
|
||||||
return cached?.os === 'macos';
|
return cached?.os === 'macos';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isWindows() {
|
export function isWindows(): boolean {
|
||||||
return cached?.os === 'windows';
|
return cached?.os === 'windows';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLinux() {
|
export function isLinux(): boolean {
|
||||||
return cached?.os === 'linux';
|
return cached?.os === 'linux';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Localised label for the primary keyboard modifier. "Cmd" on macOS,
|
/** Localised label for the primary keyboard modifier. "Cmd" on macOS,
|
||||||
* "Ctrl" everywhere else. Use in hotkey display strings:
|
* "Ctrl" everywhere else. Use in hotkey display strings:
|
||||||
* `${modKeyLabel()}+Shift+R` */
|
* `${modKeyLabel()}+Shift+R` */
|
||||||
export function modKeyLabel() {
|
export function modKeyLabel(): string {
|
||||||
return cached?.primaryModifierLabel ?? 'Ctrl';
|
return cached?.primaryModifierLabel ?? 'Ctrl';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True if the current Linux session is Wayland. False on Windows/macOS.
|
/** True if the current Linux session is Wayland. False on Windows/macOS.
|
||||||
* Useful for Settings → Audio "PipeWire detected" status display. */
|
* Useful for Settings → Audio "PipeWire detected" status display. */
|
||||||
export function isWayland() {
|
export function isWayland(): boolean {
|
||||||
return !!cached?.isWayland;
|
return !!cached?.isWayland;
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
// behaviour for browser-preview mode.
|
// behaviour for browser-preview mode.
|
||||||
|
|
||||||
export function hasTauriRuntime() {
|
export function hasTauriRuntime() {
|
||||||
if (typeof window === 'undefined') return false;
|
if (typeof window === "undefined") return false;
|
||||||
if (window.__TAURI_INTERNALS__) return true;
|
if (window.__TAURI_INTERNALS__) return true;
|
||||||
if (window.isTauri === true) return true;
|
if (window.isTauri === true) return true;
|
||||||
return false;
|
return false;
|
||||||
8
src/lib/utils/storage.ts
Normal file
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,
|
layout,
|
||||||
prepareWithSegments,
|
prepareWithSegments,
|
||||||
layoutWithLines,
|
layoutWithLines,
|
||||||
} from '@chenglou/pretext';
|
} from "@chenglou/pretext";
|
||||||
|
|
||||||
|
type LayoutOptions = Record<string, unknown>;
|
||||||
|
type PreparedFactory = (
|
||||||
|
text: string,
|
||||||
|
font: string,
|
||||||
|
options?: LayoutOptions,
|
||||||
|
) => unknown;
|
||||||
|
type ClampResult = ReturnType<typeof layoutWithLines> & {
|
||||||
|
visibleLineCount: number;
|
||||||
|
text: string;
|
||||||
|
didTruncate: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
// Cache prepared text to avoid re-measuring.
|
// Cache prepared text to avoid re-measuring.
|
||||||
// prepare() is expensive, layout() is the cheap hot path.
|
// prepare() is expensive, layout() is the cheap hot path.
|
||||||
@@ -11,34 +23,44 @@ const prepareCache = new Map();
|
|||||||
const segmentedPrepareCache = new Map();
|
const segmentedPrepareCache = new Map();
|
||||||
const MAX_CACHE_ENTRIES = 400;
|
const MAX_CACHE_ENTRIES = 400;
|
||||||
|
|
||||||
function touch(cache, key, value) {
|
function touch<T>(cache: Map<string, T>, key: string, value: T): T {
|
||||||
if (cache.has(key)) cache.delete(key);
|
if (cache.has(key)) cache.delete(key);
|
||||||
cache.set(key, value);
|
cache.set(key, value);
|
||||||
if (cache.size > MAX_CACHE_ENTRIES) {
|
if (cache.size > MAX_CACHE_ENTRIES) {
|
||||||
const oldestKey = cache.keys().next().value;
|
const oldestKey = cache.keys().next().value;
|
||||||
cache.delete(oldestKey);
|
if (typeof oldestKey === "string") cache.delete(oldestKey);
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cacheKey(text, font, options = {}) {
|
function cacheKey(text: string, font: string, options: LayoutOptions = {}): string {
|
||||||
return `${text}\0${font}\0${JSON.stringify(options)}`;
|
return `${text}\0${font}\0${JSON.stringify(options)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPrepared(cache, factory, text, font, options = {}) {
|
function getPrepared(
|
||||||
|
cache: Map<string, unknown>,
|
||||||
|
factory: PreparedFactory,
|
||||||
|
text: string,
|
||||||
|
font: string,
|
||||||
|
options: LayoutOptions = {},
|
||||||
|
): unknown {
|
||||||
const key = cacheKey(text, font, options);
|
const key = cacheKey(text, font, options);
|
||||||
if (cache.has(key)) {
|
if (cache.has(key)) {
|
||||||
const cached = cache.get(key);
|
const cached = cache.get(key);
|
||||||
return touch(cache, key, cached);
|
if (cached !== undefined) return touch(cache, key, cached);
|
||||||
}
|
}
|
||||||
return touch(cache, key, factory(text, font, options));
|
return touch(cache, key, factory(text, font, options));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function prepareText(text, font, options = {}) {
|
export function prepareText(text: string, font: string, options: LayoutOptions = {}): unknown {
|
||||||
return getPrepared(prepareCache, prepare, text, font, options);
|
return getPrepared(prepareCache, prepare, text, font, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function prepareTextWithSegments(text, font, options = {}) {
|
export function prepareTextWithSegments(
|
||||||
|
text: string,
|
||||||
|
font: string,
|
||||||
|
options: LayoutOptions = {},
|
||||||
|
): unknown {
|
||||||
return getPrepared(
|
return getPrepared(
|
||||||
segmentedPrepareCache,
|
segmentedPrepareCache,
|
||||||
prepareWithSegments,
|
prepareWithSegments,
|
||||||
@@ -59,7 +81,13 @@ export function prepareTextWithSegments(text, font, options = {}) {
|
|||||||
* @param {object} [options] - Additional options (e.g. { whiteSpace: 'pre-wrap' })
|
* @param {object} [options] - Additional options (e.g. { whiteSpace: 'pre-wrap' })
|
||||||
* @returns {{ height: number, lineCount: number }}
|
* @returns {{ height: number, lineCount: number }}
|
||||||
*/
|
*/
|
||||||
export function measureTextHeight(text, font, maxWidth, lineHeight, options = {}) {
|
export function measureTextHeight(
|
||||||
|
text: string,
|
||||||
|
font: string,
|
||||||
|
maxWidth: number,
|
||||||
|
lineHeight: number,
|
||||||
|
options: LayoutOptions = {},
|
||||||
|
) {
|
||||||
const prepared = prepareText(text, font, options);
|
const prepared = prepareText(text, font, options);
|
||||||
return layout(prepared, maxWidth, lineHeight);
|
return layout(prepared, maxWidth, lineHeight);
|
||||||
}
|
}
|
||||||
@@ -68,14 +96,25 @@ export function measureTextHeight(text, font, maxWidth, lineHeight, options = {}
|
|||||||
* Measure height using pre-wrap mode (textarea-like behaviour).
|
* Measure height using pre-wrap mode (textarea-like behaviour).
|
||||||
* Spaces, tabs, and hard breaks are preserved.
|
* Spaces, tabs, and hard breaks are preserved.
|
||||||
*/
|
*/
|
||||||
export function measurePreWrap(text, font, maxWidth, lineHeight) {
|
export function measurePreWrap(
|
||||||
return measureTextHeight(text, font, maxWidth, lineHeight, { whiteSpace: 'pre-wrap' });
|
text: string,
|
||||||
|
font: string,
|
||||||
|
maxWidth: number,
|
||||||
|
lineHeight: number,
|
||||||
|
) {
|
||||||
|
return measureTextHeight(text, font, maxWidth, lineHeight, { whiteSpace: "pre-wrap" });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return full line layout information for custom rendering or clipping.
|
* Return full line layout information for custom rendering or clipping.
|
||||||
*/
|
*/
|
||||||
export function layoutTextLines(text, font, maxWidth, lineHeight, options = {}) {
|
export function layoutTextLines(
|
||||||
|
text: string,
|
||||||
|
font: string,
|
||||||
|
maxWidth: number,
|
||||||
|
lineHeight: number,
|
||||||
|
options: LayoutOptions = {},
|
||||||
|
) {
|
||||||
const prepared = prepareTextWithSegments(text, font, options);
|
const prepared = prepareTextWithSegments(text, font, options);
|
||||||
return layoutWithLines(prepared, maxWidth, lineHeight);
|
return layoutWithLines(prepared, maxWidth, lineHeight);
|
||||||
}
|
}
|
||||||
@@ -84,13 +123,13 @@ export function layoutTextLines(text, font, maxWidth, lineHeight, options = {})
|
|||||||
* Clamp text to a maximum number of laid-out lines without DOM reads.
|
* Clamp text to a maximum number of laid-out lines without DOM reads.
|
||||||
*/
|
*/
|
||||||
export function clampTextLines(
|
export function clampTextLines(
|
||||||
text,
|
text: string,
|
||||||
font,
|
font: string,
|
||||||
maxWidth,
|
maxWidth: number,
|
||||||
lineHeight,
|
lineHeight: number,
|
||||||
maxLines,
|
maxLines: number,
|
||||||
options = {},
|
options: LayoutOptions = {},
|
||||||
) {
|
): ClampResult {
|
||||||
const result = layoutTextLines(text, font, maxWidth, lineHeight, options);
|
const result = layoutTextLines(text, font, maxWidth, lineHeight, options);
|
||||||
const visibleLines = result.lines.slice(0, maxLines);
|
const visibleLines = result.lines.slice(0, maxLines);
|
||||||
const didTruncate = result.lineCount > maxLines;
|
const didTruncate = result.lineCount > maxLines;
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
/** Pad number to 2 digits */
|
/** Pad number to 2 digits */
|
||||||
export function pad(n) {
|
export function pad(n: number): string {
|
||||||
return n.toString().padStart(2, "0");
|
return n.toString().padStart(2, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format seconds as M:SS (e.g. 1:05) */
|
/** Format seconds as M:SS (e.g. 1:05) */
|
||||||
export function formatTime(seconds) {
|
export function formatTime(seconds: number): string {
|
||||||
if (!seconds || isNaN(seconds)) return "0:00";
|
if (!seconds || isNaN(seconds)) return "0:00";
|
||||||
const m = Math.floor(seconds / 60);
|
const m = Math.floor(seconds / 60);
|
||||||
const s = Math.floor(seconds % 60).toString().padStart(2, "0");
|
const s = Math.floor(seconds % 60).toString().padStart(2, "0");
|
||||||
@@ -12,7 +12,7 @@ export function formatTime(seconds) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Format seconds as human duration (e.g. 2m 30s) */
|
/** Format seconds as human duration (e.g. 2m 30s) */
|
||||||
export function formatDuration(seconds) {
|
export function formatDuration(seconds: number): string {
|
||||||
if (!seconds) return "";
|
if (!seconds) return "";
|
||||||
const m = Math.floor(seconds / 60);
|
const m = Math.floor(seconds / 60);
|
||||||
const s = Math.round(seconds % 60);
|
const s = Math.round(seconds % 60);
|
||||||
@@ -20,7 +20,7 @@ export function formatDuration(seconds) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Format ISO timestamp as D Mon HH:MM (e.g. 15 Mar 18:11) */
|
/** Format ISO timestamp as D Mon HH:MM (e.g. 15 Mar 18:11) */
|
||||||
export function formatTimestamp(iso) {
|
export function formatTimestamp(iso: string | null | undefined): string {
|
||||||
if (!iso) return "";
|
if (!iso) return "";
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
return d.toLocaleDateString("en-GB", { day: "numeric", month: "short" }) +
|
return d.toLocaleDateString("en-GB", { day: "numeric", month: "short" }) +
|
||||||
@@ -28,7 +28,7 @@ export function formatTimestamp(iso) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Format seconds as SRT timestamp (HH:MM:SS,mmm) */
|
/** Format seconds as SRT timestamp (HH:MM:SS,mmm) */
|
||||||
export function formatTimeSRT(seconds) {
|
export function formatTimeSRT(seconds: number): string {
|
||||||
const h = Math.floor(seconds / 3600);
|
const h = Math.floor(seconds / 3600);
|
||||||
const m = Math.floor((seconds % 3600) / 60);
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
const s = Math.floor(seconds % 60);
|
const s = Math.floor(seconds % 60);
|
||||||
@@ -37,7 +37,7 @@ export function formatTimeSRT(seconds) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Format seconds as VTT timestamp (HH:MM:SS.mmm) */
|
/** Format seconds as VTT timestamp (HH:MM:SS.mmm) */
|
||||||
export function formatTimeVTT(seconds) {
|
export function formatTimeVTT(seconds: number): string {
|
||||||
const h = Math.floor(seconds / 3600);
|
const h = Math.floor(seconds / 3600);
|
||||||
const m = Math.floor((seconds % 3600) / 60);
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
const s = Math.floor(seconds % 60);
|
const s = Math.floor(seconds % 60);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export function buildCumulativeOffsets(heights) {
|
export function buildCumulativeOffsets(heights: number[]): number[] {
|
||||||
const offsets = new Array(heights.length + 1);
|
const offsets = new Array(heights.length + 1);
|
||||||
offsets[0] = 0;
|
offsets[0] = 0;
|
||||||
for (let i = 0; i < heights.length; i++) {
|
for (let i = 0; i < heights.length; i++) {
|
||||||
@@ -8,10 +8,10 @@ export function buildCumulativeOffsets(heights) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function findVisibleRange(
|
export function findVisibleRange(
|
||||||
cumulativeOffsets,
|
cumulativeOffsets: number[],
|
||||||
itemCount,
|
itemCount: number,
|
||||||
scrollTop,
|
scrollTop: number,
|
||||||
viewportHeight,
|
viewportHeight: number,
|
||||||
buffer = 4,
|
buffer = 4,
|
||||||
) {
|
) {
|
||||||
if (!cumulativeOffsets.length || itemCount === 0 || viewportHeight <= 0) {
|
if (!cumulativeOffsets.length || itemCount === 0 || viewportHeight <= 0) {
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
|
// @ts-nocheck
|
||||||
import "../app.css";
|
import "../app.css";
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
|
// @ts-nocheck
|
||||||
import "../../app.css";
|
import "../../app.css";
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
|
import { tick } from "svelte";
|
||||||
|
import type { TaskList } from "$lib/types/app";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import {
|
import {
|
||||||
tasks, addTask, completeTask, uncompleteTask, deleteTask,
|
tasks, addTask, completeTask, uncompleteTask, deleteTask,
|
||||||
@@ -12,12 +14,14 @@
|
|||||||
let showCompleted = $state(false);
|
let showCompleted = $state(false);
|
||||||
let newListName = $state("");
|
let newListName = $state("");
|
||||||
let showNewList = $state(false);
|
let showNewList = $state(false);
|
||||||
let contextMenuListId = $state(null);
|
let contextMenuListId = $state<string | null>(null);
|
||||||
let editingListId = $state(null);
|
let editingListId = $state<string | null>(null);
|
||||||
let editingName = $state("");
|
let editingName = $state("");
|
||||||
let showSortMenu = $state(false);
|
let showSortMenu = $state(false);
|
||||||
let draggingTaskId = $state(null);
|
let draggingTaskId = $state<string | null>(null);
|
||||||
let dropHighlightId = $state(null);
|
let dropHighlightId = $state<string | null>(null);
|
||||||
|
let editingInputEl = $state<HTMLInputElement | null>(null);
|
||||||
|
let newListInputEl = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
// Filtered tasks for active list
|
// Filtered tasks for active list
|
||||||
let tasksForActiveList = $derived.by(() => {
|
let tasksForActiveList = $derived.by(() => {
|
||||||
@@ -34,7 +38,7 @@
|
|||||||
return done.filter((t) => t.listId === activeListId).slice(0, 10);
|
return done.filter((t) => t.listId === activeListId).slice(0, 10);
|
||||||
});
|
});
|
||||||
|
|
||||||
function countForList(listId) {
|
function countForList(listId: string) {
|
||||||
if (listId === "all") return tasks.filter((t) => !t.done).length;
|
if (listId === "all") return tasks.filter((t) => !t.done).length;
|
||||||
if (listId === "inbox") return tasks.filter((t) => !t.done && !t.listId).length;
|
if (listId === "inbox") return tasks.filter((t) => !t.done && !t.listId).length;
|
||||||
return tasks.filter((t) => !t.done && t.listId === listId).length;
|
return tasks.filter((t) => !t.done && t.listId === listId).length;
|
||||||
@@ -48,7 +52,7 @@
|
|||||||
let builtInLists = $derived(taskLists.filter((l) => l.builtIn));
|
let builtInLists = $derived(taskLists.filter((l) => l.builtIn));
|
||||||
let customLists = $derived(taskLists.filter((l) => !l.builtIn));
|
let customLists = $derived(taskLists.filter((l) => !l.builtIn));
|
||||||
|
|
||||||
function handleQuickAdd(e) {
|
function handleQuickAdd(e: KeyboardEvent) {
|
||||||
if (e.key === "Enter" && quickInput.trim()) {
|
if (e.key === "Enter" && quickInput.trim()) {
|
||||||
const listId = (activeListId === "all" || activeListId === "inbox") ? null : activeListId;
|
const listId = (activeListId === "all" || activeListId === "inbox") ? null : activeListId;
|
||||||
addTask({ text: quickInput.trim(), bucket: "inbox", listId });
|
addTask({ text: quickInput.trim(), bucket: "inbox", listId });
|
||||||
@@ -56,11 +60,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragStart(e) {
|
function handleDragStart(e: PointerEvent) {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
if (e.target.closest("button")) return;
|
if ((e.target as HTMLElement | null)?.closest("button")) return;
|
||||||
if (e.target.closest("input")) return;
|
if ((e.target as HTMLElement | null)?.closest("input")) return;
|
||||||
try { e.currentTarget?.setPointerCapture?.(e.pointerId); } catch {}
|
try { (e.currentTarget as HTMLElement | null)?.setPointerCapture?.(e.pointerId); } catch {}
|
||||||
getCurrentWindow().startDragging();
|
getCurrentWindow().startDragging();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +78,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// List management
|
// List management
|
||||||
function handleCreateList(e) {
|
function handleCreateList(e: KeyboardEvent) {
|
||||||
if (e.key === "Enter" && newListName.trim()) {
|
if (e.key === "Enter" && newListName.trim()) {
|
||||||
addTaskList(newListName.trim());
|
addTaskList(newListName.trim());
|
||||||
newListName = "";
|
newListName = "";
|
||||||
@@ -86,10 +90,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startRenaming(list) {
|
async function startRenaming(list: TaskList) {
|
||||||
editingListId = list.id;
|
editingListId = list.id;
|
||||||
editingName = list.name;
|
editingName = list.name;
|
||||||
contextMenuListId = null;
|
contextMenuListId = null;
|
||||||
|
await tick();
|
||||||
|
editingInputEl?.focus();
|
||||||
|
editingInputEl?.select();
|
||||||
}
|
}
|
||||||
|
|
||||||
function finishRenaming() {
|
function finishRenaming() {
|
||||||
@@ -100,28 +107,28 @@
|
|||||||
editingName = "";
|
editingName = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRenameKey(e) {
|
function handleRenameKey(e: KeyboardEvent) {
|
||||||
if (e.key === "Enter") finishRenaming();
|
if (e.key === "Enter") finishRenaming();
|
||||||
if (e.key === "Escape") { editingListId = null; editingName = ""; }
|
if (e.key === "Escape") { editingListId = null; editingName = ""; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDeleteList(id) {
|
function handleDeleteList(id: string) {
|
||||||
if (activeListId === id) activeListId = "all";
|
if (activeListId === id) activeListId = "all";
|
||||||
deleteTaskList(id);
|
deleteTaskList(id);
|
||||||
contextMenuListId = null;
|
contextMenuListId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleContextMenu(e, listId) {
|
function toggleContextMenu(e: MouseEvent, listId: string) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
contextMenuListId = contextMenuListId === listId ? null : listId;
|
contextMenuListId = contextMenuListId === listId ? null : listId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drag-and-drop: tasks between lists
|
// Drag-and-drop: tasks between lists
|
||||||
function handleTaskDragStart(e, taskId) {
|
function handleTaskDragStart(e: DragEvent, taskId: string) {
|
||||||
draggingTaskId = taskId;
|
draggingTaskId = taskId;
|
||||||
e.dataTransfer.setData("text/plain", taskId);
|
e.dataTransfer?.setData("text/plain", taskId);
|
||||||
e.dataTransfer.effectAllowed = "move";
|
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTaskDragEnd() {
|
function handleTaskDragEnd() {
|
||||||
@@ -129,24 +136,24 @@
|
|||||||
dropHighlightId = null;
|
dropHighlightId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleListDragOver(e) {
|
function handleListDragOver(e: DragEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = "move";
|
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleListDragEnter(e, listId) {
|
function handleListDragEnter(e: DragEvent, listId: string) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dropHighlightId = listId;
|
dropHighlightId = listId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleListDragLeave(e, listId) {
|
function handleListDragLeave(_e: DragEvent, listId: string) {
|
||||||
if (dropHighlightId === listId) dropHighlightId = null;
|
if (dropHighlightId === listId) dropHighlightId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleListDrop(e, listId) {
|
function handleListDrop(e: DragEvent, listId: string) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dropHighlightId = null;
|
dropHighlightId = null;
|
||||||
const taskId = e.dataTransfer.getData("text/plain");
|
const taskId = e.dataTransfer?.getData("text/plain");
|
||||||
if (taskId) {
|
if (taskId) {
|
||||||
moveTaskToList(taskId, listId);
|
moveTaskToList(taskId, listId);
|
||||||
}
|
}
|
||||||
@@ -157,10 +164,20 @@
|
|||||||
if (contextMenuListId) contextMenuListId = null;
|
if (contextMenuListId) contextMenuListId = null;
|
||||||
if (showSortMenu) showSortMenu = false;
|
if (showSortMenu) showSortMenu = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (showNewList) {
|
||||||
|
tick().then(() => {
|
||||||
|
newListInputEl?.focus();
|
||||||
|
newListInputEl?.select();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onclick={handleWindowClick} />
|
<svelte:window onclick={handleWindowClick} />
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="flex flex-col h-full bg-bg">
|
<div class="flex flex-col h-full bg-bg">
|
||||||
<!-- Drag handle with title -->
|
<!-- Drag handle with title -->
|
||||||
<div
|
<div
|
||||||
@@ -239,6 +256,7 @@
|
|||||||
{#each customLists as list (list.id)}
|
{#each customLists as list (list.id)}
|
||||||
{#if editingListId === list.id}
|
{#if editingListId === list.id}
|
||||||
<input
|
<input
|
||||||
|
bind:this={editingInputEl}
|
||||||
type="text"
|
type="text"
|
||||||
class="bg-bg-input border border-accent rounded px-2 py-0.5 text-[11px] text-text
|
class="bg-bg-input border border-accent rounded px-2 py-0.5 text-[11px] text-text
|
||||||
focus:outline-none w-[100px]"
|
focus:outline-none w-[100px]"
|
||||||
@@ -246,7 +264,6 @@
|
|||||||
onkeydown={handleRenameKey}
|
onkeydown={handleRenameKey}
|
||||||
onblur={finishRenaming}
|
onblur={finishRenaming}
|
||||||
data-no-transition
|
data-no-transition
|
||||||
autofocus
|
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="relative flex-shrink-0">
|
<div class="relative flex-shrink-0">
|
||||||
@@ -280,7 +297,7 @@
|
|||||||
<!-- Context menu -->
|
<!-- Context menu -->
|
||||||
{#if contextMenuListId === list.id}
|
{#if contextMenuListId === list.id}
|
||||||
<div class="absolute left-0 top-full mt-1 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-20 animate-fade-in min-w-[120px]"
|
<div class="absolute left-0 top-full mt-1 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-20 animate-fade-in min-w-[120px]"
|
||||||
onclick={(e) => e.stopPropagation()}>
|
onpointerdown={(e) => e.stopPropagation()}>
|
||||||
<button
|
<button
|
||||||
class="w-full text-left px-3 py-1 text-[10px] text-text-secondary hover:bg-hover hover:text-text"
|
class="w-full text-left px-3 py-1 text-[10px] text-text-secondary hover:bg-hover hover:text-text"
|
||||||
onclick={() => startRenaming(list)}
|
onclick={() => startRenaming(list)}
|
||||||
@@ -322,6 +339,7 @@
|
|||||||
<!-- New list button -->
|
<!-- New list button -->
|
||||||
{#if showNewList}
|
{#if showNewList}
|
||||||
<input
|
<input
|
||||||
|
bind:this={newListInputEl}
|
||||||
type="text"
|
type="text"
|
||||||
class="bg-bg-input border border-border rounded px-2 py-0.5 text-[10px] text-text
|
class="bg-bg-input border border-border rounded px-2 py-0.5 text-[10px] text-text
|
||||||
placeholder:text-text-tertiary focus:outline-none focus:border-accent w-[100px] flex-shrink-0"
|
placeholder:text-text-tertiary focus:outline-none focus:border-accent w-[100px] flex-shrink-0"
|
||||||
@@ -330,7 +348,6 @@
|
|||||||
onkeydown={handleCreateList}
|
onkeydown={handleCreateList}
|
||||||
onblur={() => { showNewList = false; newListName = ""; }}
|
onblur={() => { showNewList = false; newListName = ""; }}
|
||||||
data-no-transition
|
data-no-transition
|
||||||
autofocus
|
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<button
|
||||||
@@ -345,6 +362,7 @@
|
|||||||
<button
|
<button
|
||||||
class="px-1.5 py-1 rounded text-[10px] text-text-tertiary hover:text-text-secondary"
|
class="px-1.5 py-1 rounded text-[10px] text-text-tertiary hover:text-text-secondary"
|
||||||
onclick={(e) => { e.stopPropagation(); showSortMenu = !showSortMenu; }}
|
onclick={(e) => { e.stopPropagation(); showSortMenu = !showSortMenu; }}
|
||||||
|
aria-label="Sort task lists"
|
||||||
>
|
>
|
||||||
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M3 6h18M3 12h12M3 18h6" stroke-linecap="round" />
|
<path d="M3 6h18M3 12h12M3 18h6" stroke-linecap="round" />
|
||||||
@@ -352,7 +370,7 @@
|
|||||||
</button>
|
</button>
|
||||||
{#if showSortMenu}
|
{#if showSortMenu}
|
||||||
<div class="absolute right-0 top-full mt-1 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-20 animate-fade-in min-w-[110px]"
|
<div class="absolute right-0 top-full mt-1 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-20 animate-fade-in min-w-[110px]"
|
||||||
onclick={(e) => e.stopPropagation()}>
|
onpointerdown={(e) => e.stopPropagation()}>
|
||||||
<button
|
<button
|
||||||
class="w-full text-left px-3 py-1 text-[10px] text-text-secondary hover:bg-hover hover:text-text"
|
class="w-full text-left px-3 py-1 text-[10px] text-text-secondary hover:bg-hover hover:text-text"
|
||||||
onclick={() => { sortTaskLists("alpha"); showSortMenu = false; }}
|
onclick={() => { sortTaskLists("alpha"); showSortMenu = false; }}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
|
// @ts-nocheck
|
||||||
import "../../app.css";
|
import "../../app.css";
|
||||||
import { onDestroy, onMount } from "svelte";
|
import { onDestroy, onMount } from "svelte";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy, tick } from "svelte";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
|
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
|
||||||
|
import type { TranscriptEntry, ViewerSegment } from "$lib/types/app";
|
||||||
import { formatTime, formatDuration } from "$lib/utils/time.js";
|
import { formatTime, formatDuration } from "$lib/utils/time.js";
|
||||||
import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js";
|
import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js";
|
||||||
|
import { errorMessage } from "$lib/utils/errors.js";
|
||||||
|
import { parseStoredJson } from "$lib/utils/storage.js";
|
||||||
import { saveTranscriptMeta } from "$lib/stores/page.svelte.js";
|
import { saveTranscriptMeta } from "$lib/stores/page.svelte.js";
|
||||||
|
|
||||||
let item = $state(null);
|
let item = $state<TranscriptEntry | null>(null);
|
||||||
let audioEl = $state(null);
|
let audioEl = $state<HTMLAudioElement | null>(null);
|
||||||
let playing = $state(false);
|
let playing = $state(false);
|
||||||
let currentTime = $state(0);
|
let currentTime = $state(0);
|
||||||
let duration = $state(0);
|
let duration = $state(0);
|
||||||
@@ -18,41 +21,62 @@
|
|||||||
let editingText = $state("");
|
let editingText = $state("");
|
||||||
let showTimestamps = $state(true);
|
let showTimestamps = $state(true);
|
||||||
let showStarredOnly = $state(false);
|
let showStarredOnly = $state(false);
|
||||||
let animFrameId = null;
|
let animFrameId: number | null = null;
|
||||||
let segmentRefs = [];
|
let segmentRefs = $state<Array<HTMLElement | null>>([]);
|
||||||
let viewerMode = $state("view"); // "view" | "edit"
|
let viewerMode = $state<"view" | "edit">("view");
|
||||||
let textDraft = $state("");
|
let textDraft = $state("");
|
||||||
let textDirty = $state(false);
|
let textDirty = $state(false);
|
||||||
let textSaveTimer = null;
|
let textSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let editingTextareaEl = $state<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
|
function stopAudio() {
|
||||||
|
if (audioEl) audioEl.pause();
|
||||||
|
playing = false;
|
||||||
|
if (animFrameId !== null) cancelAnimationFrame(animFrameId);
|
||||||
|
animFrameId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAudio(audioPath: string | null) {
|
||||||
|
stopAudio();
|
||||||
|
audioEl = null;
|
||||||
|
duration = 0;
|
||||||
|
currentTime = 0;
|
||||||
|
if (!audioPath) return;
|
||||||
|
|
||||||
|
const src = convertFileSrc(audioPath);
|
||||||
|
const audio = new Audio(src);
|
||||||
|
audio.playbackRate = playbackRate;
|
||||||
|
audio.onloadedmetadata = () => { duration = audio.duration; };
|
||||||
|
audio.onended = () => {
|
||||||
|
playing = false;
|
||||||
|
if (animFrameId !== null) cancelAnimationFrame(animFrameId);
|
||||||
|
animFrameId = null;
|
||||||
|
};
|
||||||
|
audio.onerror = () => { playing = false; };
|
||||||
|
audioEl = audio;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadViewerItem(nextItem: TranscriptEntry | null) {
|
||||||
|
item = nextItem;
|
||||||
|
textDraft = nextItem?.text ?? "";
|
||||||
|
activeSegmentIdx = -1;
|
||||||
|
editingIdx = -1;
|
||||||
|
editingText = "";
|
||||||
|
segmentRefs.length = 0;
|
||||||
|
buildAudio(nextItem?.audioPath ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
// Load item data from localStorage (set by HistoryPage before opening this window)
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
try {
|
loadViewerItem(parseStoredJson<TranscriptEntry>(localStorage.getItem("kon_viewer_item")));
|
||||||
const raw = localStorage.getItem("kon_viewer_item");
|
const mode = localStorage.getItem("kon_viewer_mode");
|
||||||
if (raw) {
|
if (mode === "edit" || mode === "view") viewerMode = mode;
|
||||||
item = JSON.parse(raw);
|
|
||||||
textDraft = item?.text || "";
|
|
||||||
if (item.audioPath) {
|
|
||||||
const src = convertFileSrc(item.audioPath);
|
|
||||||
const audio = new Audio(src);
|
|
||||||
audio.onloadedmetadata = () => { duration = audio.duration; };
|
|
||||||
audio.onended = () => { playing = false; cancelAnimationFrame(animFrameId); };
|
|
||||||
audio.onerror = () => { playing = false; };
|
|
||||||
audioEl = audio;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const mode = localStorage.getItem("kon_viewer_mode");
|
|
||||||
if (mode === "edit" || mode === "view") viewerMode = mode;
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
// Listen for new items via storage events
|
// Listen for new items via storage events
|
||||||
window.addEventListener("storage", handleStorageChange);
|
window.addEventListener("storage", handleStorageChange);
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (audioEl) audioEl.pause();
|
stopAudio();
|
||||||
cancelAnimationFrame(animFrameId);
|
|
||||||
window.removeEventListener("storage", handleStorageChange);
|
window.removeEventListener("storage", handleStorageChange);
|
||||||
// Flush any pending text edit before the window tears down.
|
// Flush any pending text edit before the window tears down.
|
||||||
if (textSaveTimer) {
|
if (textSaveTimer) {
|
||||||
@@ -62,23 +86,9 @@
|
|||||||
if (textDirty) commitTextEdit();
|
if (textDirty) commitTextEdit();
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleStorageChange(e) {
|
function handleStorageChange(e: StorageEvent) {
|
||||||
if (e.key === "kon_viewer_item" && e.newValue) {
|
if (e.key === "kon_viewer_item" && e.newValue) {
|
||||||
try {
|
loadViewerItem(parseStoredJson<TranscriptEntry>(e.newValue));
|
||||||
if (audioEl) { audioEl.pause(); }
|
|
||||||
cancelAnimationFrame(animFrameId);
|
|
||||||
playing = false;
|
|
||||||
currentTime = 0;
|
|
||||||
|
|
||||||
item = JSON.parse(e.newValue);
|
|
||||||
if (item.audioPath) {
|
|
||||||
const src = convertFileSrc(item.audioPath);
|
|
||||||
const audio = new Audio(src);
|
|
||||||
audio.onloadedmetadata = () => { duration = audio.duration; };
|
|
||||||
audio.onended = () => { playing = false; cancelAnimationFrame(animFrameId); };
|
|
||||||
audioEl = audio;
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +97,7 @@
|
|||||||
if (playing) {
|
if (playing) {
|
||||||
audioEl.pause();
|
audioEl.pause();
|
||||||
playing = false;
|
playing = false;
|
||||||
cancelAnimationFrame(animFrameId);
|
if (animFrameId !== null) cancelAnimationFrame(animFrameId);
|
||||||
} else {
|
} else {
|
||||||
audioEl.play();
|
audioEl.play();
|
||||||
playing = true;
|
playing = true;
|
||||||
@@ -114,17 +124,15 @@
|
|||||||
if (activeSegmentIdx !== i) {
|
if (activeSegmentIdx !== i) {
|
||||||
activeSegmentIdx = i;
|
activeSegmentIdx = i;
|
||||||
// Auto-scroll to active segment
|
// Auto-scroll to active segment
|
||||||
if (segmentRefs[i]) {
|
segmentRefs[i]?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
segmentRefs[i].scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function seekTo(e) {
|
function seekTo(e: Event) {
|
||||||
const val = parseFloat(e.target.value);
|
const val = parseFloat((e.currentTarget as HTMLInputElement).value);
|
||||||
if (audioEl) {
|
if (audioEl) {
|
||||||
audioEl.currentTime = val;
|
audioEl.currentTime = val;
|
||||||
currentTime = val;
|
currentTime = val;
|
||||||
@@ -132,7 +140,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function seekToTime(time) {
|
function seekToTime(time: number) {
|
||||||
if (audioEl) {
|
if (audioEl) {
|
||||||
audioEl.currentTime = time;
|
audioEl.currentTime = time;
|
||||||
currentTime = time;
|
currentTime = time;
|
||||||
@@ -145,7 +153,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSpeed(speed) {
|
function setSpeed(speed: number) {
|
||||||
playbackRate = speed;
|
playbackRate = speed;
|
||||||
if (audioEl) audioEl.playbackRate = speed;
|
if (audioEl) audioEl.playbackRate = speed;
|
||||||
}
|
}
|
||||||
@@ -153,9 +161,9 @@
|
|||||||
|
|
||||||
// Search: find matching segments
|
// Search: find matching segments
|
||||||
let matchingSegments = $derived.by(() => {
|
let matchingSegments = $derived.by(() => {
|
||||||
if (!searchQuery.trim() || !item?.segments) return new Set();
|
if (!searchQuery.trim() || !item?.segments) return new Set<number>();
|
||||||
const q = searchQuery.toLowerCase();
|
const q = searchQuery.toLowerCase();
|
||||||
const matches = new Set();
|
const matches = new Set<number>();
|
||||||
item.segments.forEach((seg, i) => {
|
item.segments.forEach((seg, i) => {
|
||||||
if (seg.text.toLowerCase().includes(q)) matches.add(i);
|
if (seg.text.toLowerCase().includes(q)) matches.add(i);
|
||||||
});
|
});
|
||||||
@@ -165,7 +173,7 @@
|
|||||||
let matchCount = $derived.by(() => matchingSegments.size);
|
let matchCount = $derived.by(() => matchingSegments.size);
|
||||||
|
|
||||||
// Highlight search matches in text (XSS-safe: escapes HTML entities first)
|
// Highlight search matches in text (XSS-safe: escapes HTML entities first)
|
||||||
function highlightText(text) {
|
function highlightText(text: string) {
|
||||||
if (!searchQuery.trim()) return text;
|
if (!searchQuery.trim()) return text;
|
||||||
const escaped = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
const escaped = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
const q = searchQuery.trim();
|
const q = searchQuery.trim();
|
||||||
@@ -174,9 +182,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Segment editing
|
// Segment editing
|
||||||
function startEditing(idx) {
|
async function startEditing(idx: number) {
|
||||||
|
if (!item?.segments?.[idx]) return;
|
||||||
editingIdx = idx;
|
editingIdx = idx;
|
||||||
editingText = item.segments[idx].text;
|
editingText = item.segments[idx].text;
|
||||||
|
await tick();
|
||||||
|
editingTextareaEl?.focus();
|
||||||
|
editingTextareaEl?.select();
|
||||||
}
|
}
|
||||||
|
|
||||||
function finishEditing() {
|
function finishEditing() {
|
||||||
@@ -194,7 +206,7 @@
|
|||||||
editingText = "";
|
editingText = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteSegment(idx) {
|
function deleteSegment(idx: number) {
|
||||||
if (item?.segments) {
|
if (item?.segments) {
|
||||||
item.segments.splice(idx, 1);
|
item.segments.splice(idx, 1);
|
||||||
item.text = item.segments.map((s) => s.text).join(" ").trim();
|
item.text = item.segments.map((s) => s.text).join(" ").trim();
|
||||||
@@ -205,7 +217,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleStar(idx) {
|
function toggleStar(idx: number) {
|
||||||
if (item?.segments) {
|
if (item?.segments) {
|
||||||
item.segments[idx].starred = !item.segments[idx].starred;
|
item.segments[idx].starred = !item.segments[idx].starred;
|
||||||
// Task 2.5 — persist the updated segments array (incl. starred flags)
|
// Task 2.5 — persist the updated segments array (incl. starred flags)
|
||||||
@@ -223,12 +235,10 @@
|
|||||||
// Task 2.5, tracked by the file's 80-odd pre-existing TS errors.
|
// Task 2.5, tracked by the file's 80-odd pre-existing TS errors.
|
||||||
function persistSegments() {
|
function persistSegments() {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
/** @type {any} */
|
saveTranscriptMeta(item.id, { segments: item.segments });
|
||||||
const snap = item;
|
|
||||||
saveTranscriptMeta(snap.id, { segments: snap.segments });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function copySegment(idx) {
|
function copySegment(idx: number) {
|
||||||
if (item?.segments?.[idx]) {
|
if (item?.segments?.[idx]) {
|
||||||
const text = item.segments[idx].text.trim();
|
const text = item.segments[idx].text.trim();
|
||||||
navigator.clipboard.writeText(text).catch(() => {
|
navigator.clipboard.writeText(text).catch(() => {
|
||||||
@@ -259,13 +269,13 @@
|
|||||||
text: item.text ?? null,
|
text: item.text ?? null,
|
||||||
title: item.title ?? null,
|
title: item.title ?? null,
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
console.warn("viewer saveItemToHistory: update_transcript failed", err);
|
console.warn("viewer saveItemToHistory: update_transcript failed", errorMessage(err));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleTextSave() {
|
function scheduleTextSave() {
|
||||||
textDirty = true;
|
textDirty = true;
|
||||||
clearTimeout(textSaveTimer);
|
if (textSaveTimer) clearTimeout(textSaveTimer);
|
||||||
textSaveTimer = setTimeout(() => {
|
textSaveTimer = setTimeout(() => {
|
||||||
commitTextEdit();
|
commitTextEdit();
|
||||||
}, 400);
|
}, 400);
|
||||||
@@ -288,8 +298,8 @@
|
|||||||
|
|
||||||
// Filtered segments (starred filter + search)
|
// Filtered segments (starred filter + search)
|
||||||
let visibleSegments = $derived.by(() => {
|
let visibleSegments = $derived.by(() => {
|
||||||
if (!item?.segments) return [];
|
if (!item?.segments) return [] as ViewerSegment[];
|
||||||
let segs = item.segments.map((seg, i) => ({ ...seg, _idx: i }));
|
let segs: ViewerSegment[] = item.segments.map((seg, i) => ({ ...seg, _idx: i }));
|
||||||
if (showStarredOnly) {
|
if (showStarredOnly) {
|
||||||
segs = segs.filter((s) => s.starred);
|
segs = segs.filter((s) => s.starred);
|
||||||
}
|
}
|
||||||
@@ -298,11 +308,11 @@
|
|||||||
|
|
||||||
// Window drag — pointerdown + setPointerCapture avoids the mousedown
|
// Window drag — pointerdown + setPointerCapture avoids the mousedown
|
||||||
// latency that makes KWin's initial grab feel draggy.
|
// latency that makes KWin's initial grab feel draggy.
|
||||||
function handleDragStart(e) {
|
function handleDragStart(e: PointerEvent) {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
if (e.target.closest("button")) return;
|
if ((e.target as HTMLElement | null)?.closest("button")) return;
|
||||||
if (e.target.closest("input")) return;
|
if ((e.target as HTMLElement | null)?.closest("input")) return;
|
||||||
try { e.currentTarget?.setPointerCapture?.(e.pointerId); } catch {}
|
try { (e.currentTarget as HTMLElement | null)?.setPointerCapture?.(e.pointerId); } catch {}
|
||||||
getCurrentWindow().startDragging();
|
getCurrentWindow().startDragging();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,6 +320,7 @@
|
|||||||
|
|
||||||
<div class="flex flex-col h-full bg-bg">
|
<div class="flex flex-col h-full bg-bg">
|
||||||
<!-- Drag handle -->
|
<!-- Drag handle -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="flex items-center h-[36px] bg-bg-elevated select-none px-3"
|
class="flex items-center h-[36px] bg-bg-elevated select-none px-3"
|
||||||
onpointerdown={handleDragStart}
|
onpointerdown={handleDragStart}
|
||||||
@@ -469,6 +480,7 @@
|
|||||||
<!-- Text (editable or display) -->
|
<!-- Text (editable or display) -->
|
||||||
{#if editingIdx === seg._idx}
|
{#if editingIdx === seg._idx}
|
||||||
<textarea
|
<textarea
|
||||||
|
bind:this={editingTextareaEl}
|
||||||
class="flex-1 bg-bg-input border border-accent rounded-lg px-2 py-1 text-[13px] text-text
|
class="flex-1 bg-bg-input border border-accent rounded-lg px-2 py-1 text-[13px] text-text
|
||||||
leading-relaxed resize-none focus:outline-none min-h-[40px]"
|
leading-relaxed resize-none focus:outline-none min-h-[40px]"
|
||||||
bind:value={editingText}
|
bind:value={editingText}
|
||||||
@@ -476,7 +488,6 @@
|
|||||||
onblur={finishEditing}
|
onblur={finishEditing}
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
data-no-transition
|
data-no-transition
|
||||||
autofocus
|
|
||||||
></textarea>
|
></textarea>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-[13px] text-text leading-relaxed flex-1">
|
<p class="text-[13px] text-text leading-relaxed flex-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user