feat(kon): scaffold hybrid modular workspace
- Cargo workspace with 6 domain crates: core, audio, transcription, ai-formatting, storage, cloud-providers - Minimal Tauri shell (lib.rs + main.rs) with plugin registration - Svelte 5 frontend copied from Ramble v0.2 - All crates compile as empty stubs - App identifier: uk.co.corbel.kon Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
30
src/lib/utils/constants.js
Normal file
30
src/lib/utils/constants.js
Normal file
@@ -0,0 +1,30 @@
|
||||
// Timing
|
||||
export const FEEDBACK_TIMEOUT_MS = 3000;
|
||||
export const HOTKEY_FEEDBACK_MS = 1500;
|
||||
export const HISTORY_MAX_ENTRIES = 100;
|
||||
export const MAX_PCM_SAMPLES = 4_800_000; // 5 min at 16kHz
|
||||
export const SIDEBAR_MAX_TASKS = 30;
|
||||
export const CHUNK_INTERVAL_MS = 3000;
|
||||
export const MIN_CHUNK_SAMPLES = 8000;
|
||||
|
||||
// Buckets
|
||||
export const BUCKET_COLORS = {
|
||||
inbox: "text-text-tertiary",
|
||||
today: "text-accent",
|
||||
soon: "text-warning",
|
||||
later: "text-text-secondary",
|
||||
};
|
||||
|
||||
export const BUCKET_DOT_COLORS = {
|
||||
inbox: "bg-text-tertiary",
|
||||
today: "bg-accent",
|
||||
soon: "bg-warning",
|
||||
later: "bg-text-secondary",
|
||||
};
|
||||
|
||||
// Effort
|
||||
export const EFFORT_LABELS = { quick: "Quick", medium: "Medium", deep: "Deep" };
|
||||
export const EFFORT_ORDER = { quick: 1, medium: 2, deep: 3, "": 4 };
|
||||
|
||||
// Playback
|
||||
export const PLAYBACK_SPEEDS = [0.5, 1, 1.5, 2, 3, 5];
|
||||
116
src/lib/utils/export.js
Normal file
116
src/lib/utils/export.js
Normal file
@@ -0,0 +1,116 @@
|
||||
import { pad, formatTimeSRT, formatTimeVTT } from "./time.js";
|
||||
|
||||
/**
|
||||
* Export transcript in various formats.
|
||||
* @param {string} text - Plain text transcript
|
||||
* @param {Array<{start: number, end: number, text: string}>} segments - Timed segments
|
||||
* @param {string} format - "txt" | "md" | "csv" | "html" | "srt" | "vtt"
|
||||
* @returns {string}
|
||||
*/
|
||||
export function exportTranscript(text, segments, format) {
|
||||
switch (format) {
|
||||
case "srt":
|
||||
return toSRT(segments);
|
||||
case "vtt":
|
||||
return toVTT(segments);
|
||||
case "md":
|
||||
return toMarkdown(text, segments);
|
||||
case "csv":
|
||||
return toCSV(segments);
|
||||
case "html":
|
||||
return toHTML(text, segments);
|
||||
default:
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function toSRT(segments) {
|
||||
if (!segments || segments.length === 0) return "";
|
||||
return segments
|
||||
.map((seg, i) => {
|
||||
return `${i + 1}\n${formatTimeSRT(seg.start)} --> ${formatTimeSRT(seg.end)}\n${seg.text.trim()}\n`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function toVTT(segments) {
|
||||
if (!segments || segments.length === 0) return "";
|
||||
let out = "WEBVTT\n\n";
|
||||
out += segments
|
||||
.map((seg, i) => {
|
||||
return `${i + 1}\n${formatTimeVTT(seg.start)} --> ${formatTimeVTT(seg.end)}\n${seg.text.trim()}\n`;
|
||||
})
|
||||
.join("\n");
|
||||
return out;
|
||||
}
|
||||
|
||||
function toCSV(segments) {
|
||||
if (!segments || segments.length === 0) return "Start,End,Text\n";
|
||||
let csv = "Start,End,Text\n";
|
||||
for (const seg of segments) {
|
||||
const text = seg.text.trim().replace(/"/g, '""');
|
||||
csv += `${seg.start.toFixed(2)},${seg.end.toFixed(2)},"${text}"\n`;
|
||||
}
|
||||
return csv;
|
||||
}
|
||||
|
||||
function toHTML(text, segments) {
|
||||
let html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Ramble Transcript</title>
|
||||
<style>
|
||||
body { font-family: "DM Sans", system-ui, sans-serif; max-width: 700px; margin: 2rem auto; padding: 0 1rem; color: #1a1816; line-height: 1.7; }
|
||||
h1 { font-family: "Instrument Serif", Georgia, serif; font-style: italic; }
|
||||
.meta { color: #9a9486; font-size: 0.85rem; margin-bottom: 1.5rem; }
|
||||
.segment { display: flex; gap: 1rem; padding: 0.4rem 0; }
|
||||
.timestamp { color: #9a9486; font-size: 0.8rem; min-width: 3rem; font-variant-numeric: tabular-nums; padding-top: 0.15rem; }
|
||||
.text { flex: 1; }
|
||||
.starred { border-left: 3px solid #e8a87c; padding-left: 0.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Transcript</h1>
|
||||
<p class="meta">${new Date().toLocaleDateString("en-GB")}`;
|
||||
if (segments?.length > 0) {
|
||||
const dur = segments[segments.length - 1].end;
|
||||
html += ` · ${Math.round(dur)}s`;
|
||||
}
|
||||
html += `</p>\n`;
|
||||
|
||||
if (segments?.length > 0) {
|
||||
for (const seg of segments) {
|
||||
const mins = Math.floor(seg.start / 60);
|
||||
const secs = Math.floor(seg.start % 60);
|
||||
const starClass = seg.starred ? ' starred' : '';
|
||||
html += `<div class="segment${starClass}"><span class="timestamp">${pad(mins)}:${pad(secs)}</span><span class="text">${seg.text.trim()}</span></div>\n`;
|
||||
}
|
||||
} else {
|
||||
html += `<p>${text.replace(/\n/g, "<br>")}</p>`;
|
||||
}
|
||||
html += `</body></html>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
function toMarkdown(text, segments) {
|
||||
let md = `# Transcription\n\n`;
|
||||
md += `**Date:** ${new Date().toLocaleDateString("en-GB")}\n`;
|
||||
if (segments && segments.length > 0) {
|
||||
const duration = segments[segments.length - 1].end;
|
||||
md += `**Duration:** ${Math.round(duration)}s\n`;
|
||||
}
|
||||
md += `\n---\n\n`;
|
||||
|
||||
if (segments && segments.length > 0) {
|
||||
segments.forEach((seg) => {
|
||||
const mins = Math.floor(seg.start / 60);
|
||||
const secs = Math.floor(seg.start % 60);
|
||||
md += `[${pad(mins)}:${pad(secs)}] ${seg.text.trim()}\n\n`;
|
||||
});
|
||||
} else {
|
||||
md += text;
|
||||
}
|
||||
|
||||
return md;
|
||||
}
|
||||
127
src/lib/utils/taskExtractor.js
Normal file
127
src/lib/utils/taskExtractor.js
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
46
src/lib/utils/time.js
Normal file
46
src/lib/utils/time.js
Normal file
@@ -0,0 +1,46 @@
|
||||
/** Pad number to 2 digits */
|
||||
export function pad(n) {
|
||||
return n.toString().padStart(2, "0");
|
||||
}
|
||||
|
||||
/** Format seconds as M:SS (e.g. 1:05) */
|
||||
export function formatTime(seconds) {
|
||||
if (!seconds || isNaN(seconds)) return "0:00";
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60).toString().padStart(2, "0");
|
||||
return `${m}:${s}`;
|
||||
}
|
||||
|
||||
/** Format seconds as human duration (e.g. 2m 30s) */
|
||||
export function formatDuration(seconds) {
|
||||
if (!seconds) return "";
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.round(seconds % 60);
|
||||
return m > 0 ? `${m}m ${s}s` : `${s}s`;
|
||||
}
|
||||
|
||||
/** Format ISO timestamp as D Mon HH:MM (e.g. 15 Mar 18:11) */
|
||||
export function formatTimestamp(iso) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString("en-GB", { day: "numeric", month: "short" }) +
|
||||
" " + d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
/** Format seconds as SRT timestamp (HH:MM:SS,mmm) */
|
||||
export function formatTimeSRT(seconds) {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
const ms = Math.floor((seconds % 1) * 1000);
|
||||
return `${pad(h)}:${pad(m)}:${pad(s)},${ms.toString().padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
/** Format seconds as VTT timestamp (HH:MM:SS.mmm) */
|
||||
export function formatTimeVTT(seconds) {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
const ms = Math.floor((seconds % 1) * 1000);
|
||||
return `${pad(h)}:${pad(m)}:${pad(s)}.${ms.toString().padStart(3, "0")}`;
|
||||
}
|
||||
Reference in New Issue
Block a user