impl/typescript.ts
4,527 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, daysInMonth } from "./dates_add_days.ts"; ← from dates.add-days ^1.0.0 · built alongside by fune
import { addMonths } from "./dates_add_months.ts"; ← from dates.add-months ^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
// A Map, not an object literal, so "constructor" or "__proto__" is not a weekday.
const WEEKDAYS = new Map<string, number>([
["monday", 1], ["mon", 1],
["tuesday", 2], ["tue", 2], ["tues", 2],
["wednesday", 3], ["wed", 3],
["thursday", 4], ["thu", 4], ["thur", 4], ["thurs", 4],
["friday", 5], ["fri", 5],
["saturday", 6], ["sat", 6],
["sunday", 7], ["sun", 7],
]);
const NUMBER_WORDS = new Map<string, number>([
["a", 1], ["an", 1], ["one", 1], ["two", 2], ["three", 3], ["four", 4], ["five", 5],
["six", 6], ["seven", 7], ["eight", 8], ["nine", 9], ["ten", 10],
]);
const UNITS = new Map<string, string>([
["day", "day"], ["days", "day"], ["week", "week"], ["weeks", "week"],
["month", "month"], ["months", "month"], ["year", "year"], ["years", "year"],
]);
/** Upper-case ASCII letters only: "É" stays "É", so no other script can spell a keyword. */
function lowerAscii(text: string): string {
return text.replace(/[A-Z]/g, (c) => c.toLowerCase());
}
function splitWords(text: string): string[] {
return text.split(/[ \t\n\r\v\f]+/).filter((w) => w !== "");
}
function isDigits(text: string): boolean {
for (let i = 0; i < text.length; i++) {
const code = text.charCodeAt(i);
if (code < 48 || code > 57) return false;
}
return text.length > 0;
}
/** YYYY-MM-DD naming a day that exists; anything else is simply not a date phrase. */
function isRealDate(word: string): boolean {
if (word.length !== 10 || word[4] !== "-" || word[7] !== "-") return false;
const y = word.slice(0, 4), m = word.slice(5, 7), d = word.slice(8, 10);
if (!isDigits(y) || !isDigits(m) || !isDigits(d)) return false;
const year = Number(y), month = Number(m), day = Number(d);
return year >= 1 && month >= 1 && month <= 12 && day >= 1 && day <= daysInMonth(year, month);
}
/** 1 to 999 in ASCII digits without a leading zero, or a/an/one...ten. */
function count(word: string): number | null {
if (isDigits(word) && word.length <= 3 && word[0] !== "0") return Number(word);
return NUMBER_WORDS.get(word) ?? null;
}
/** Days from today to the next given weekday, 1 to 7: never today itself. */
function daysUntil(weekday: number, today: number): number {
return ((weekday - today + 6) % 7) + 1;
}
/**
* The date a typed phrase names, counted from `today`, or null when the
* whole text is not one of the phrases in the README. A weekday name is the
* next one strictly after today; "next <weekday>" is that day in next ISO
* week (Monday to Sunday). Months and years clamp to the month end.
*/
export function parseDatePhrase(text: string, today: string): string | null {
// addDays(x, 0) is the date check: it throws dates.add-days's message.
addDays(today, 0);
let words = splitWords(lowerAscii(text));
if (words.length > 1 && (words[0] === "on" || words[0] === "due")) words = words.slice(1);
const dow = dayOfWeek(today);
if (words.length === 1) {
const word = words[0];
if (word === "today" || word === "tod") return today;
if (word === "tomorrow" || word === "tmr" || word === "tmrw") return addDays(today, 1);
if (word === "yesterday") return addDays(today, -1);
if (word === "weekend") return dow >= 6 ? today : addDays(today, 6 - dow);
const weekday = WEEKDAYS.get(word);
if (weekday !== undefined) return addDays(today, daysUntil(weekday, dow));
return isRealDate(word) ? word : null;
}
if (words.length === 2) {
const [first, second] = words;
if (first === "this" && second === "weekend") return dow >= 6 ? today : addDays(today, 6 - dow);
if (first !== "next") return null;
const nextMonday = 8 - dow;
if (second === "week") return addDays(today, nextMonday);
if (second === "month") return addMonths(today.slice(0, 8) + "01", 1);
if (second === "year") return addMonths(today.slice(0, 5) + "01-01", 12);
const weekday = WEEKDAYS.get(second);
return weekday === undefined ? null : addDays(today, nextMonday + weekday - 1);
}
if (words.length === 3 && words[0] === "in") {
const n = count(words[1]);
const unit = UNITS.get(words[2]);
if (n === null || unit === undefined) return null;
if (unit === "day") return addDays(today, n);
if (unit === "week") return addDays(today, 7 * n);
if (unit === "month") return addMonths(today, n);
return addMonths(today, 12 * n);
}
return null;
}