Functional Weave
Code in Python

todo.parse-quick-add@1.0.0

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