111 lines
3.3 KiB
TypeScript
111 lines
3.3 KiB
TypeScript
// Natural-language quick add, Todoist style:
|
|
// "Pay rent tomorrow p1 @home #Sydney ~30m monthly"
|
|
// → title "Pay rent", due tomorrow, priority 1, context @home,
|
|
// project "Sydney" (matched by name prefix), estimate 30 min, repeats monthly.
|
|
//
|
|
// The add form parses the title field live and mirrors every recognized token
|
|
// into a visible form control, so a false positive (e.g. "fri" in a name) is
|
|
// seen and fixable, never silent.
|
|
import type { Project, Repeat, Task } from "./types";
|
|
|
|
export interface ParsedQuickAdd {
|
|
title: string;
|
|
priority?: Task["priority"];
|
|
due_date?: string;
|
|
context?: string;
|
|
project_id?: string;
|
|
estimate_min?: number;
|
|
repeat?: Repeat;
|
|
}
|
|
|
|
const WEEKDAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
|
|
const REPEAT_WORDS: Record<string, Repeat> = {
|
|
daily: "daily",
|
|
weekdays: "weekdays",
|
|
weekly: "weekly",
|
|
monthly: "monthly",
|
|
yearly: "yearly",
|
|
};
|
|
|
|
function iso(d: Date): string {
|
|
const y = d.getFullYear();
|
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
const day = String(d.getDate()).padStart(2, "0");
|
|
return `${y}-${m}-${day}`;
|
|
}
|
|
|
|
function parseDateWord(word: string): string | undefined {
|
|
const w = word.toLowerCase();
|
|
const today = new Date();
|
|
if (w === "today" || w === "tod") return iso(today);
|
|
// No "tom" alias — it's a name far more often than a date.
|
|
if (w === "tomorrow" || w === "tmr") {
|
|
const d = new Date(today);
|
|
d.setDate(d.getDate() + 1);
|
|
return iso(d);
|
|
}
|
|
if (/^\d{4}-\d{2}-\d{2}$/.test(w)) return w;
|
|
const wd = WEEKDAYS.findIndex((n) => n === w || n.slice(0, 3) === w);
|
|
if (wd >= 0) {
|
|
const d = new Date(today);
|
|
const delta = (wd - d.getDay() + 7) % 7 || 7; // always the *next* occurrence
|
|
d.setDate(d.getDate() + delta);
|
|
return iso(d);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function parseEstimate(token: string): number | undefined {
|
|
const m = token.match(/^~(?:(\d+)h)?(\d+)?m?$/i);
|
|
if (!m || (!m[1] && !m[2])) return undefined;
|
|
return (Number(m[1] ?? 0) * 60 + Number(m[2] ?? 0)) || undefined;
|
|
}
|
|
|
|
export function parseQuickAdd(input: string, projects: Project[]): ParsedQuickAdd {
|
|
const out: ParsedQuickAdd = { title: "" };
|
|
const rest: string[] = [];
|
|
|
|
for (const token of input.trim().split(/\s+/)) {
|
|
const p = token.match(/^p([1-4])$/i);
|
|
if (p && out.priority === undefined) {
|
|
out.priority = Number(p[1]) as Task["priority"];
|
|
continue;
|
|
}
|
|
if (token.startsWith("@") && token.length > 1 && out.context === undefined) {
|
|
out.context = token;
|
|
continue;
|
|
}
|
|
if (token.startsWith("#") && token.length > 1 && out.project_id === undefined) {
|
|
const name = token.slice(1).toLowerCase();
|
|
const match = projects.find(
|
|
(pr) => pr.status === "active" && pr.name.toLowerCase().startsWith(name),
|
|
);
|
|
if (match) {
|
|
out.project_id = match.id;
|
|
continue;
|
|
}
|
|
}
|
|
const est = parseEstimate(token);
|
|
if (est && out.estimate_min === undefined) {
|
|
out.estimate_min = est;
|
|
continue;
|
|
}
|
|
const rep = REPEAT_WORDS[token.toLowerCase()];
|
|
if (rep && out.repeat === undefined) {
|
|
out.repeat = rep;
|
|
continue;
|
|
}
|
|
if (out.due_date === undefined) {
|
|
const due = parseDateWord(token);
|
|
if (due) {
|
|
out.due_date = due;
|
|
continue;
|
|
}
|
|
}
|
|
rest.push(token);
|
|
}
|
|
|
|
out.title = rest.join(" ");
|
|
return out;
|
|
}
|