Functional Weave
Code in Python

todo.parse-quick-add

Turn one typed line like "Pay rent tomorrow !high #home every month" into a todo draft.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 44 tests, run in TypeScript, Python and Rust.

What it does

Turns one line typed into a quick-add box into a `TodoDraft` (todo.item):

`Pay rent tomorrow !high #home every month`, typed on Monday 2026-09-28, is title `Pay rent`, due 2026-09-29, priority `high`, tags `["home"]`, repeating monthly (interval 1) from 2026-09-29.

For example

  • parse_quick_add(Pay rent tomorrow !high #home every month, 2026-09-28) → title Pay rent, notes —, priority high, due 2026-09-29, tags home, recurrence … the README example: every kind of token in one line
  • parse_quick_add(Buy milk, 2026-09-28) → title Buy milk, notes —, priority none, due —, tags , recurrence — plain words are all title
  • parse_quick_add( Call Bob , 2026-09-28) → title Call Bob, notes —, priority none, due —, tags , recurrence — the title is trimmed and inner runs of whitespace collapse

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

def parse_quick_add(text: str, today: str) -> TodoDraft
textstringthe line as typed
todaydatethe person's local date, which relative dates count from
returnsTodoDraftthe draft to check with todo.item's validateDraft; the title may be empty

Your code names it in one line, in the file that uses it

from fune.todo.parse_quick_add import parse_quick_add  # todo.parse-quick-add@^1
impl/python.py · 139 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import re
from typing import List, Optional, Tuple

from .dates_add_days import add_days  ← from dates.add-days ^1.0.0 · built alongside by fune
from .dates_day_of_week import day_of_week  ← from dates.day-of-week ^1.0.0 · built alongside by fune
from .todo_item import Recurrence, TodoDraft  ← from todo.item ^1.0.0 · built alongside by fune
from .todo_normalise_tags import normalise_tags  ← from todo.normalise-tags ^1.0.0 · built alongside by fune
from .todo_parse_date_phrase import parse_date_phrase  ← from todo.parse-date-phrase ^1.0.0 · built alongside by fune

_PRIORITIES = {
    "!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.
_GUARDED = {"mon", "tue", "tues", "wed", "thu", "thur", "thurs", "fri", "sat", "sun", "tod", "weekend"}

_WEEKDAY_WORDS = {
    "monday", "mon", "tuesday", "tue", "tues", "wednesday", "wed", "thursday", "thu", "thur", "thurs",
    "friday", "fri", "saturday", "sat", "sunday", "sun",
}

_UNIT_FREQUENCIES = {
    "day": "daily", "days": "daily", "week": "weekly", "weeks": "weekly",
    "month": "monthly", "months": "monthly", "year": "yearly", "years": "yearly",
}

_NUMBER_WORDS = {"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".
_MAX_DATE_WORDS = 4

_SPACES = re.compile(r"[ \t\n\r\x0b\x0c]+")
_COUNT = re.compile(r"[1-9][0-9]{0,2}")

# (frequency, interval, weekday word or None, words used)
_Repeat = Tuple[str, int, Optional[str], int]


def _lower_ascii(text: str) -> str:
    return "".join(chr(ord(c) + 32) if "A" <= c <= "Z" else c for c in text)


def _count(word: str) -> Optional[int]:
    if _COUNT.fullmatch(word):
        return int(word)
    return _NUMBER_WORDS.get(word)


def _repeat_at(words: List[str], i: int) -> Optional[_Repeat]:
    """The repeat phrase starting with "every" at words[i], or None."""
    if i + 1 >= len(words):
        return None
    a = _lower_ascii(words[i + 1])
    if a in ("weekday", "weekdays"):
        return ("weekdays", 1, None, 2)
    if a in _UNIT_FREQUENCIES:
        return (_UNIT_FREQUENCIES[a], 1, None, 2)
    if a in _WEEKDAY_WORDS:
        return ("weekly", 1, a, 2)
    if i + 2 >= len(words):
        return None
    every = _UNIT_FREQUENCIES.get(_lower_ascii(words[i + 2]))
    if every is None:
        return None
    n = 2 if a == "other" else _count(a)
    return None if n is None else (every, n, None, 3)


def parse_quick_add(text: str, today: str) -> TodoDraft:
    """One typed line into a TodoDraft: #tags, a !priority, the first date
    phrase and the first "every ..." repeat come out; every other word stays in
    the title, in order, single-spaced. The title can come out empty."""
    # add_days(x, 0) is the date check: it raises dates.add-days's message.
    add_days(today, 0)
    words = [w for w in _SPACES.split(text) if w != ""]
    title: List[str] = []
    tag_words: List[str] = []
    priority: Optional[str] = None
    due: Optional[str] = None
    repeat: Optional[_Repeat] = None

    i = 0
    while i < len(words):
        word = words[i]
        lower = _lower_ascii(word)
        if repeat is None and lower == "every":
            found = _repeat_at(words, i)
            if found is not None:
                repeat = found
                i += found[3]
                continue
        if priority is None and lower in _PRIORITIES:
            priority = _PRIORITIES[lower]
            i += 1
            continue
        if len(word) > 1 and word[0] == "#" and len(normalise_tags([word])) > 0:
            tag_words.append(word)
            i += 1
            continue
        if due is None:
            taken = 0
            for length in range(min(_MAX_DATE_WORDS, len(words) - i), 0, -1):
                if length == 1 and lower in _GUARDED:
                    continue
                date = parse_date_phrase(" ".join(words[i:i + length]), today)
                if date is not None:
                    due = date
                    taken = length
                    break
            if taken > 0:
                i += taken
                continue
        title.append(word)
        i += 1

    recurrence: Optional[Recurrence] = None
    if repeat is not None:
        frequency, interval, weekday, _ = repeat
        if due is None:
            if frequency == "weekdays":
                dow = day_of_week(today)
                due = today if dow <= 5 else add_days(today, 8 - dow)
            elif weekday is not None:
                due = parse_date_phrase(weekday, today)
            else:
                due = today
        recurrence = Recurrence(frequency=frequency, interval=interval, anchor=due)
    return TodoDraft(
        title=" ".join(title),
        notes=None,
        priority=priority if priority is not None else "none",
        due=due,
        tags=normalise_tags(tag_words),
        recurrence=recurrence,
    )

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 5 dependencies, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add todo.parse-quick-add
Download for Python todo.parse-quick-add-1.0.0-python.fune · 26,406 bytes sha256 fc8b48b8ffd1f1ad2166bc115d3b2e7a46d924aec1723c636866367cd29d459b

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./todo.parse-quick-add-1.0.0-python.fune, or fetch it from a terminal with fune pull todo.parse-quick-add@1.0.0:python.

The whole function, every language, is one file too: todo.parse-quick-add-1.0.0.fune, 38,995 bytes, sha256 a0b6cc29d50d4f894ab93594df3235c1dfbace717adf4f7d0846edaa53f9d87f. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

# fune: before todo.parse-quick-add

after — your function gets the result and the arguments, and returns the final result.

# fune: after todo.parse-quick-add

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

# fune: replace dates.add-days in todo.parse-quick-add
# fune: replace dates.day-of-week in todo.parse-quick-add
# fune: replace todo.item in todo.parse-quick-add
# fune: replace todo.normalise-tags in todo.parse-quick-add
# fune: replace todo.parse-date-phrase in todo.parse-quick-add

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show todo.parse-quick-add --steps.

# fune: step todo.parse-quick-add after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
the README example: every kind of token in one line Pay rent tomorrow !high #home every month, 2026-09-28 → title Pay rent, notes —, priority high, due 2026-09-29, tags home, recurrence …
plain words are all title Buy milk, 2026-09-28 → title Buy milk, notes —, priority none, due —, tags , recurrence —
the title is trimmed and inner runs of whitespace collapse Call Bob , 2026-09-28 → title Call Bob, notes —, priority none, due —, tags , recurrence —
a date phrase anywhere in the line Team sync friday, 2026-09-28 → title Team sync, notes —, priority none, due 2026-10-02, tags , recurrence —
a weekday name on that same weekday is a week away Team sync monday, 2026-09-28 → title Team sync, notes —, priority none, due 2026-10-05, tags , recurrence —
in 1 month from 31 January is 28 February Submit report in 1 month, 2026-01-31 → title Submit report, notes —, priority none, due 2026-02-28, tags , recurrence —
an ISO date of 29 February in a leap year Party 2028-02-29 #family, 2028-01-10 → title Party, notes —, priority none, due 2028-02-29, tags family, recurrence —
30 February is not a date and stays in the title Dentist 2026-02-30, 2026-09-28 → title Dentist 2026-02-30, notes —, priority none, due —, tags , recurrence —
a four-word phrase with due in front Pay due in 3 days, 2026-09-28 → title Pay, notes —, priority none, due 2026-10-01, tags , recurrence —
on before an ISO date, with priority and tags Meeting on 2026-10-01 !m #work #Work-Stuff, 2026-09-28 → title Meeting, notes —, priority medium, due 2026-10-01, tags work, work-stuff, recurrence —
Show the other 34 tests
CaseArgumentsExpected
a two-word phrase counts on its own Plan next week, 2026-09-28 → title Plan, notes —, priority none, due 2026-10-05, tags , recurrence —
next week's is not next week Plan next week's menu, 2026-09-28 → title Plan next week's menu, notes —, priority none, due —, tags , recurrence —
a bare 3-letter weekday stays in the title Read the sun, 2026-09-28 → title Read the sun, notes —, priority none, due —, tags , recurrence —
the same abbreviation after on is a date Picnic on sun, 2026-09-28 → title Picnic, notes —, priority none, due 2026-10-04, tags , recurrence —
a bare weekend stays in the title Plan weekend trip, 2026-09-28 → title Plan weekend trip, notes —, priority none, due —, tags , recurrence —
an unknown unit stays in the title Travel in 3 lightyears, 2026-09-28 → title Travel in 3 lightyears, notes —, priority none, due —, tags , recurrence —
only the first date phrase counts; later ones stay in the title Pay tomorrow and friday, 2026-09-28 → title Pay and friday, notes —, priority none, due 2026-09-29, tags , recurrence —
a lone #, a lone ! and every on its own stay in the title Tidy up # ! every, 2026-09-28 → title Tidy up # ! every, notes —, priority none, due —, tags , recurrence —
unicode title text is kept as typed Café 日本 🎉 friday, 2026-09-28 → title Café 日本 🎉, notes —, priority none, due 2026-10-02, tags , recurrence —
a tag that normalises to nothing stays in the title #日本 Visit, 2026-09-28 → title #日本 Visit, notes —, priority none, due —, tags , recurrence —
tags are normalised and deduplicated in order #Home #home #Home-Office Clean, 2026-09-28 → title Clean, notes —, priority none, due —, tags home, home-office, recurrence —
only tokens: the title comes out empty tomorrow #x !h, 2026-09-28 → title , notes —, priority high, due 2026-09-29, tags x, recurrence —
!!! is high Fix bug !!!, 2026-09-28 → title Fix bug, notes —, priority high, due —, tags , recurrence —
!2 is medium Email accountant !2, 2026-09-28 → title Email accountant, notes —, priority medium, due —, tags , recurrence —
!L is low, in any case Call !L plumber, 2026-09-28 → title Call plumber, notes —, priority low, due —, tags , recurrence —
!none is an explicit none Nap !NONE, 2026-09-28 → title Nap, notes —, priority none, due —, tags , recurrence —
an unknown !word stays in the title Deploy !urgent, 2026-09-28 → title Deploy !urgent, notes —, priority none, due —, tags , recurrence —
the first priority wins; the second stays in the title !low !high Fix bug, 2026-09-28 → title !high Fix bug, notes —, priority low, due —, tags , recurrence —
every month with no date is due today Invoice clients every month, 2026-09-28 → title Invoice clients, notes —, priority none, due 2026-09-28, tags , recurrence …
every other day is daily with interval 2 Water plants every other day, 2026-09-28 → title Water plants, notes —, priority none, due 2026-09-28, tags , recurrence …
every 3 weeks Backup every 3 weeks, 2026-09-28 → title Backup, notes —, priority none, due 2026-09-28, tags , recurrence …
every 12 months, and a title word that looks like a repeat Yearly review every 12 months, 2026-09-28 → title Yearly review, notes —, priority none, due 2026-09-28, tags , recurrence …
weekly without every is a title word Write weekly report, 2026-09-28 → title Write weekly report, notes —, priority none, due —, tags , recurrence —
every thursday on a Monday: due this Thursday Bins every thursday, 2026-09-28 → title Bins, notes —, priority none, due 2026-10-01, tags , recurrence …
every monday on a Monday: due next Monday Gym every monday, 2026-09-28 → title Gym, notes —, priority none, due 2026-10-05, tags , recurrence …
a date phrase beats every <weekday> for the due date and anchor Report every friday due 2026-10-16, 2026-09-28 → title Report, notes —, priority none, due 2026-10-16, tags , recurrence …
every weekday on a Monday is due today Stand-up EVERY Weekday, 2026-09-28 → title Stand-up, notes —, priority none, due 2026-09-28, tags , recurrence …
every weekday on a Saturday is due Monday Stand-up every weekday, 2026-10-03 → title Stand-up, notes —, priority none, due 2026-10-05, tags , recurrence …
every weekday on a Sunday is due Monday Stand-up every weekday, 2026-10-04 → title Stand-up, notes —, priority none, due 2026-10-05, tags , recurrence …
a repeat with a later date phrase anchors on that date Stretch every day in 2 days, 2026-09-28 → title Stretch, notes —, priority none, due 2026-09-30, tags , recurrence …
the first repeat wins; the second stays in the title Renew every year every month, 2026-09-28 → title Renew every month, notes —, priority none, due 2026-09-28, tags , recurrence …
every 1000 days is too many: all title Ship every 1000 days, 2026-09-28 → title Ship every 1000 days, notes —, priority none, due —, tags , recurrence —
today in day/month/year order is refused Buy milk, 28/09/2026 → error: is not an ISO date
today that never existed is refused Buy milk tomorrow, 2026-13-01 → error: is not a real calendar date

More from the author

`today` is the person's local date, passed in rather than read from the clock. The function only throws when `today` is not a real ISO date (dates.add-days's message, e.g. `"28/09/2026" is not an ISO date`). The draft is not validated: pass it to todo.item's `validateDraft`, which says "Enter a title." when the line held only tokens.

## How a line is read

The line is split into words on ASCII whitespace (space, tab, CR, LF, VT, FF) and scanned left to right. At each word the first rule that applies takes it (and, for phrases, the words after it); a word no rule takes stays in the title. Keywords are matched case-insensitively, ASCII letters only.

| Order | Token | Meaning | Only once? | | --- | --- | --- | --- | | 1 | `every day(s)`, `every week(s)`, `every month(s)`, `every year(s)` | repeat daily / weekly / monthly / yearly, interval 1 | first repeat wins | | 1 | `every weekday(s)` | repeat on weekdays (Monday to Friday) | | | 1 | `every <weekday>` (any name todo.parse-date-phrase knows: `monday`, `mon`, `tues`, ...) | repeat weekly, interval 1 | | | 1 | `every other day/week/month/year` | interval 2 | | | 1 | `every N days/weeks/months/years`, N = 1 to 999 in digits (no leading zero) or `one` ... `ten` | interval N | | | 2 | `!high`, `!h`, `!1`, `!!!` | priority high | first priority wins | | 2 | `!medium`, `!med`, `!m`, `!2`, `!!` | priority medium | | | 2 | `!low`, `!l`, `!3` | priority low | | | 2 | `!none` | priority none (the default anyway) | | | 3 | `#tag`: `#` then at least one character, and not empty once normalised | a tag, normalised by todo.normalise-tags (`#Home-Office` is `home-office`); repeats merge | every one | | 4 | a date phrase of 1 to 4 words that todo.parse-date-phrase reads as a whole: `tomorrow`, `friday`, `next week`, `in 3 days`, `on 2026-10-01`, `due in 3 days` ... | the due date | first date phrase wins | | - | anything else | a title word | |

For a date phrase the longest run wins: 4 words are tried, then 3, 2 and 1. Four, not three, because `due in 3 days` is the longest phrase todo.parse-date-phrase accepts.

After the scan:

- **title**: the words left over, in order, joined by single spaces, so it is trimmed and inner runs of spaces collapse. It may be empty. - **priority**: `none` unless a priority token was found. **notes**: null. **tags**: `[]` when there are none. - **due**: the date phrase's date. With a repeat and no date phrase, it is today; for `every weekday` today if it is Monday to Friday, else next Monday; for `every <weekday>` the next such day strictly after today (so `every monday` typed on a Monday is due next Monday, as `monday` is). - **recurrence**: null without a repeat; otherwise the repeat's frequency and interval, anchored on the final due date. A date phrase beats `every <weekday>` for the due date, and the series then runs weekly from that date.

## Traps it avoids

- **Short words that are also English.** On their own, the 3- and 4-letter weekday abbreviations (`mon`, `tue`, `tues`, `wed`, `thu`, `thur`, `thurs`, `fri`, `sat`, `sun`), `tod` and `weekend` stay in the title: `Read the sun` and `Plan weekend trip` keep every word. After `on` or `due` they count: `Picnic on sun` is due Sunday. Full weekday names, `today`, `tomorrow`, `tmr`, `tmrw`, `yesterday` and ISO dates count on their own. Phrases of two or more words always count (`Plan next week` is due next Monday), so `Sit on sun lounger` does lose `on sun`: the price of `on sun` working. - **Repeat words in titles.** Only the `every ...` forms repeat, so `Write weekly report` and `Yearly review` keep `weekly` and `Yearly`. `daily`, `weekly`, `monthly` and `yearly` on their own are title words. - **Near misses stay in the title as typed**: a lone `#` or `!`, `every` with nothing it understands after it, `in 3 lightyears`, `every 1000 days`, `2026-02-30`, `next week's`, an unknown `!urgent`, and a tag such as `#日本` that normalises to nothing (text.slugify keeps ASCII and Latin-1 letters only). A lone `!` is never a priority: it is usually punctuation. - **Second tokens are not dropped.** Only the first date phrase, priority and repeat are used; later ones stay in the title (`!low !high Fix bug` is priority low, title `!high Fix bug`) rather than silently vanishing. - **Month ends and leap days** come from todo.parse-date-phrase: `in 1 month` on 31 January is 28 February.

Files

PathBytes
README.md4,716
impl/python.py4,963
impl/rust.rs6,457
impl/typescript.ts5,382
vectors.json12,527