feat: DictaTask MVP - voice memos to structured tasks Chrome extension

This commit is contained in:
Bun Bun
2026-06-21 06:29:56 +00:00
commit 2039b98586
14 changed files with 2968 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
*.log
.verdict
.venv/
*.pem
*.crx
+1500
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "DictaTask",
"version": "1.0.0",
"description": "Voice Memos to Structured Tasks",
"type": "module",
"scripts": {
"build": "tsc && node --test dist/test/*.js && vite build",
"test": "node --test dist/test/*.js",
"dev": "vite"
},
"devDependencies": {
"@crxjs/vite-plugin": "^2.0.0-beta.26",
"@types/chrome": "^0.0.268",
"@types/node": "^26.0.0",
"typescript": "^5.5.0",
"vite": "^5.3.0"
}
}
+12
View File
@@ -0,0 +1,12 @@
chrome.runtime.onInstalled.addListener(() => {
console.log('DictaTask installed');
});
// Keep service worker alive for clipboard API access
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
if (request.type === 'copy') {
// Service worker cannot directly use clipboard, but we can handle other messages
sendResponse({ ok: true });
}
return true;
});
+30
View File
@@ -0,0 +1,30 @@
import { parseVoiceMemo, formatTaskPlain } from './parser';
// Content script: allows DictaTask to inject structured tasks into web-based task managers
// Currently exposes a lightweight API on window for PWA support and future integrations
interface DictaTaskAPI {
parse: (text: string) => ReturnType<typeof parseVoiceMemo>;
format: (task: ReturnType<typeof parseVoiceMemo>) => string;
}
// Expose on window for integrations (e.g., Todoist web, Notion web, custom dashboards)
(window as any).__dictatask = {
parse: parseVoiceMemo,
format: formatTaskPlain,
} as DictaTaskAPI;
// Listen for paste-to-field requests from popup (if we add auto-fill later)
chrome.runtime.onMessage?.addListener((request, _sender, sendResponse) => {
if (request.type === 'fill-field') {
const el = document.activeElement as HTMLElement | null;
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA')) {
(el as HTMLInputElement).value = request.text;
el.dispatchEvent(new Event('input', { bubbles: true }));
sendResponse({ ok: true });
} else {
sendResponse({ ok: false, reason: 'No focused input found' });
}
}
return true;
});
+32
View File
@@ -0,0 +1,32 @@
{
"manifest_version": 3,
"name": "DictaTask - Voice to Structured Tasks",
"version": "1.0.0",
"description": "Capture voice memos and convert them into structured, actionable tasks. Works with any task manager.",
"permissions": [
"activeTab",
"clipboardWrite",
"storage"
],
"host_permissions": [],
"action": {
"default_popup": "src/popup/popup.html"
},
"background": {
"service_worker": "src/background.ts",
"type": "module"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["src/content.ts"],
"run_at": "document_idle"
}
],
"web_accessible_resources": [
{
"resources": ["assets/*"],
"matches": ["<all_urls>"]
}
]
}
+295
View File
@@ -0,0 +1,295 @@
/**
* DictaTask - Heuristic Parser
* Extracts structured task fields from natural-language voice transcriptions
* without any external LLM or API call.
*/
export interface ParsedTask {
action: string; // WHAT to do
person?: string; // WHO to do it with / for / assign to
deadline?: string; // WHEN - parsed date/time expression
context?: string; // CONTEXT - details, sub-tasks, extra info
priority?: 'high' | 'medium' | 'low'; // inferred urgency
original: string; // raw transcription
confidence: number; // 0.0-1.0 heuristic confidence
}
// --- Deadline keywords & regexes ---
const DEADLINE_PATTERNS: Array<{ regex: RegExp; normalize: (m: RegExpMatchArray) => string | undefined }> = [
// "by tomorrow" / "by next Monday" / "by Friday"
{
regex: /\bby\s+(tomorrow|today|tonight|(?:next\s+)?(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday))\b/gi,
normalize: (m) => m[1]?.toLowerCase(),
},
// "on Monday" / "on Friday" / "next Tuesday" / "this Wednesday"
{
regex: /\b(?:on|by|next|this)\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b/gi,
normalize: (m) => m[1]?.toLowerCase(),
},
// "in 3 days" / "in 2 hours"
{
regex: /\bin\s+(\d+\s+(?:days?|hours?|minutes?|weeks?))\b/gi,
normalize: (m) => `in ${m[1]}`,
},
// "at 3pm" / "at 5:30"
{
regex: /\bat\s+(\d{1,2}(?::\d{2})?\s*(?:am|pm)?)\b/gi,
normalize: (m) => `at ${m[1]}`,
},
// "on June 15th" / "on 15 June" / "on 6/15"
{
regex: /\bon\s+(\d{1,2}[\/.\-]\d{1,2}(?:[\/.\-]\d{2,4})?|[a-z]+\s+\d{1,2}(?:st|nd|rd|th)?)\b/gi,
normalize: (m) => `on ${m[1]}`,
},
// "end of week" / "beginning of next month"
{
regex: /\b(end of (?:the\s+)?(?:week|month|day)|beginning of (?:the\s+)?(?:week|month|day))\b/gi,
normalize: (m) => m[1],
},
// "asap" / "urgent" / "eod"
{
regex: /\b(asap|eod|cob|urgent|rush|priority)\b/gi,
normalize: (m) => m[1]?.toLowerCase(),
},
];
// --- Person indicators ---
const PERSON_PREFIXES = [
'call', 'email', 'text', 'message', 'meet with', 'talk to', 'discuss with',
'follow up with', 'remind', 'assign', 'delegate', 'ask',
];
// --- Action verbs ---
const ACTION_VERBS = [
'buy', 'purchase', 'get', 'pick up', 'order', 'book', 'schedule', 'plan',
'call', 'email', 'text', 'message', 'send', 'write', 'draft', 'review', 'approve',
'meet', 'discuss', 'talk', 'present', 'demo', 'show',
'fix', 'debug', 'deploy', 'test', 'build', 'implement', 'refactor', 'update', 'upgrade',
'research', 'investigate', 'look into', 'find out', 'check', 'verify', 'confirm',
'prepare', 'create', 'make', 'design', 'draw', 'record', 'document', 'upload',
'remind', 'follow up', 'chase', 'ping', 'nudge',
'pay', 'invoice', 'bill', 'charge', 'refund', 'reimburse',
'clean', 'organize', 'tidy', 'declutter', 'sort', 'archive', 'back up',
'exercise', 'work out', 'walk', 'run', 'meditate', 'stretch',
'read', 'study', 'learn', 'watch', 'listen', 'practice',
'cook', 'make', 'bake', 'prep', 'pack', 'prepare',
];
// --- Priority words ---
const HIGH_PRIORITY = /\b(urgent|asap|critical|crucial|vital|essential|important|must|need to|deadline|eod|cob|today|tonight|first thing)\b/gi;
const LOW_PRIORITY = /\b(later|whenever|someday|eventually|low priority|not urgent|nice to have|if possible|if time|maybe)\b/gi;
// --- Stop words for action extraction ---
const STOP_WORDS = new Set([
'a', 'an', 'the', 'and', 'or', 'but', 'if', 'then', 'else', 'when', 'where', 'why', 'how',
'what', 'who', 'which', 'this', 'that', 'these', 'those', 'i', 'you', 'he', 'she', 'it', 'we', 'they',
'me', 'him', 'her', 'us', 'them', 'my', 'your', 'his', 'her', 'its', 'our', 'their',
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'shall', 'should',
'can', 'could', 'may', 'might', 'must', 'ought', 'to', 'of', 'in', 'on', 'at', 'by', 'for', 'with', 'about', 'against', 'between', 'into', 'through',
'during', 'before', 'after', 'above', 'below', 'from', 'up', 'down', 'out', 'off', 'over', 'under', 'again', 'further', 'then', 'once', 'just', 'only', 'also',
]);
/**
* Extract a deadline phrase from the text.
*/
function extractDeadline(text: string): string | undefined {
for (const pattern of DEADLINE_PATTERNS) {
const matches = [...text.matchAll(pattern.regex)];
if (matches.length > 0) {
// Prefer the first match, but if there are multiple, concatenate
const parts = matches.map((m) => pattern.normalize(m)).filter(Boolean);
return parts.join(', ');
}
}
return undefined;
}
/**
* Extract a person name / email / pronoun from the text.
*/
function extractPerson(text: string): string | undefined {
const lower = text.toLowerCase();
// 1. Check for explicit person prefixes
for (const prefix of PERSON_PREFIXES) {
const idx = lower.indexOf(prefix);
if (idx === -1) continue;
const after = text.slice(idx + prefix.length).trim();
// Capture the next 1-3 capitalized words or an email
const personMatch = after.match(/^\s*(?:to\s+)?\s*([A-Z][a-zA-Z\s]{1,30}?)(?:\s+(?:about|regarding|on|to|for|by|at|before|after|and|or|with)\b|$)/i);
if (personMatch) {
const candidate = personMatch[1].trim();
if (candidate.length > 1 && candidate.split(/\s+/).length <= 4) {
return candidate;
}
}
}
// 2. Generic "with/for X" pattern (capitalized name, not a common noun)
const withForMatch = text.match(/(?:with|for|to)\s+([A-Z][a-zA-Z]{1,15})(?:\s+and\s+([A-Z][a-zA-Z]{1,15}))?/g);
if (withForMatch) {
const names = withForMatch
.map((m) => m.replace(/^(?:with|for|to)\s+/, '').trim())
.filter((n) => n.length > 1 && !STOP_WORDS.has(n.toLowerCase()));
if (names.length > 0) return names.join(', ');
}
// 3. Email address
const emailMatch = text.match(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/);
if (emailMatch) return emailMatch[0];
return undefined;
}
/**
* Extract the core action (WHAT) from the text.
*/
function extractAction(text: string, person?: string): string {
// Clean up the text: remove deadline clauses, trim
let cleaned = text;
// Remove deadline phrases so they don't pollute the action
for (const pattern of DEADLINE_PATTERNS) {
cleaned = cleaned.replace(pattern.regex, '');
}
// Remove person mention if we already extracted it
if (person) {
const personIdx = cleaned.toLowerCase().indexOf(person.toLowerCase());
if (personIdx !== -1) {
cleaned = cleaned.slice(0, personIdx) + cleaned.slice(personIdx + person.length);
}
}
cleaned = cleaned.replace(/\s+/g, ' ').trim();
if (!cleaned) return 'Review voice note'; // fallback
// Try to find an action verb at the start
const lower = cleaned.toLowerCase();
for (const verb of ACTION_VERBS) {
if (lower.startsWith(verb + ' ') || lower.startsWith(verb + 's ')) {
// Extract up to the first conjunction or punctuation that suggests a new clause
const rest = cleaned.slice(verb.length).trim();
const endIdx = rest.search(/[,;.\--]\s+(?:and|or|but|then|also|plus|so|because|if|when|where|who|which|that)\b/i);
const actionCore = endIdx === -1 ? rest : rest.slice(0, endIdx);
return `${verb.charAt(0).toUpperCase() + verb.slice(1)} ${actionCore}`.trim();
}
}
// If no action verb found, return the first sentence-ish chunk
const firstChunk = cleaned.split(/[,;.\--]/)[0].trim();
return firstChunk.charAt(0).toUpperCase() + firstChunk.slice(1);
}
/**
* Extract context / details - everything that's not action, person, deadline.
*/
function extractContext(text: string, action: string, person?: string, deadline?: string): string | undefined {
let remaining = text;
// Remove action
remaining = remaining.replace(new RegExp(escapeRegExp(action), 'i'), '');
// Remove person
if (person) remaining = remaining.replace(new RegExp(escapeRegExp(person), 'i'), '');
// Remove deadline
if (deadline) remaining = remaining.replace(new RegExp(escapeRegExp(deadline), 'i'), '');
remaining = remaining.replace(/\s+/g, ' ').trim();
if (!remaining || remaining.length < 3) return undefined;
// Remove dangling connector words
remaining = remaining.replace(/^(?:\s*[\-,:;]\s*|\s*(?:and|or|but|then|also|plus|about|regarding|on|to|for|with|by|at|before|after)\s+)/i, '');
return remaining || undefined;
}
function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Infer priority from urgency words.
*/
function inferPriority(text: string): ParsedTask['priority'] {
const high = (text.match(HIGH_PRIORITY) || []).length;
const low = (text.match(LOW_PRIORITY) || []).length;
if (high > low) return 'high';
if (low > high) return 'low';
return 'medium';
}
/**
* Main parse entry point.
*/
export function parseVoiceMemo(text: string): ParsedTask {
const original = text.trim();
if (!original) {
return {
action: 'Review voice note',
original: '',
confidence: 0.0,
};
}
const deadline = extractDeadline(original);
const person = extractPerson(original);
const action = extractAction(original, person);
const context = extractContext(original, action, person, deadline);
const priority = inferPriority(original);
// Confidence heuristic: more fields extracted = higher confidence
let score = 0.3; // base
if (action && action !== 'Review voice note') score += 0.2;
if (person) score += 0.15;
if (deadline) score += 0.15;
if (context) score += 0.15;
if (priority !== 'medium') score += 0.05;
const confidence = Math.min(1.0, score);
return {
action,
person,
deadline,
context,
priority,
original,
confidence,
};
}
/**
* Format a ParsedTask as a clean, human-readable string.
*/
export function formatTask(task: ParsedTask): string {
const lines: string[] = [];
lines.push(task.action);
if (task.person) lines.push(`👤 ${task.person}`);
if (task.deadline) lines.push(`📅 ${task.deadline}`);
if (task.context) lines.push(`📝 ${task.context}`);
if (task.priority === 'high') lines.push('🔴 High priority');
if (task.priority === 'low') lines.push('🟢 Low priority');
return lines.join('\n');
}
/**
* Format a ParsedTask as Markdown for task managers (Notion, Todoist, etc.).
*/
export function formatTaskMarkdown(task: ParsedTask): string {
const parts: string[] = [];
parts.push(`- [ ] ${task.action}`);
if (task.person) parts.push(` - **Who:** ${task.person}`);
if (task.deadline) parts.push(` - **When:** ${task.deadline}`);
if (task.context) parts.push(` - **Context:** ${task.context}`);
if (task.priority === 'high') parts.push(` - **Priority:** 🔴 High`);
if (task.priority === 'low') parts.push(` - **Priority:** 🟢 Low`);
return parts.join('\n');
}
/**
* Format as plain text for clipboard / quick paste.
*/
export function formatTaskPlain(task: ParsedTask): string {
const parts: string[] = [task.action];
if (task.person) parts.push(`(with ${task.person})`);
if (task.deadline) parts.push(`- ${task.deadline}`);
if (task.context) parts.push(`// ${task.context}`);
return parts.join(' ');
}
+427
View File
@@ -0,0 +1,427 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
width: 380px;
min-height: 480px;
background: #0f172a;
color: #e2e8f0;
overflow-x: hidden;
}
#app {
display: flex;
flex-direction: column;
height: 100%;
min-height: 480px;
}
/* Header */
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #1e293b;
background: #0f172a;
}
.logo {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: 15px;
color: #38bdf8;
}
.logo svg { color: #38bdf8; }
.status {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 4px 10px;
border-radius: 999px;
background: #1e293b;
color: #94a3b8;
}
.status.recording {
background: #ef4444;
color: #fff;
animation: pulse-badge 1.5s infinite;
}
@keyframes pulse-badge {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
/* Main / Panels */
main {
flex: 1;
padding: 16px;
display: flex;
flex-direction: column;
}
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
flex: 1;
}
.panel.hidden { display: none; }
/* Mic */
.mic-wrapper {
position: relative;
display: flex;
justify-content: center;
align-items: center;
margin-top: 24px;
}
.mic-btn {
width: 80px;
height: 80px;
border-radius: 50%;
border: none;
background: #38bdf8;
color: #0f172a;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.15s, background 0.2s;
z-index: 2;
position: relative;
}
.mic-btn:hover { background: #7dd3fc; transform: scale(1.05); }
.mic-btn:active { transform: scale(0.95); }
.mic-btn.recording {
background: #ef4444;
color: #fff;
}
.pulse-ring {
position: absolute;
width: 80px;
height: 80px;
border-radius: 50%;
border: 2px solid #38bdf8;
opacity: 0;
pointer-events: none;
}
.pulse-ring.active {
animation: pulse-ring 1.5s ease-out infinite;
}
@keyframes pulse-ring {
0% { transform: scale(1); opacity: 0.6; }
100% { transform: scale(2.2); opacity: 0; }
}
.hint {
font-size: 13px;
color: #94a3b8;
text-align: center;
}
.timer {
font-size: 28px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #ef4444;
letter-spacing: 0.05em;
}
.transcript {
width: 100%;
max-height: 120px;
overflow-y: auto;
padding: 10px 12px;
border-radius: 10px;
background: #1e293b;
font-size: 13px;
line-height: 1.5;
color: #cbd5e1;
word-break: break-word;
}
/* Task Card */
.task-card {
width: 100%;
background: #1e293b;
border-radius: 12px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.field-row {
display: flex;
gap: 10px;
}
.field-row .field { flex: 1; }
.field label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #94a3b8;
}
.field input,
.field textarea {
background: #0f172a;
border: 1px solid #334155;
border-radius: 8px;
padding: 8px 10px;
color: #e2e8f0;
font-size: 13px;
outline: none;
transition: border-color 0.15s;
font-family: inherit;
resize: vertical;
}
.field input:focus,
.field textarea:focus { border-color: #38bdf8; }
.field input::placeholder,
.field textarea::placeholder { color: #475569; }
.priority-options {
display: flex;
gap: 6px;
}
.priority-btn {
flex: 1;
padding: 6px 8px;
border: 1px solid #334155;
border-radius: 8px;
background: #0f172a;
color: #94a3b8;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.priority-btn:hover { border-color: #475569; }
.priority-btn.active {
border-color: #38bdf8;
color: #38bdf8;
background: #0f172a;
}
.confidence {
display: flex;
align-items: center;
gap: 8px;
margin-top: 2px;
}
.confidence-bar {
flex: 1;
height: 4px;
background: #334155;
border-radius: 2px;
overflow: hidden;
}
.confidence-bar span {
display: block;
height: 100%;
background: #38bdf8;
border-radius: 2px;
transition: width 0.3s, background 0.3s;
}
.confidence-bar span.low { background: #ef4444; }
.confidence-bar span.medium { background: #f59e0b; }
.confidence-bar span.high { background: #22c55e; }
#confidenceText {
font-size: 11px;
color: #64748b;
}
/* Actions */
.actions {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
margin-top: 4px;
}
.btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 14px;
border-radius: 10px;
border: none;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
}
.btn svg { flex-shrink: 0; }
.btn.primary {
background: #38bdf8;
color: #0f172a;
}
.btn.primary:hover { background: #7dd3fc; }
.btn.primary:active { transform: translateY(1px); }
.btn.secondary {
background: #1e293b;
color: #e2e8f0;
border: 1px solid #334155;
}
.btn.secondary:hover { background: #334155; }
.btn.secondary:active { transform: translateY(1px); }
/* Error */
.error-msg {
color: #fca5a5;
font-size: 13px;
text-align: center;
line-height: 1.5;
}
/* Footer */
footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
border-top: 1px solid #1e293b;
background: #0f172a;
font-size: 12px;
color: #64748b;
}
.format-toggle {
display: flex;
align-items: center;
gap: 6px;
}
.fmt-btn {
padding: 3px 8px;
border-radius: 6px;
border: none;
background: transparent;
color: #64748b;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.fmt-btn:hover { color: #94a3b8; }
.fmt-btn.active {
background: #1e293b;
color: #e2e8f0;
}
.history-link {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
color: #64748b;
transition: color 0.15s;
}
.history-link:hover { color: #94a3b8; }
/* Overlay */
.overlay {
position: absolute;
inset: 0;
background: #0f172a;
display: flex;
flex-direction: column;
z-index: 10;
animation: slideIn 0.2s ease;
}
.overlay.hidden { display: none; }
@keyframes slideIn {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.overlay-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #1e293b;
}
.overlay-header h3 {
font-size: 14px;
font-weight: 700;
}
.close-btn {
background: none;
border: none;
color: #94a3b8;
font-size: 20px;
cursor: pointer;
line-height: 1;
}
.close-btn:hover { color: #e2e8f0; }
.history-list {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.history-item {
background: #1e293b;
border-radius: 10px;
padding: 10px 12px;
font-size: 12px;
cursor: pointer;
transition: background 0.15s;
border: 1px solid transparent;
}
.history-item:hover { background: #334155; border-color: #475569; }
.history-item .h-action { font-weight: 600; color: #e2e8f0; margin-bottom: 2px; }
.history-item .h-meta { color: #64748b; font-size: 11px; }
/* Scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #334155; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #475569; }
+135
View File
@@ -0,0 +1,135 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DictaTask</title>
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<div id="app">
<header>
<div class="logo">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2a3 3 0 0 0-3 3v14a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="23"/>
</svg>
<span>DictaTask</span>
</div>
<div class="status" id="statusBadge">Ready</div>
</header>
<main>
<!-- Recording Panel -->
<div class="panel" id="recordPanel">
<div class="mic-wrapper">
<button id="micBtn" class="mic-btn" aria-label="Start recording">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2a3 3 0 0 0-3 3v14a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="23"/>
</svg>
</button>
<div class="pulse-ring" id="pulseRing"></div>
</div>
<p class="hint" id="recordHint">Tap microphone to start</p>
<div class="timer" id="timer" hidden>00:00</div>
<div class="transcript" id="transcript" hidden></div>
</div>
<!-- Result Panel -->
<div class="panel hidden" id="resultPanel">
<div class="task-card" id="taskCard">
<div class="field">
<label>Action</label>
<input type="text" id="fieldAction" />
</div>
<div class="field-row">
<div class="field" id="fieldPersonWrap">
<label>Who</label>
<input type="text" id="fieldPerson" />
</div>
<div class="field" id="fieldDeadlineWrap">
<label>When</label>
<input type="text" id="fieldDeadline" />
</div>
</div>
<div class="field" id="fieldContextWrap">
<label>Context</label>
<textarea id="fieldContext" rows="2"></textarea>
</div>
<div class="field" id="fieldPriorityWrap">
<label>Priority</label>
<div class="priority-options">
<button class="priority-btn" data-p="high" id="pHigh">🔴 High</button>
<button class="priority-btn active" data-p="medium" id="pMed">🟡 Medium</button>
<button class="priority-btn" data-p="low" id="pLow">🟢 Low</button>
</div>
</div>
<div class="confidence" id="confidenceWrap">
<span class="confidence-bar"><span id="confidenceFill"></span></span>
<span id="confidenceText">Confidence</span>
</div>
</div>
<div class="actions">
<button class="btn primary" id="btnCopy">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
Copy to Clipboard
</button>
<button class="btn secondary" id="btnMarkdown">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
Copy Markdown
</button>
<button class="btn secondary" id="btnAgain">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="1 4 1 10 7 10"/>
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
</svg>
Record Again
</button>
</div>
</div>
<!-- Empty / Error State -->
<div class="panel hidden" id="errorPanel">
<p class="error-msg" id="errorMsg"></p>
<button class="btn secondary" id="btnRetry">Try Again</button>
</div>
</main>
<footer>
<div class="format-toggle">
<span>Format:</span>
<button class="fmt-btn active" data-fmt="plain">Plain</button>
<button class="fmt-btn" data-fmt="markdown">Markdown</button>
</div>
<div class="history-link" id="historyLink">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
History
</div>
</footer>
</div>
<!-- History overlay -->
<div class="overlay hidden" id="historyOverlay">
<div class="overlay-header">
<h3>Recent Tasks</h3>
<button class="close-btn" id="closeHistory">×</button>
</div>
<div class="history-list" id="historyList"></div>
</div>
<script type="module" src="popup.ts"></script>
</body>
</html>
+351
View File
@@ -0,0 +1,351 @@
/// <reference path="../types/speech.d.ts" />
import { parseVoiceMemo, formatTaskPlain, formatTaskMarkdown, ParsedTask } from '../parser';
// --- DOM refs ---
const micBtn = document.getElementById('micBtn') as HTMLButtonElement;
const pulseRing = document.getElementById('pulseRing') as HTMLDivElement;
const recordHint = document.getElementById('recordHint') as HTMLParagraphElement;
const timerEl = document.getElementById('timer') as HTMLDivElement;
const transcriptEl = document.getElementById('transcript') as HTMLDivElement;
const statusBadge = document.getElementById('statusBadge') as HTMLDivElement;
const recordPanel = document.getElementById('recordPanel') as HTMLDivElement;
const resultPanel = document.getElementById('resultPanel') as HTMLDivElement;
const errorPanel = document.getElementById('errorPanel') as HTMLDivElement;
const errorMsg = document.getElementById('errorMsg') as HTMLParagraphElement;
const btnRetry = document.getElementById('btnRetry') as HTMLButtonElement;
const fieldAction = document.getElementById('fieldAction') as HTMLInputElement;
const fieldPerson = document.getElementById('fieldPerson') as HTMLInputElement;
const fieldDeadline = document.getElementById('fieldDeadline') as HTMLInputElement;
const fieldContext = document.getElementById('fieldContext') as HTMLTextAreaElement;
const pHigh = document.getElementById('pHigh') as HTMLButtonElement;
const pMed = document.getElementById('pMed') as HTMLButtonElement;
const pLow = document.getElementById('pLow') as HTMLButtonElement;
const confidenceFill = document.getElementById('confidenceFill') as HTMLSpanElement;
const confidenceText = document.getElementById('confidenceText') as HTMLSpanElement;
const btnCopy = document.getElementById('btnCopy') as HTMLButtonElement;
const btnMarkdown = document.getElementById('btnMarkdown') as HTMLButtonElement;
const btnAgain = document.getElementById('btnAgain') as HTMLButtonElement;
const fmtButtons = document.querySelectorAll('.fmt-btn');
const historyLink = document.getElementById('historyLink') as HTMLDivElement;
const historyOverlay = document.getElementById('historyOverlay') as HTMLDivElement;
const closeHistory = document.getElementById('closeHistory') as HTMLButtonElement;
const historyList = document.getElementById('historyList') as HTMLDivElement;
// --- State ---
let recognition: SpeechRecognition | null = null;
let isRecording = false;
let timerInterval: number | null = null;
let startTime = 0;
let currentTranscript = '';
let currentTask: ParsedTask | null = null;
let activeFormat: 'plain' | 'markdown' = 'plain';
let history: ParsedTask[] = [];
const MAX_RECORD_SEC = 120; // 2 minutes max
// --- Init ---
async function init() {
loadHistory();
setupEventListeners();
checkSpeechSupport();
}
function checkSpeechSupport() {
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
if (!SpeechRecognition) {
showError('Speech recognition is not supported in this browser. Please use Chrome or Edge.');
micBtn.disabled = true;
micBtn.style.opacity = '0.5';
return;
}
}
function setupEventListeners() {
micBtn.addEventListener('click', toggleRecording);
btnRetry.addEventListener('click', () => showPanel('record'));
btnAgain.addEventListener('click', () => showPanel('record'));
btnCopy.addEventListener('click', () => copyToClipboard('plain'));
btnMarkdown.addEventListener('click', () => copyToClipboard('markdown'));
[pHigh, pMed, pLow].forEach((btn) => {
btn.addEventListener('click', () => {
[pHigh, pMed, pLow].forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
if (currentTask) currentTask.priority = (btn.dataset.p || 'medium') as ParsedTask['priority'];
});
});
fmtButtons.forEach((btnEl) => {
const btn = btnEl as HTMLElement;
btn.addEventListener('click', () => {
fmtButtons.forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
activeFormat = (btn.dataset.fmt || 'plain') as 'plain' | 'markdown';
});
});
historyLink.addEventListener('click', showHistory);
closeHistory.addEventListener('click', () => historyOverlay.classList.add('hidden'));
}
// --- Recording ---
function toggleRecording() {
if (isRecording) {
stopRecording();
} else {
startRecording();
}
}
function startRecording() {
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
if (!SpeechRecognition) return;
recognition = new SpeechRecognition();
const rec = recognition!;
rec.continuous = true;
rec.interimResults = true;
rec.lang = 'en-US';
rec.maxAlternatives = 1;
isRecording = true;
currentTranscript = '';
startTime = Date.now();
// UI updates
micBtn.classList.add('recording');
pulseRing.classList.add('active');
statusBadge.textContent = 'Recording';
statusBadge.classList.add('recording');
recordHint.textContent = 'Tap to stop';
transcriptEl.hidden = false;
transcriptEl.textContent = 'Listening...';
timerEl.hidden = false;
updateTimer();
timerInterval = window.setInterval(updateTimer, 1000);
rec.onresult = (event: SpeechRecognitionEvent) => {
let interim = '';
let final = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const transcript = event.results[i][0].transcript;
if (event.results[i].isFinal) {
final += transcript + ' ';
} else {
interim += transcript;
}
}
if (final) currentTranscript += final;
transcriptEl.textContent = (currentTranscript + interim).trim() || 'Listening...';
};
rec.onerror = (event: SpeechRecognitionErrorEvent) => {
console.error('Speech recognition error:', event.error);
if (event.error === 'no-speech') {
transcriptEl.textContent = 'No speech detected. Try speaking closer to the mic.';
} else if (event.error === 'audio-capture') {
showError('No microphone found. Please connect a microphone and allow access.');
stopRecording();
} else if (event.error === 'not-allowed') {
showError('Microphone permission denied. Please allow microphone access in your browser settings.');
stopRecording();
} else if (event.error === 'network') {
transcriptEl.textContent = 'Network error. Check your connection.';
} else {
transcriptEl.textContent = `Error: ${event.error}`;
}
};
rec.onend = () => {
if (isRecording) {
const elapsed = (Date.now() - startTime) / 1000;
if (elapsed >= MAX_RECORD_SEC) {
stopRecording();
} else if (isRecording) {
try { rec.start(); } catch { /* already started */ }
}
}
};
try {
rec.start();
} catch (err) {
showError('Could not start recording. Please check microphone permissions.');
stopRecording();
}
}
function stopRecording() {
isRecording = false;
if (recognition) {
try { recognition.stop(); } catch { /* ignore */ }
recognition = null;
}
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
micBtn.classList.remove('recording');
pulseRing.classList.remove('active');
statusBadge.textContent = 'Ready';
statusBadge.classList.remove('recording');
recordHint.textContent = 'Tap microphone to start';
timerEl.hidden = true;
transcriptEl.hidden = true;
const raw = currentTranscript.trim();
if (!raw) {
showError('No speech detected. Try speaking louder or closer to the microphone.');
return;
}
// Parse and show results
const task = parseVoiceMemo(raw);
currentTask = task;
showTask(task);
}
function updateTimer() {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const mins = Math.floor(elapsed / 60).toString().padStart(2, '0');
const secs = (elapsed % 60).toString().padStart(2, '0');
timerEl.textContent = `${mins}:${secs}`;
if (elapsed >= MAX_RECORD_SEC) {
stopRecording();
}
}
// --- UI Panels ---
function showPanel(name: 'record' | 'result' | 'error') {
recordPanel.classList.add('hidden');
resultPanel.classList.add('hidden');
errorPanel.classList.add('hidden');
if (name === 'record') recordPanel.classList.remove('hidden');
if (name === 'result') resultPanel.classList.remove('hidden');
if (name === 'error') errorPanel.classList.remove('hidden');
}
function showError(msg: string) {
errorMsg.textContent = msg;
showPanel('error');
}
function showTask(task: ParsedTask) {
fieldAction.value = task.action;
fieldPerson.value = task.person || '';
fieldDeadline.value = task.deadline || '';
fieldContext.value = task.context || '';
[pHigh, pMed, pLow].forEach((b) => b.classList.remove('active'));
if (task.priority === 'high') pHigh.classList.add('active');
else if (task.priority === 'low') pLow.classList.add('active');
else pMed.classList.add('active');
// Show/hide optional fields based on presence
(document.getElementById('fieldPersonWrap') as HTMLDivElement).hidden = !task.person && false; // always show for edit
(document.getElementById('fieldDeadlineWrap') as HTMLDivElement).hidden = !task.deadline && false;
(document.getElementById('fieldContextWrap') as HTMLDivElement).hidden = !task.context && false;
// Confidence bar
const pct = Math.round(task.confidence * 100);
confidenceFill.style.width = `${pct}%`;
confidenceFill.className = '';
if (pct < 50) confidenceFill.classList.add('low');
else if (pct < 80) confidenceFill.classList.add('medium');
else confidenceFill.classList.add('high');
confidenceText.textContent = `${pct}% confidence`;
showPanel('result');
saveToHistory(task);
}
// --- Clipboard / Output ---
async function copyToClipboard(format: 'plain' | 'markdown') {
if (!currentTask) return;
// Update currentTask from edited fields
currentTask.action = fieldAction.value.trim() || currentTask.action;
currentTask.person = fieldPerson.value.trim() || undefined;
currentTask.deadline = fieldDeadline.value.trim() || undefined;
currentTask.context = fieldContext.value.trim() || undefined;
const activeP = document.querySelector('.priority-btn.active') as HTMLButtonElement;
currentTask.priority = (activeP.dataset.p || 'medium') as ParsedTask['priority'];
const text = format === 'markdown' ? formatTaskMarkdown(currentTask) : formatTaskPlain(currentTask);
try {
await navigator.clipboard.writeText(text);
const btn = format === 'markdown' ? btnMarkdown : btnCopy;
const original = btn.innerHTML;
btn.innerHTML = `\u003csvg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"\u003e\u003cpolyline points="20 6 9 17 4 12"/\u003e\u003c/svg\u003e Copied!`;
setTimeout(() => { btn.innerHTML = original; }, 1500);
} catch (err) {
console.error('Clipboard failed:', err);
try {
await navigator.clipboard.writeText(text);
} catch {
showError('Clipboard access denied. Please copy manually from the text below:\n\n' + text);
}
}
}
// --- History ---
async function loadHistory() {
try {
const result = await chrome.storage?.local?.get('dictatask_history');
if (result?.dictatask_history) {
history = JSON.parse(result.dictatask_history);
}
} catch {
history = [];
}
}
async function saveToHistory(task: ParsedTask) {
history.unshift(task);
if (history.length > 20) history = history.slice(0, 20);
try {
await chrome.storage?.local?.set({ dictatask_history: JSON.stringify(history) });
} catch (err) {
console.error('History save failed:', err);
}
}
function showHistory() {
historyList.innerHTML = '';
if (history.length === 0) {
historyList.innerHTML = '\u003cp style="color:#64748b;text-align:center;font-size:13px;margin-top:20px;"\u003eNo history yet.\u003c/p\u003e';
} else {
for (const task of history) {
const el = document.createElement('div');
el.className = 'history-item';
el.innerHTML = `
\u003cdiv class="h-action"\u003e${escapeHtml(task.action)}\u003c/div\u003e
\u003cdiv class="h-meta"\u003e${task.person ? `👤 ${escapeHtml(task.person)} · ` : ''}${task.deadline ? `📅 ${escapeHtml(task.deadline)} · ` : ''}${Math.round(task.confidence * 100)}% confidence\u003c/div\u003e
`;
el.addEventListener('click', () => {
currentTask = task;
showTask(task);
historyOverlay.classList.add('hidden');
});
historyList.appendChild(el);
}
}
historyOverlay.classList.remove('hidden');
}
function escapeHtml(str: string): string {
return str
.replace(/\u0026/g, '\u0026amp;')
.replace(/\u003c/g, '\u0026lt;')
.replace(/\u003e/g, '\u0026gt;')
.replace(/"/g, '\u0026quot;')
.replace(/'/g, '\u0026#039;');
}
// --- Start ---
init();
+72
View File
@@ -0,0 +1,72 @@
import { parseVoiceMemo, formatTaskPlain, formatTaskMarkdown, formatTask } from '../parser.js';
import { describe, it } from 'node:test';
import assert from 'node:assert';
describe('Parser', () => {
it('parses a simple action', () => {
const t = parseVoiceMemo('Buy milk');
assert.strictEqual(t.action, 'Buy milk');
assert.strictEqual(t.priority, 'medium');
assert.ok(t.confidence >= 0.3);
});
it('extracts a deadline', () => {
const t = parseVoiceMemo('Call Sarah by tomorrow');
assert.ok(t.action.includes('Call'), `action was "${t.action}"`);
assert.strictEqual(t.deadline, 'tomorrow');
assert.ok(t.confidence > 0.5);
});
it('extracts a person via call prefix', () => {
const t = parseVoiceMemo('Call Sarah about the Q3 proposal');
assert.strictEqual(t.person, 'Sarah');
assert.ok(t.action.includes('Call'));
assert.ok(t.context?.includes('Q3 proposal'));
});
it('extracts email as person', () => {
const t = parseVoiceMemo('Send the report to john@example.com by Friday');
assert.strictEqual(t.person, 'john@example.com');
assert.strictEqual(t.deadline, 'friday');
});
it('infers high priority', () => {
const t = parseVoiceMemo('Urgent: fix the production bug ASAP');
assert.strictEqual(t.priority, 'high');
});
it('infers low priority', () => {
const t = parseVoiceMemo('Maybe learn Spanish later when I have time');
assert.strictEqual(t.priority, 'low');
});
it('handles long-form dictation', () => {
const text = 'Prepare the quarterly report and make sure to include the revenue numbers from the APAC region and also mention the new hires so we can present it to the board on Friday morning before lunch';
const t = parseVoiceMemo(text);
assert.ok(t.action.length > 10);
assert.ok(t.deadline?.includes('friday'));
assert.ok(t.context && t.context.length > 20);
assert.ok(t.confidence > 0.6);
});
it('handles empty input', () => {
const t = parseVoiceMemo('');
assert.strictEqual(t.action, 'Review voice note');
assert.strictEqual(t.confidence, 0.0);
});
it('formats plain text', () => {
const t = parseVoiceMemo('Buy milk by tomorrow');
const plain = formatTaskPlain(t);
assert.ok(plain.includes('Buy milk'));
assert.ok(plain.includes('tomorrow'));
});
it('formats markdown', () => {
const t = parseVoiceMemo('Email John about the proposal by Monday');
const md = formatTaskMarkdown(t);
assert.ok(md.includes('- [ ]'));
assert.ok(md.includes('**Who:**'));
assert.ok(md.includes('**When:**'));
});
});
+55
View File
@@ -0,0 +1,55 @@
/**
* Web Speech API type declarations for TypeScript
*/
interface SpeechRecognition extends EventTarget {
continuous: boolean;
interimResults: boolean;
lang: string;
maxAlternatives: number;
onresult: ((event: SpeechRecognitionEvent) => void) | null;
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null;
onend: (() => void) | null;
start(): void;
stop(): void;
abort(): void;
}
interface SpeechRecognitionEvent extends Event {
readonly resultIndex: number;
readonly results: SpeechRecognitionResultList;
}
interface SpeechRecognitionResultList {
length: number;
item(index: number): SpeechRecognitionResult;
[index: number]: SpeechRecognitionResult;
}
interface SpeechRecognitionResult {
isFinal: boolean;
length: number;
item(index: number): SpeechRecognitionAlternative;
[index: number]: SpeechRecognitionAlternative;
}
interface SpeechRecognitionAlternative {
transcript: string;
confidence: number;
}
interface SpeechRecognitionErrorEvent extends Event {
error: string;
message: string;
}
declare var SpeechRecognition: {
new (): SpeechRecognition;
};
declare var webkitSpeechRecognition: {
new (): SpeechRecognition;
};
interface Window {
SpeechRecognition?: typeof SpeechRecognition;
webkitSpeechRecognition?: typeof SpeechRecognition;
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022", "DOM"],
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite';
import { crx } from '@crxjs/vite-plugin';
import manifest from './src/manifest.json' assert { type: 'json' };
export default defineConfig({
plugins: [crx({ manifest })],
build: {
outDir: 'dist',
emptyOutDir: true,
rollupOptions: {
input: {
popup: 'src/popup/popup.html',
}
}
},
publicDir: 'public'
});