Functional Weave
Code in Rust

todo.parse-date-phrase@1.0.0

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;
}