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:
143
src/lib/utils/frontmatter.ts
Normal file
143
src/lib/utils/frontmatter.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
// Transcript frontmatter + auto-tag derivation.
|
||||
//
|
||||
// A transcript's "frontmatter" is a flat object of metadata that can be
|
||||
// exported as YAML for Obsidian or other Markdown consumers. Auto-tags are
|
||||
// derived deterministically from existing fields (date, duration, source,
|
||||
// text length) so they stay in sync without migration.
|
||||
//
|
||||
// Storage model:
|
||||
// - Source of truth is the existing transcript fields (id, title, date,
|
||||
// duration, source, text, segments).
|
||||
// - Manual tags live on `item.manualTags: string[]`.
|
||||
// - Auto-tags are never stored — derived on demand.
|
||||
|
||||
const DURATION_BUCKETS = [
|
||||
{ max: 60, tag: "duration:short" }, // < 1 minute
|
||||
{ max: 300, tag: "duration:medium" }, // < 5 minutes
|
||||
{ max: 1800, tag: "duration:long" }, // < 30 minutes
|
||||
{ max: Infinity, tag: "duration:very-long" },
|
||||
];
|
||||
|
||||
const WORD_BUCKETS = [
|
||||
{ max: 50, tag: "words:short" },
|
||||
{ max: 300, tag: "words:medium" },
|
||||
{ max: 1500, tag: "words:long" },
|
||||
{ max: Infinity, tag: "words:very-long" },
|
||||
];
|
||||
|
||||
function durationTag(seconds: number): string | null {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return null;
|
||||
return DURATION_BUCKETS.find((b) => seconds < b.max)?.tag ?? null;
|
||||
}
|
||||
|
||||
function wordCountTag(text: string): string | null {
|
||||
if (!text || typeof text !== "string") return null;
|
||||
const count = text.trim().split(/\s+/).filter(Boolean).length;
|
||||
return WORD_BUCKETS.find((b) => count < b.max)?.tag ?? null;
|
||||
}
|
||||
|
||||
// Resolve time-of-day bucket from an ISO date or a legacy string like
|
||||
// "19/04/2026, 11:37:23". Thresholds are fixed and local to the user's
|
||||
// machine — hour 6-11 morning, 12-17 afternoon, 18-21 evening, else night.
|
||||
function timeOfDayTag(dateStr: string): string | null {
|
||||
if (!dateStr) return null;
|
||||
let ts = Date.parse(dateStr);
|
||||
if (Number.isNaN(ts)) {
|
||||
// Try DD/MM/YYYY, HH:MM:SS (UK local format used by Kon history rows).
|
||||
const match = String(dateStr).match(
|
||||
/(\d{1,2})\/(\d{1,2})\/(\d{4})[,\s]+(\d{1,2}):(\d{2})(?::(\d{2}))?/,
|
||||
);
|
||||
if (!match) return null;
|
||||
const [, dd, mm, yyyy, hh, min, ss] = match;
|
||||
const d = new Date(
|
||||
Number(yyyy), Number(mm) - 1, Number(dd),
|
||||
Number(hh), Number(min), Number(ss || 0),
|
||||
);
|
||||
ts = d.getTime();
|
||||
}
|
||||
const hour = new Date(ts).getHours();
|
||||
if (hour >= 6 && hour < 12) return "time:morning";
|
||||
if (hour >= 12 && hour < 18) return "time:afternoon";
|
||||
if (hour >= 18 && hour < 22) return "time:evening";
|
||||
return "time:night";
|
||||
}
|
||||
|
||||
function sourceTag(source: string): string | null {
|
||||
if (!source) return null;
|
||||
const s = String(source).toLowerCase();
|
||||
if (s.includes("file")) return "source:file";
|
||||
if (s.includes("live") || s.includes("mic")) return "source:live";
|
||||
return `source:${s.replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "")}`;
|
||||
}
|
||||
|
||||
// Returns tags to display as chips. Intentionally empty by default: the
|
||||
// metadata these tags used to encode (duration, date, source, starred) is
|
||||
// already shown elsewhere in the History row, so chips would duplicate
|
||||
// information and add cognitive load without improving retrieval. The
|
||||
// function is kept as a hook for one future AI-derived content tag
|
||||
// (`topic:*`) once kon-llm wires up real llama-cpp-2 in Phase 3.
|
||||
export function deriveAutoTags(_item: TranscriptEntry): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
export function normaliseTag(raw: string): string {
|
||||
return String(raw || "").trim().toLowerCase().replace(/\s+/g, "-");
|
||||
}
|
||||
|
||||
// Build the flat frontmatter object that represents a transcript's metadata.
|
||||
// Shown in the expanded History row and serialised when exporting to .md.
|
||||
export function buildFrontmatter(item: TranscriptEntry | null) {
|
||||
if (!item) return {};
|
||||
const auto = deriveAutoTags(item);
|
||||
const manual = Array.isArray(item.manualTags) ? item.manualTags : [];
|
||||
const tags = Array.from(new Set([...auto, ...manual.map(normaliseTag)])).filter(Boolean);
|
||||
const wordCount = item.text ? item.text.trim().split(/\s+/).filter(Boolean).length : 0;
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title || null,
|
||||
date: item.createdAt || item.date || null,
|
||||
duration_s: Number.isFinite(item.duration) ? item.duration : null,
|
||||
source: item.source || null,
|
||||
word_count: wordCount,
|
||||
tags,
|
||||
};
|
||||
}
|
||||
|
||||
// Escape a YAML scalar. Keeps things simple — quote if it contains any
|
||||
// character that would otherwise need escaping in plain scalars.
|
||||
function yamlScalar(value: unknown): string {
|
||||
if (value === null || value === undefined) return "null";
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
const s = String(value);
|
||||
if (s === "") return '""';
|
||||
if (/^[A-Za-z0-9._/:\- ]+$/.test(s) && !/^\s|\s$/.test(s)) return s;
|
||||
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
export function serialiseFrontmatter(fm: Record<string, unknown>): string {
|
||||
const lines = ["---"];
|
||||
for (const [key, value] of Object.entries(fm)) {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
lines.push(`${key}: []`);
|
||||
} else {
|
||||
lines.push(`${key}:`);
|
||||
for (const v of value) lines.push(` - ${yamlScalar(v)}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(`${key}: ${yamlScalar(value)}`);
|
||||
}
|
||||
}
|
||||
lines.push("---");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// Produce an Obsidian-flavoured markdown document for a transcript.
|
||||
export function buildMarkdown(item: TranscriptEntry): string {
|
||||
const fm = buildFrontmatter(item);
|
||||
const header = serialiseFrontmatter(fm);
|
||||
const title = fm.title || "Transcript";
|
||||
const body = item?.text || "";
|
||||
return `${header}\n\n# ${title}\n\n${body}\n`;
|
||||
}
|
||||
import type { TranscriptEntry } from "$lib/types/app";
|
||||
Reference in New Issue
Block a user