impl/typescript.ts
5,382 bytes · the TypeScript implementation · view raw
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { addDays } from "./dates_add_days.ts"; ← from dates.add-days ^1.0.0 · built alongside by fune
import { dayOfWeek } from "./dates_day_of_week.ts"; ← from dates.day-of-week ^1.0.0 · built alongside by fune
import { type Priority, type Recurrence, type RecurrenceFrequency, type TodoDraft } from "./todo_item.ts"; ← from todo.item ^1.0.0 · built alongside by fune
import { normaliseTags } from "./todo_normalise_tags.ts"; ← from todo.normalise-tags ^1.0.0 · built alongside by fune
import { parseDatePhrase } from "./todo_parse_date_phrase.ts"; ← from todo.parse-date-phrase ^1.0.0 · built alongside by fune
const PRIORITIES = new Map<string, Priority>([
["!high", "high"], ["!h", "high"], ["!1", "high"], ["!!!", "high"],
["!medium", "medium"], ["!med", "medium"], ["!m", "medium"], ["!2", "medium"], ["!!", "medium"],
["!low", "low"], ["!l", "low"], ["!3", "low"],
["!none", "none"],
]);
// Short words that are also ordinary title words ("Read the sun"): on their
// own they stay in the title; "on sun" or "due sat" still sets the date.
const GUARDED = new Set(["mon", "tue", "tues", "wed", "thu", "thur", "thurs", "fri", "sat", "sun", "tod", "weekend"]);
const WEEKDAY_WORDS = new Set([
"monday", "mon", "tuesday", "tue", "tues", "wednesday", "wed", "thursday", "thu", "thur", "thurs",
"friday", "fri", "saturday", "sat", "sunday", "sun",
]);
const UNIT_FREQUENCIES = new Map<string, RecurrenceFrequency>([
["day", "daily"], ["days", "daily"], ["week", "weekly"], ["weeks", "weekly"],
["month", "monthly"], ["months", "monthly"], ["year", "yearly"], ["years", "yearly"],
]);
const NUMBER_WORDS = new Map<string, number>([
["one", 1], ["two", 2], ["three", 3], ["four", 4], ["five", 5],
["six", 6], ["seven", 7], ["eight", 8], ["nine", 9], ["ten", 10],
]);
// The longest phrase todo.parse-date-phrase accepts: "due in 3 days".
const MAX_DATE_WORDS = 4;
interface Repeat {
frequency: RecurrenceFrequency;
interval: number;
weekday: string | null;
length: number;
}
function lowerAscii(text: string): string {
return text.replace(/[A-Z]/g, (c) => c.toLowerCase());
}
/** 1 to 999 in ASCII digits without a leading zero, or one...ten. */
function count(word: string): number | null {
if (/^[1-9][0-9]{0,2}$/.test(word)) return Number(word);
return NUMBER_WORDS.get(word) ?? null;
}
/** The repeat phrase starting with "every" at words[i], or null. */
function repeatAt(words: readonly string[], i: number): Repeat | null {
if (i + 1 >= words.length) return null;
const a = lowerAscii(words[i + 1]);
if (a === "weekday" || a === "weekdays") return { frequency: "weekdays", interval: 1, weekday: null, length: 2 };
const unit = UNIT_FREQUENCIES.get(a);
if (unit !== undefined) return { frequency: unit, interval: 1, weekday: null, length: 2 };
if (WEEKDAY_WORDS.has(a)) return { frequency: "weekly", interval: 1, weekday: a, length: 2 };
if (i + 2 >= words.length) return null;
const every = UNIT_FREQUENCIES.get(lowerAscii(words[i + 2]));
if (every === undefined) return null;
const n = a === "other" ? 2 : count(a);
return n === null ? null : { frequency: every, interval: n, weekday: null, length: 3 };
}
/**
* One typed line into a TodoDraft: `#tags`, a `!priority`, the first date
* phrase (todo.parse-date-phrase) and the first `every ...` repeat are taken
* out, and every other word stays in the title, in order, single-spaced.
* The full grammar and its traps are in the README. The title can come out
* empty; todo.item's validateDraft then asks for one.
*/
export function parseQuickAdd(text: string, today: string): TodoDraft {
// addDays(x, 0) is the date check: it throws dates.add-days's message.
addDays(today, 0);
const words = text.split(/[ \t\n\r\v\f]+/).filter((w) => w !== "");
const title: string[] = [];
const tagWords: string[] = [];
let priority: Priority | null = null;
let due: string | null = null;
let repeat: Repeat | null = null;
let i = 0;
while (i < words.length) {
const word = words[i];
const lower = lowerAscii(word);
if (repeat === null && lower === "every") {
const found = repeatAt(words, i);
if (found !== null) {
repeat = found;
i += found.length;
continue;
}
}
if (priority === null && PRIORITIES.has(lower)) {
priority = PRIORITIES.get(lower) as Priority;
i += 1;
continue;
}
if (word.length > 1 && word[0] === "#" && normaliseTags([word]).length > 0) {
tagWords.push(word);
i += 1;
continue;
}
if (due === null) {
let taken = 0;
for (let length = Math.min(MAX_DATE_WORDS, words.length - i); length >= 1; length--) {
if (length === 1 && GUARDED.has(lower)) continue;
const date = parseDatePhrase(words.slice(i, i + length).join(" "), today);
if (date !== null) {
due = date;
taken = length;
break;
}
}
if (taken > 0) {
i += taken;
continue;
}
}
title.push(word);
i += 1;
}
let recurrence: Recurrence | null = null;
if (repeat !== null) {
if (due === null) {
if (repeat.frequency === "weekdays") {
const dow = dayOfWeek(today);
due = dow <= 5 ? today : addDays(today, 8 - dow);
} else if (repeat.weekday !== null) {
due = parseDatePhrase(repeat.weekday, today);
} else {
due = today;
}
}
recurrence = { frequency: repeat.frequency, interval: repeat.interval, anchor: due as string };
}
return { title: title.join(" "), notes: null, priority: priority ?? "none", due, tags: normaliseTags(tagWords), recurrence };
}