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:
@@ -1,28 +0,0 @@
|
||||
const PRETEXT_FONT_FAMILIES = {
|
||||
lexend: "'Lexend'",
|
||||
atkinson: "'Atkinson Hyperlegible Next'",
|
||||
opendyslexic: "'OpenDyslexic'",
|
||||
};
|
||||
|
||||
export function pretextFontFamily(fontFamily = "lexend") {
|
||||
return PRETEXT_FONT_FAMILIES[fontFamily] || PRETEXT_FONT_FAMILIES.lexend;
|
||||
}
|
||||
|
||||
export function pretextFontShorthand(accessibility = {}, sizePx = 16) {
|
||||
return `${sizePx}px ${pretextFontFamily(accessibility.fontFamily)}`;
|
||||
}
|
||||
|
||||
export function bodyPretextLineHeight(accessibility = {}, sizePx = 16) {
|
||||
const ratio = accessibility.lineHeight || 1.5;
|
||||
return Math.round(sizePx * ratio);
|
||||
}
|
||||
|
||||
export function transcriptPretextFont(accessibility = {}) {
|
||||
const sizePx = accessibility.transcriptSize || accessibility.fontSize || 16;
|
||||
return pretextFontShorthand(accessibility, sizePx);
|
||||
}
|
||||
|
||||
export function transcriptPretextLineHeight(accessibility = {}, ratio = 1.85) {
|
||||
const sizePx = accessibility.transcriptSize || accessibility.fontSize || 16;
|
||||
return Math.round(sizePx * ratio);
|
||||
}
|
||||
41
src/lib/utils/accessibilityTypography.ts
Normal file
41
src/lib/utils/accessibilityTypography.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { AccessibilityPreferences, FontFamily } from "$lib/types/app";
|
||||
|
||||
const PRETEXT_FONT_FAMILIES = {
|
||||
lexend: "'Lexend'",
|
||||
atkinson: "'Atkinson Hyperlegible Next'",
|
||||
opendyslexic: "'OpenDyslexic'",
|
||||
} satisfies Record<FontFamily, string>;
|
||||
|
||||
export function pretextFontFamily(fontFamily: FontFamily = "lexend"): string {
|
||||
return PRETEXT_FONT_FAMILIES[fontFamily] || PRETEXT_FONT_FAMILIES.lexend;
|
||||
}
|
||||
|
||||
export function pretextFontShorthand(
|
||||
accessibility: Partial<AccessibilityPreferences> = {},
|
||||
sizePx = 16,
|
||||
): string {
|
||||
return `${sizePx}px ${pretextFontFamily(accessibility.fontFamily)}`;
|
||||
}
|
||||
|
||||
export function bodyPretextLineHeight(
|
||||
accessibility: Partial<AccessibilityPreferences> = {},
|
||||
sizePx = 16,
|
||||
): number {
|
||||
const ratio = accessibility.lineHeight || 1.5;
|
||||
return Math.round(sizePx * ratio);
|
||||
}
|
||||
|
||||
export function transcriptPretextFont(
|
||||
accessibility: Partial<AccessibilityPreferences> = {},
|
||||
): string {
|
||||
const sizePx = accessibility.transcriptSize || accessibility.fontSize || 16;
|
||||
return pretextFontShorthand(accessibility, sizePx);
|
||||
}
|
||||
|
||||
export function transcriptPretextLineHeight(
|
||||
accessibility: Partial<AccessibilityPreferences> = {},
|
||||
ratio = 1.85,
|
||||
): number {
|
||||
const sizePx = accessibility.transcriptSize || accessibility.fontSize || 16;
|
||||
return Math.round(sizePx * ratio);
|
||||
}
|
||||
8
src/lib/utils/errors.ts
Normal file
8
src/lib/utils/errors.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function errorMessage(error: unknown): string {
|
||||
if (typeof error === "string") return error;
|
||||
if (error && typeof error === "object" && "message" in error) {
|
||||
const message = (error as { message?: unknown }).message;
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { pad, formatTimeSRT, formatTimeVTT } from "./time.js";
|
||||
import type { Segment } from "$lib/types/app";
|
||||
|
||||
type ExportFormat = "txt" | "md" | "csv" | "html" | "srt" | "vtt";
|
||||
|
||||
/**
|
||||
* Export transcript in various formats.
|
||||
@@ -7,7 +10,11 @@ import { pad, formatTimeSRT, formatTimeVTT } from "./time.js";
|
||||
* @param {string} format - "txt" | "md" | "csv" | "html" | "srt" | "vtt"
|
||||
* @returns {string}
|
||||
*/
|
||||
export function exportTranscript(text, segments, format) {
|
||||
export function exportTranscript(
|
||||
text: string,
|
||||
segments: Segment[],
|
||||
format: ExportFormat,
|
||||
): string {
|
||||
switch (format) {
|
||||
case "srt":
|
||||
return toSRT(segments);
|
||||
@@ -24,7 +31,7 @@ export function exportTranscript(text, segments, format) {
|
||||
}
|
||||
}
|
||||
|
||||
function toSRT(segments) {
|
||||
function toSRT(segments: Segment[]): string {
|
||||
if (!segments || segments.length === 0) return "";
|
||||
return segments
|
||||
.map((seg, i) => {
|
||||
@@ -33,7 +40,7 @@ function toSRT(segments) {
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function toVTT(segments) {
|
||||
function toVTT(segments: Segment[]): string {
|
||||
if (!segments || segments.length === 0) return "";
|
||||
let out = "WEBVTT\n\n";
|
||||
out += segments
|
||||
@@ -44,7 +51,7 @@ function toVTT(segments) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function toCSV(segments) {
|
||||
function toCSV(segments: Segment[]): string {
|
||||
if (!segments || segments.length === 0) return "Start,End,Text\n";
|
||||
let csv = "Start,End,Text\n";
|
||||
for (const seg of segments) {
|
||||
@@ -54,11 +61,11 @@ function toCSV(segments) {
|
||||
return csv;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
function escapeHtml(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function toHTML(text, segments) {
|
||||
function toHTML(text: string, segments: Segment[]): string {
|
||||
let html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -95,7 +102,7 @@ function toHTML(text, segments) {
|
||||
return html;
|
||||
}
|
||||
|
||||
function toMarkdown(text, segments) {
|
||||
function toMarkdown(text: string, segments: Segment[]): string {
|
||||
let md = `# Transcription\n\n`;
|
||||
md += `**Date:** ${new Date().toLocaleDateString("en-GB")}\n`;
|
||||
if (segments && segments.length > 0) {
|
||||
@@ -25,12 +25,12 @@ const WORD_BUCKETS = [
|
||||
{ max: Infinity, tag: "words:very-long" },
|
||||
];
|
||||
|
||||
function durationTag(seconds) {
|
||||
function durationTag(seconds: number): string | null {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return null;
|
||||
return DURATION_BUCKETS.find((b) => seconds < b.max)?.tag ?? null;
|
||||
}
|
||||
|
||||
function wordCountTag(text) {
|
||||
function wordCountTag(text: string): string | null {
|
||||
if (!text || typeof text !== "string") return null;
|
||||
const count = text.trim().split(/\s+/).filter(Boolean).length;
|
||||
return WORD_BUCKETS.find((b) => count < b.max)?.tag ?? null;
|
||||
@@ -39,7 +39,7 @@ function wordCountTag(text) {
|
||||
// Resolve time-of-day bucket from an ISO date or a legacy string like
|
||||
// "19/04/2026, 11:37:23". Thresholds are fixed and local to the user's
|
||||
// machine — hour 6-11 morning, 12-17 afternoon, 18-21 evening, else night.
|
||||
function timeOfDayTag(dateStr) {
|
||||
function timeOfDayTag(dateStr: string): string | null {
|
||||
if (!dateStr) return null;
|
||||
let ts = Date.parse(dateStr);
|
||||
if (Number.isNaN(ts)) {
|
||||
@@ -62,7 +62,7 @@ function timeOfDayTag(dateStr) {
|
||||
return "time:night";
|
||||
}
|
||||
|
||||
function sourceTag(source) {
|
||||
function sourceTag(source: string): string | null {
|
||||
if (!source) return null;
|
||||
const s = String(source).toLowerCase();
|
||||
if (s.includes("file")) return "source:file";
|
||||
@@ -76,17 +76,17 @@ function sourceTag(source) {
|
||||
// information and add cognitive load without improving retrieval. The
|
||||
// function is kept as a hook for one future AI-derived content tag
|
||||
// (`topic:*`) once kon-llm wires up real llama-cpp-2 in Phase 3.
|
||||
export function deriveAutoTags(_item) {
|
||||
export function deriveAutoTags(_item: TranscriptEntry): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
export function normaliseTag(raw) {
|
||||
export function normaliseTag(raw: string): string {
|
||||
return String(raw || "").trim().toLowerCase().replace(/\s+/g, "-");
|
||||
}
|
||||
|
||||
// Build the flat frontmatter object that represents a transcript's metadata.
|
||||
// Shown in the expanded History row and serialised when exporting to .md.
|
||||
export function buildFrontmatter(item) {
|
||||
export function buildFrontmatter(item: TranscriptEntry | null) {
|
||||
if (!item) return {};
|
||||
const auto = deriveAutoTags(item);
|
||||
const manual = Array.isArray(item.manualTags) ? item.manualTags : [];
|
||||
@@ -105,7 +105,7 @@ export function buildFrontmatter(item) {
|
||||
|
||||
// Escape a YAML scalar. Keeps things simple — quote if it contains any
|
||||
// character that would otherwise need escaping in plain scalars.
|
||||
function yamlScalar(value) {
|
||||
function yamlScalar(value: unknown): string {
|
||||
if (value === null || value === undefined) return "null";
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
const s = String(value);
|
||||
@@ -114,7 +114,7 @@ function yamlScalar(value) {
|
||||
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
export function serialiseFrontmatter(fm) {
|
||||
export function serialiseFrontmatter(fm: Record<string, unknown>): string {
|
||||
const lines = ["---"];
|
||||
for (const [key, value] of Object.entries(fm)) {
|
||||
if (Array.isArray(value)) {
|
||||
@@ -133,10 +133,11 @@ export function serialiseFrontmatter(fm) {
|
||||
}
|
||||
|
||||
// Produce an Obsidian-flavoured markdown document for a transcript.
|
||||
export function buildMarkdown(item) {
|
||||
export function buildMarkdown(item: TranscriptEntry): string {
|
||||
const fm = buildFrontmatter(item);
|
||||
const header = serialiseFrontmatter(fm);
|
||||
const title = fm.title || "Transcript";
|
||||
const body = item?.text || "";
|
||||
return `${header}\n\n# ${title}\n\n${body}\n`;
|
||||
}
|
||||
import type { TranscriptEntry } from "$lib/types/app";
|
||||
@@ -18,7 +18,17 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { hasTauriRuntime } from './runtime.js';
|
||||
|
||||
let cached = null;
|
||||
interface OsInfo {
|
||||
os: string;
|
||||
arch: string;
|
||||
family: string;
|
||||
usesCmd: boolean;
|
||||
isWayland: boolean;
|
||||
customHotkeyBackend: boolean;
|
||||
primaryModifierLabel: string;
|
||||
}
|
||||
|
||||
let cached: OsInfo | null = null;
|
||||
|
||||
const FALLBACK_BROWSER_INFO = {
|
||||
os: detectBrowserOs(),
|
||||
@@ -52,14 +62,14 @@ function detectBrowserFamily() {
|
||||
/** Fetch OS info from the Tauri backend. Idempotent — call multiple times,
|
||||
* only the first does the round-trip. Browser-preview mode returns the
|
||||
* navigator-detected fallback. */
|
||||
export async function loadOsInfo() {
|
||||
export async function loadOsInfo(): Promise<OsInfo> {
|
||||
if (cached) return cached;
|
||||
if (!hasTauriRuntime()) {
|
||||
cached = FALLBACK_BROWSER_INFO;
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
cached = await invoke('get_os_info');
|
||||
cached = await invoke<OsInfo>("get_os_info");
|
||||
} catch (err) {
|
||||
console.warn('loadOsInfo: get_os_info failed, using browser fallback', err);
|
||||
cached = FALLBACK_BROWSER_INFO;
|
||||
@@ -70,31 +80,31 @@ export async function loadOsInfo() {
|
||||
/** Synchronous accessor. Returns null if loadOsInfo has not been awaited
|
||||
* yet. Components that need the value at first render should await
|
||||
* loadOsInfo() in onMount before reading. */
|
||||
export function osInfo() {
|
||||
export function osInfo(): OsInfo | null {
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function isMac() {
|
||||
export function isMac(): boolean {
|
||||
return cached?.os === 'macos';
|
||||
}
|
||||
|
||||
export function isWindows() {
|
||||
export function isWindows(): boolean {
|
||||
return cached?.os === 'windows';
|
||||
}
|
||||
|
||||
export function isLinux() {
|
||||
export function isLinux(): boolean {
|
||||
return cached?.os === 'linux';
|
||||
}
|
||||
|
||||
/** Localised label for the primary keyboard modifier. "Cmd" on macOS,
|
||||
* "Ctrl" everywhere else. Use in hotkey display strings:
|
||||
* `${modKeyLabel()}+Shift+R` */
|
||||
export function modKeyLabel() {
|
||||
export function modKeyLabel(): string {
|
||||
return cached?.primaryModifierLabel ?? 'Ctrl';
|
||||
}
|
||||
|
||||
/** True if the current Linux session is Wayland. False on Windows/macOS.
|
||||
* Useful for Settings → Audio "PipeWire detected" status display. */
|
||||
export function isWayland() {
|
||||
export function isWayland(): boolean {
|
||||
return !!cached?.isWayland;
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
// behaviour for browser-preview mode.
|
||||
|
||||
export function hasTauriRuntime() {
|
||||
if (typeof window === 'undefined') return false;
|
||||
if (typeof window === "undefined") return false;
|
||||
if (window.__TAURI_INTERNALS__) return true;
|
||||
if (window.isTauri === true) return true;
|
||||
return false;
|
||||
8
src/lib/utils/storage.ts
Normal file
8
src/lib/utils/storage.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function parseStoredJson<T>(raw: string | null): T | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* Rule-based task extraction from transcripts.
|
||||
* Identifies action items from natural speech patterns.
|
||||
*/
|
||||
|
||||
// Action verbs that typically start task sentences
|
||||
const ACTION_VERBS = [
|
||||
"call", "email", "send", "write", "build", "fix", "update", "check",
|
||||
"review", "schedule", "book", "create", "set up", "follow up", "organise",
|
||||
"prepare", "draft", "submit", "cancel", "confirm", "arrange", "order",
|
||||
"buy", "get", "find", "look into", "research", "test", "deploy", "push",
|
||||
"move", "add", "remove", "delete", "install", "configure", "contact",
|
||||
"message", "tell", "ask", "invite", "remind", "finish", "complete",
|
||||
"start", "begin", "plan", "design", "implement", "refactor",
|
||||
];
|
||||
|
||||
// Phrases that signal a task
|
||||
const TASK_PHRASES = [
|
||||
"need to", "needs to", "should", "must", "have to", "has to",
|
||||
"want to", "going to", "gonna", "got to", "gotta",
|
||||
"don't forget to", "remember to", "make sure to", "make sure we",
|
||||
"let's", "we should", "i should", "we need to", "i need to",
|
||||
"we have to", "i have to", "we must", "i must",
|
||||
];
|
||||
|
||||
// Explicit markers (user intentionally marks a task)
|
||||
const EXPLICIT_MARKERS = [
|
||||
"action:", "action item:", "todo:", "task:", "to do:",
|
||||
"action item", "follow up:", "follow-up:",
|
||||
];
|
||||
|
||||
/**
|
||||
* Extract tasks from a transcript string.
|
||||
* Returns an array of { text, confidence } objects.
|
||||
*/
|
||||
export function extractTasks(transcript) {
|
||||
if (!transcript || !transcript.trim()) return [];
|
||||
|
||||
const tasks = [];
|
||||
const seen = new Set();
|
||||
|
||||
// Split into sentences (rough but effective for speech)
|
||||
const sentences = splitSentences(transcript);
|
||||
|
||||
for (const sentence of sentences) {
|
||||
const trimmed = sentence.trim();
|
||||
if (trimmed.length < 5) continue;
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
let matched = false;
|
||||
let confidence = 0;
|
||||
|
||||
// Check explicit markers (highest confidence)
|
||||
for (const marker of EXPLICIT_MARKERS) {
|
||||
if (lower.startsWith(marker) || lower.includes(marker)) {
|
||||
const taskText = extractAfterMarker(trimmed, marker);
|
||||
if (taskText && taskText.length > 3) {
|
||||
addTask(tasks, seen, capitalise(taskText), 0.95);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matched) continue;
|
||||
|
||||
// Check task phrases (high confidence)
|
||||
for (const phrase of TASK_PHRASES) {
|
||||
const idx = lower.indexOf(phrase);
|
||||
if (idx >= 0) {
|
||||
const taskText = extractTaskFromPhrase(trimmed, phrase, idx);
|
||||
if (taskText && taskText.length > 3) {
|
||||
addTask(tasks, seen, capitalise(taskText), 0.8);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matched) continue;
|
||||
|
||||
// Check if sentence starts with an action verb (medium confidence)
|
||||
const firstWord = lower.split(/\s+/)[0];
|
||||
const firstTwo = lower.split(/\s+/).slice(0, 2).join(" ");
|
||||
if (ACTION_VERBS.includes(firstWord) || ACTION_VERBS.includes(firstTwo)) {
|
||||
addTask(tasks, seen, capitalise(trimmed), 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
function splitSentences(text) {
|
||||
// Split on sentence-ending punctuation, newlines, or common speech breaks
|
||||
return text
|
||||
.split(/(?<=[.!?])\s+|\n+/)
|
||||
.flatMap((s) => s.split(/(?:,\s*(?:and\s+)?)?(?=(?:then|also|plus)\s)/i))
|
||||
.filter((s) => s.trim().length > 0);
|
||||
}
|
||||
|
||||
function extractAfterMarker(sentence, marker) {
|
||||
const lower = sentence.toLowerCase();
|
||||
const idx = lower.indexOf(marker);
|
||||
if (idx < 0) return null;
|
||||
return sentence.slice(idx + marker.length).trim().replace(/^[:\-–—]\s*/, "");
|
||||
}
|
||||
|
||||
function extractTaskFromPhrase(sentence, phrase, idx) {
|
||||
// Extract "need to X" → "X"
|
||||
const afterPhrase = sentence.slice(idx + phrase.length).trim();
|
||||
if (afterPhrase) return afterPhrase;
|
||||
return null;
|
||||
}
|
||||
|
||||
function capitalise(text) {
|
||||
if (!text) return text;
|
||||
// Clean up leading punctuation/whitespace
|
||||
const cleaned = text.replace(/^[,;:\-–—\s]+/, "").trim();
|
||||
if (!cleaned) return text;
|
||||
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
|
||||
}
|
||||
|
||||
function addTask(tasks, seen, text, confidence) {
|
||||
// Remove trailing punctuation for dedup
|
||||
const key = text.toLowerCase().replace(/[.!?,;:]+$/, "").trim();
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
tasks.push({ text: text.replace(/[.!?]+$/, "").trim(), confidence });
|
||||
}
|
||||
224
src/lib/utils/taskExtractor.ts
Normal file
224
src/lib/utils/taskExtractor.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Rule-based task extraction from transcripts.
|
||||
* Identifies action items from natural speech patterns.
|
||||
*/
|
||||
|
||||
// Action verbs that typically start task sentences
|
||||
const ACTION_VERBS = [
|
||||
"call", "email", "send", "write", "build", "fix", "update", "check",
|
||||
"review", "schedule", "book", "create", "set up", "follow up", "organise",
|
||||
"prepare", "draft", "submit", "cancel", "confirm", "arrange", "order",
|
||||
"buy", "get", "find", "look into", "research", "test", "deploy", "push",
|
||||
"move", "add", "remove", "delete", "install", "configure", "contact",
|
||||
"message", "tell", "ask", "invite", "remind", "finish", "complete",
|
||||
"start", "begin", "plan", "design", "implement", "refactor",
|
||||
];
|
||||
|
||||
// Phrases that signal a task
|
||||
const TASK_PHRASES = [
|
||||
"need to", "needs to", "should", "must", "have to", "has to",
|
||||
"want to", "going to", "gonna", "got to", "gotta",
|
||||
"don't forget to", "remember to", "make sure to", "make sure we",
|
||||
"let's", "we should", "i should", "we need to", "i need to",
|
||||
"we have to", "i have to", "we must", "i must",
|
||||
];
|
||||
|
||||
// Explicit markers (user intentionally marks a task)
|
||||
const EXPLICIT_MARKERS = [
|
||||
"action:", "action item:", "todo:", "task:", "to do:",
|
||||
"action item", "follow up:", "follow-up:",
|
||||
];
|
||||
|
||||
const SORTED_TASK_PHRASES = [...TASK_PHRASES].sort((a, b) => b.length - a.length);
|
||||
const LISTABLE_VERBS = ["get", "buy", "pick up", "order"];
|
||||
const LISTABLE_VERB_PATTERN = new RegExp(`^(${LISTABLE_VERBS.join("|")})\\s+(.+)$`, "i");
|
||||
|
||||
/**
|
||||
* Extract tasks from a transcript string.
|
||||
* Returns an array of { text, confidence } objects.
|
||||
*/
|
||||
export function extractTasks(transcript: string): ExtractedTask[] {
|
||||
if (!transcript || !transcript.trim()) return [];
|
||||
|
||||
const tasks: ExtractedTask[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// Split into sentences (rough but effective for speech)
|
||||
const sentences = splitSentences(transcript);
|
||||
|
||||
for (const sentence of sentences) {
|
||||
const trimmed = sentence.trim();
|
||||
if (trimmed.length < 5) continue;
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
let matched = false;
|
||||
|
||||
// Check explicit markers (highest confidence)
|
||||
for (const marker of EXPLICIT_MARKERS) {
|
||||
if (lower.startsWith(marker) || lower.includes(marker)) {
|
||||
const taskText = extractAfterMarker(trimmed, marker);
|
||||
if (taskText && taskText.length > 3) {
|
||||
addTask(tasks, seen, capitalise(taskText), 0.95);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matched) continue;
|
||||
|
||||
// Check task phrases (high confidence)
|
||||
const phraseMatches = findPhraseMatches(lower);
|
||||
if (phraseMatches.length > 0) {
|
||||
for (let i = 0; i < phraseMatches.length; i += 1) {
|
||||
const match = phraseMatches[i];
|
||||
const nextMatch = phraseMatches[i + 1] ?? null;
|
||||
const taskCandidates = extractTaskCandidatesFromPhrase(trimmed, match, nextMatch);
|
||||
for (const candidate of taskCandidates) {
|
||||
if (candidate.length > 3) {
|
||||
addTask(tasks, seen, capitalise(candidate), 0.8);
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matched) continue;
|
||||
|
||||
// Check if sentence starts with an action verb (medium confidence)
|
||||
const firstWord = lower.split(/\s+/)[0];
|
||||
const firstTwo = lower.split(/\s+/).slice(0, 2).join(" ");
|
||||
if (ACTION_VERBS.includes(firstWord) || ACTION_VERBS.includes(firstTwo)) {
|
||||
addTask(tasks, seen, capitalise(trimmed), 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
function splitSentences(text: string): string[] {
|
||||
// Split on sentence-ending punctuation, newlines, or common speech breaks
|
||||
return text
|
||||
.split(/(?<=[.!?])\s+|\n+/)
|
||||
.flatMap((s) => s.split(/(?:,\s*(?:and\s+)?)?(?=(?:then|also|plus)\s)/i))
|
||||
.filter((s) => s.trim().length > 0);
|
||||
}
|
||||
|
||||
function extractAfterMarker(sentence: string, marker: string): string | null {
|
||||
const lower = sentence.toLowerCase();
|
||||
const idx = lower.indexOf(marker);
|
||||
if (idx < 0) return null;
|
||||
const extracted = sentence.slice(idx + marker.length).trim().replace(/^[:\-–—]\s*/, "");
|
||||
return cleanTaskText(extracted);
|
||||
}
|
||||
|
||||
function findPhraseMatches(lower: string): PhraseMatch[] {
|
||||
const matches: PhraseMatch[] = [];
|
||||
|
||||
for (let i = 0; i < lower.length; i += 1) {
|
||||
for (const phrase of SORTED_TASK_PHRASES) {
|
||||
if (!lower.startsWith(phrase, i)) continue;
|
||||
if (!hasPhraseBoundary(lower, i, phrase.length)) continue;
|
||||
matches.push({ index: i, phrase });
|
||||
i += phrase.length - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
function hasPhraseBoundary(text: string, index: number, length: number): boolean {
|
||||
const before = index === 0 ? "" : text[index - 1];
|
||||
const after = index + length >= text.length ? "" : text[index + length];
|
||||
return isBoundaryChar(before) && isBoundaryChar(after);
|
||||
}
|
||||
|
||||
function isBoundaryChar(char: string): boolean {
|
||||
return !char || /[^a-z0-9]/i.test(char);
|
||||
}
|
||||
|
||||
function extractTaskCandidatesFromPhrase(
|
||||
sentence: string,
|
||||
match: PhraseMatch,
|
||||
nextMatch: PhraseMatch | null,
|
||||
): string[] {
|
||||
const end = nextMatch?.index ?? sentence.length;
|
||||
const afterPhrase = sentence.slice(match.index + match.phrase.length, end).trim();
|
||||
const cleaned = cleanTaskText(afterPhrase);
|
||||
if (!cleaned) return [];
|
||||
return expandListTask(cleaned);
|
||||
}
|
||||
|
||||
function cleanTaskText(text: string): string {
|
||||
return text
|
||||
.replace(/^[,;:\-–—\s]+/, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/[.!?]+$/, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function expandListTask(taskText: string): string[] {
|
||||
const match = taskText.match(LISTABLE_VERB_PATTERN);
|
||||
if (!match) return [taskText];
|
||||
|
||||
const [, verb, remainder] = match;
|
||||
if (!/(?:,|&|\band\b)/i.test(remainder)) {
|
||||
return [taskText];
|
||||
}
|
||||
|
||||
const items = remainder
|
||||
.split(/\s*(?:,|&|\band\b)\s*/i)
|
||||
.map((item) => cleanTaskText(item))
|
||||
.filter(Boolean);
|
||||
|
||||
if (
|
||||
items.length < 2
|
||||
|| items.some((item) => item.split(/\s+/).length > 4)
|
||||
|| items.some((item) => containsActionLanguage(item))
|
||||
) {
|
||||
return [taskText];
|
||||
}
|
||||
|
||||
return items.map((item) => `${verb} ${item}`);
|
||||
}
|
||||
|
||||
function containsActionLanguage(text: string): boolean {
|
||||
const lower = text.toLowerCase();
|
||||
const words = lower.split(/\s+/);
|
||||
const firstWord = words[0] ?? "";
|
||||
const firstTwo = words.slice(0, 2).join(" ");
|
||||
return (
|
||||
ACTION_VERBS.includes(firstWord)
|
||||
|| ACTION_VERBS.includes(firstTwo)
|
||||
|| TASK_PHRASES.some((phrase) => lower.includes(phrase))
|
||||
);
|
||||
}
|
||||
|
||||
function capitalise(text: string): string {
|
||||
if (!text) return text;
|
||||
// Clean up leading punctuation/whitespace
|
||||
const cleaned = text.replace(/^[,;:\-–—\s]+/, "").trim();
|
||||
if (!cleaned) return text;
|
||||
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
|
||||
}
|
||||
|
||||
function addTask(
|
||||
tasks: ExtractedTask[],
|
||||
seen: Set<string>,
|
||||
text: string,
|
||||
confidence: number,
|
||||
) {
|
||||
// Remove trailing punctuation for dedup
|
||||
const key = text.toLowerCase().replace(/[.!?,;:]+$/, "").trim();
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
tasks.push({ text: text.replace(/[.!?]+$/, "").trim(), confidence });
|
||||
}
|
||||
interface ExtractedTask {
|
||||
text: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
interface PhraseMatch {
|
||||
index: number;
|
||||
phrase: string;
|
||||
}
|
||||
@@ -3,7 +3,19 @@ import {
|
||||
layout,
|
||||
prepareWithSegments,
|
||||
layoutWithLines,
|
||||
} from '@chenglou/pretext';
|
||||
} from "@chenglou/pretext";
|
||||
|
||||
type LayoutOptions = Record<string, unknown>;
|
||||
type PreparedFactory = (
|
||||
text: string,
|
||||
font: string,
|
||||
options?: LayoutOptions,
|
||||
) => unknown;
|
||||
type ClampResult = ReturnType<typeof layoutWithLines> & {
|
||||
visibleLineCount: number;
|
||||
text: string;
|
||||
didTruncate: boolean;
|
||||
};
|
||||
|
||||
// Cache prepared text to avoid re-measuring.
|
||||
// prepare() is expensive, layout() is the cheap hot path.
|
||||
@@ -11,34 +23,44 @@ const prepareCache = new Map();
|
||||
const segmentedPrepareCache = new Map();
|
||||
const MAX_CACHE_ENTRIES = 400;
|
||||
|
||||
function touch(cache, key, value) {
|
||||
function touch<T>(cache: Map<string, T>, key: string, value: T): T {
|
||||
if (cache.has(key)) cache.delete(key);
|
||||
cache.set(key, value);
|
||||
if (cache.size > MAX_CACHE_ENTRIES) {
|
||||
const oldestKey = cache.keys().next().value;
|
||||
cache.delete(oldestKey);
|
||||
if (typeof oldestKey === "string") cache.delete(oldestKey);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function cacheKey(text, font, options = {}) {
|
||||
function cacheKey(text: string, font: string, options: LayoutOptions = {}): string {
|
||||
return `${text}\0${font}\0${JSON.stringify(options)}`;
|
||||
}
|
||||
|
||||
function getPrepared(cache, factory, text, font, options = {}) {
|
||||
function getPrepared(
|
||||
cache: Map<string, unknown>,
|
||||
factory: PreparedFactory,
|
||||
text: string,
|
||||
font: string,
|
||||
options: LayoutOptions = {},
|
||||
): unknown {
|
||||
const key = cacheKey(text, font, options);
|
||||
if (cache.has(key)) {
|
||||
const cached = cache.get(key);
|
||||
return touch(cache, key, cached);
|
||||
if (cached !== undefined) return touch(cache, key, cached);
|
||||
}
|
||||
return touch(cache, key, factory(text, font, options));
|
||||
}
|
||||
|
||||
export function prepareText(text, font, options = {}) {
|
||||
export function prepareText(text: string, font: string, options: LayoutOptions = {}): unknown {
|
||||
return getPrepared(prepareCache, prepare, text, font, options);
|
||||
}
|
||||
|
||||
export function prepareTextWithSegments(text, font, options = {}) {
|
||||
export function prepareTextWithSegments(
|
||||
text: string,
|
||||
font: string,
|
||||
options: LayoutOptions = {},
|
||||
): unknown {
|
||||
return getPrepared(
|
||||
segmentedPrepareCache,
|
||||
prepareWithSegments,
|
||||
@@ -59,7 +81,13 @@ export function prepareTextWithSegments(text, font, options = {}) {
|
||||
* @param {object} [options] - Additional options (e.g. { whiteSpace: 'pre-wrap' })
|
||||
* @returns {{ height: number, lineCount: number }}
|
||||
*/
|
||||
export function measureTextHeight(text, font, maxWidth, lineHeight, options = {}) {
|
||||
export function measureTextHeight(
|
||||
text: string,
|
||||
font: string,
|
||||
maxWidth: number,
|
||||
lineHeight: number,
|
||||
options: LayoutOptions = {},
|
||||
) {
|
||||
const prepared = prepareText(text, font, options);
|
||||
return layout(prepared, maxWidth, lineHeight);
|
||||
}
|
||||
@@ -68,14 +96,25 @@ export function measureTextHeight(text, font, maxWidth, lineHeight, options = {}
|
||||
* Measure height using pre-wrap mode (textarea-like behaviour).
|
||||
* Spaces, tabs, and hard breaks are preserved.
|
||||
*/
|
||||
export function measurePreWrap(text, font, maxWidth, lineHeight) {
|
||||
return measureTextHeight(text, font, maxWidth, lineHeight, { whiteSpace: 'pre-wrap' });
|
||||
export function measurePreWrap(
|
||||
text: string,
|
||||
font: string,
|
||||
maxWidth: number,
|
||||
lineHeight: number,
|
||||
) {
|
||||
return measureTextHeight(text, font, maxWidth, lineHeight, { whiteSpace: "pre-wrap" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Return full line layout information for custom rendering or clipping.
|
||||
*/
|
||||
export function layoutTextLines(text, font, maxWidth, lineHeight, options = {}) {
|
||||
export function layoutTextLines(
|
||||
text: string,
|
||||
font: string,
|
||||
maxWidth: number,
|
||||
lineHeight: number,
|
||||
options: LayoutOptions = {},
|
||||
) {
|
||||
const prepared = prepareTextWithSegments(text, font, options);
|
||||
return layoutWithLines(prepared, maxWidth, lineHeight);
|
||||
}
|
||||
@@ -84,13 +123,13 @@ export function layoutTextLines(text, font, maxWidth, lineHeight, options = {})
|
||||
* Clamp text to a maximum number of laid-out lines without DOM reads.
|
||||
*/
|
||||
export function clampTextLines(
|
||||
text,
|
||||
font,
|
||||
maxWidth,
|
||||
lineHeight,
|
||||
maxLines,
|
||||
options = {},
|
||||
) {
|
||||
text: string,
|
||||
font: string,
|
||||
maxWidth: number,
|
||||
lineHeight: number,
|
||||
maxLines: number,
|
||||
options: LayoutOptions = {},
|
||||
): ClampResult {
|
||||
const result = layoutTextLines(text, font, maxWidth, lineHeight, options);
|
||||
const visibleLines = result.lines.slice(0, maxLines);
|
||||
const didTruncate = result.lineCount > maxLines;
|
||||
@@ -1,10 +1,10 @@
|
||||
/** Pad number to 2 digits */
|
||||
export function pad(n) {
|
||||
export function pad(n: number): string {
|
||||
return n.toString().padStart(2, "0");
|
||||
}
|
||||
|
||||
/** Format seconds as M:SS (e.g. 1:05) */
|
||||
export function formatTime(seconds) {
|
||||
export function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return "0:00";
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60).toString().padStart(2, "0");
|
||||
@@ -12,7 +12,7 @@ export function formatTime(seconds) {
|
||||
}
|
||||
|
||||
/** Format seconds as human duration (e.g. 2m 30s) */
|
||||
export function formatDuration(seconds) {
|
||||
export function formatDuration(seconds: number): string {
|
||||
if (!seconds) return "";
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.round(seconds % 60);
|
||||
@@ -20,7 +20,7 @@ export function formatDuration(seconds) {
|
||||
}
|
||||
|
||||
/** Format ISO timestamp as D Mon HH:MM (e.g. 15 Mar 18:11) */
|
||||
export function formatTimestamp(iso) {
|
||||
export function formatTimestamp(iso: string | null | undefined): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString("en-GB", { day: "numeric", month: "short" }) +
|
||||
@@ -28,7 +28,7 @@ export function formatTimestamp(iso) {
|
||||
}
|
||||
|
||||
/** Format seconds as SRT timestamp (HH:MM:SS,mmm) */
|
||||
export function formatTimeSRT(seconds) {
|
||||
export function formatTimeSRT(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
@@ -37,7 +37,7 @@ export function formatTimeSRT(seconds) {
|
||||
}
|
||||
|
||||
/** Format seconds as VTT timestamp (HH:MM:SS.mmm) */
|
||||
export function formatTimeVTT(seconds) {
|
||||
export function formatTimeVTT(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
@@ -1,4 +1,4 @@
|
||||
export function buildCumulativeOffsets(heights) {
|
||||
export function buildCumulativeOffsets(heights: number[]): number[] {
|
||||
const offsets = new Array(heights.length + 1);
|
||||
offsets[0] = 0;
|
||||
for (let i = 0; i < heights.length; i++) {
|
||||
@@ -8,10 +8,10 @@ export function buildCumulativeOffsets(heights) {
|
||||
}
|
||||
|
||||
export function findVisibleRange(
|
||||
cumulativeOffsets,
|
||||
itemCount,
|
||||
scrollTop,
|
||||
viewportHeight,
|
||||
cumulativeOffsets: number[],
|
||||
itemCount: number,
|
||||
scrollTop: number,
|
||||
viewportHeight: number,
|
||||
buffer = 4,
|
||||
) {
|
||||
if (!cumulativeOffsets.length || itemCount === 0 || viewportHeight <= 0) {
|
||||
Reference in New Issue
Block a user