refactor(frontend): migrate JS modules to TypeScript

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

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

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

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

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

View File

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

View File

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