"""Cairn's board: board/tasks.md parsed into four columns of cards, changed only by ops, written back. The file format is the README's. Every change goes through change(): read the file under a lock, apply one op, write it back by rename, commit it. That is the CLI today, and the server and the phone later. One code path, so the file has one writer even when two programs run at once, and a hand edit made between two ops is read, not overwritten. """ import datetime as dt, fcntl, os, re, secrets, subprocess from contextlib import contextmanager from dataclasses import asdict, dataclass, field from pathlib import Path ROOT = Path(__file__).resolve().parent BOARD = Path(os.environ.get("CAIRN_BOARD", ROOT / "board")) # tests point this elsewhere; then nothing is committed COLUMNS = ["Doing", "Todo", "Later", "Done"] ID_CHARS = "abcdefghijkmnpqrstuvwxyz23456789" # where a purchase is made, for the shopping list grouped by store (Jacob 2026-10-12: "good idea for the buying cards") STORES = {"menards": "Menards", "harborfreight": "Harbor Freight", "fleetfarm": "Fleet Farm", "farmstore": "Farm store / co-op", "dealer": "Dealer", "online": "Online", "fb": "FB Marketplace"} # base 32 without l, o, 0 and 1, which read alike META = re.compile(r"(?:^|\s)(\^[a-z0-9]{4}|#[a-z][a-z0-9-]*|(?:due|until|done|cat|every|store):\S+)$") EVERY = re.compile(r"^(\d+)([dwmy])$") # every:2w, every:3m, every:1y WHEN = re.compile(r"^\d{4}-\d\d-\d\d(?:T\d\d:\d\d)?$") @dataclass class Card: id: str title: str cat: str = "" # a tag from categories.md, without the # due: str = "" # YYYY-MM-DD, or YYYY-MM-DDTHH:MM until: str = "" # Later only: the day it comes back to Todo; empty is "someday" done: str = "" # Done only: the day it was ticked notes: list = field(default_factory=list) every: str = "" # repeats: 2w, 3m, 1y … ticking it makes the next one (see repeat()) store: str = "" # where to buy it: menards, harborfreight, fleetfarm, farmstore, dealer, online (STORES) @dataclass class Board: preamble: list # the lines above the first column, kept as written columns: dict # column name -> [Card], in priority order def cards(self): for col, cards in self.columns.items(): for card in cards: yield col, card def find(self, key): """A card by its id, or by a piece of its title that matches one card only.""" key = key.lstrip("^") for col, card in self.cards(): if card.id == key: return col, card hits = [(col, card) for col, card in self.cards() if key.lower() in card.title.lower()] if len(hits) == 1: return hits[0] if not hits: raise KeyError(f"no card matches {key!r}") raise KeyError(f"{len(hits)} cards match {key!r}: " + ", ".join(f"^{c.id} {c.title}" for _, c in hits[:5])) def take(self, card_id): for col, cards in self.columns.items(): for i, card in enumerate(cards): if card.id == card_id: return col, cards.pop(i) raise KeyError(f"no card ^{card_id}") def new_id(self): used = {card.id for _, card in self.cards()} while True: cid = "".join(secrets.choice(ID_CHARS) for _ in range(4)) if cid not in used: return cid # ---------------------------------------------------------------- the file def split_meta(text): """'Caulk the windows #home due:2026-10-18 ^p7rd' -> ('Caulk the windows', {cat, due, id}). Only the tokens at the END are metadata, so a title may hold a # or a colon of its own ('LB#4', 'Ratio: 3:1').""" meta = {} while (m := META.search(text)): tok = m.group(1) text = text[: m.start()].rstrip() if tok[0] == "^": meta.setdefault("id", tok[1:]) elif tok[0] == "#": meta.setdefault("cat", tok[1:]) else: key, value = tok.split(":", 1) meta.setdefault(key, value.lstrip("#")) # cat:home is #home without the shell quoting return text, meta def parse(text): preamble, columns, col, last = [], {c: [] for c in COLUMNS}, None, None for n, line in enumerate(text.splitlines(), 1): head = re.match(r"^## +(\w+)\s*$", line) if head and head.group(1).title() in columns: col, last = head.group(1).title(), None continue if col is None: preamble.append(line) continue item = re.match(r"^- (?:\[[ xX]\] )?(.+)$", line) if item: title, meta = split_meta(item.group(1).strip()) last = Card(id=meta.get("id", ""), title=title, cat=meta.get("cat", ""), due=meta.get("due", ""), until=meta.get("until", ""), done=meta.get("done", ""), every=meta.get("every", ""), store=meta.get("store", "")) columns[col].append(last) elif last and line[:1] in (" ", "\t") and line.strip(): last.notes.append(line.strip()) elif line.strip(): # never drop a hand edit silently: a stray line is an error the dashboard shows raise ValueError(f"tasks.md line {n} is not a card, a note or a column: {line.strip()[:60]!r}") return Board(preamble, columns) def card_line(card, col): parts = [f"- [{'x' if col == 'Done' else ' '}] {card.title}"] if card.cat: parts.append(f"#{card.cat}") if card.due: parts.append(f"due:{card.due}") if card.every: parts.append(f"every:{card.every}") if card.store: parts.append(f"store:{card.store}") if card.until and col == "Later": parts.append(f"until:{card.until}") if card.done and col == "Done": parts.append(f"done:{card.done}") parts.append(f"^{card.id}") return " ".join(parts) def render(board): out = "\n".join(board.preamble).rstrip("\n") out = out + "\n\n" if out else "" for col in COLUMNS: out += f"## {col}\n\n" for card in board.columns[col]: out += card_line(card, col) + "\n" + "".join(f" {n}\n" for n in card.notes) if board.columns[col]: out += "\n" return out.rstrip("\n") + "\n" def load(): board = parse((BOARD / "tasks.md").read_text()) for _, card in board.cards(): card.id = card.id or board.new_id() # a line written by hand gets its id on the next change return board def save(board): path = BOARD / "tasks.md" tmp = path.with_suffix(".part") tmp.write_text(render(board)) tmp.replace(path) def categories(): """categories.md's table: [{tag, name, colour}], in the file's order.""" out = [] for line in (BOARD / "categories.md").read_text().splitlines(): m = re.match(r"^\|\s*`?#([a-z][a-z0-9-]*)`?\s*\|\s*([^|]+?)\s*\|\s*`?(#[0-9A-Fa-f]{6})`?\s*\|", line) if m: out.append({"tag": m.group(1), "name": m.group(2), "colour": m.group(3).upper()}) return out # ---------------------------------------------------------------- dates DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] def when(text, today=None): """today, tomorrow, mon..sun (the next one), +3d, +2w, 10-18, 2026-10-18; any of them with @9:00 or T09:00.""" today = today or dt.date.today() text = text.strip().lower() if not text or text == "-": return "" m = re.match(r"^(.*?)(?:[@t](\d{1,2}):(\d\d))?$", text) day, hh, mm = m.group(1), m.group(2), m.group(3) if day in ("", "today"): d = today elif day == "tomorrow": d = today + dt.timedelta(days=1) elif day[:3] in DAYS and day.isalpha(): d = today + dt.timedelta(days=(DAYS.index(day[:3]) - today.weekday() - 1) % 7 + 1) elif (r := re.match(r"^\+(\d+)([dw])$", day)): d = today + dt.timedelta(days=int(r.group(1)) * (7 if r.group(2) == "w" else 1)) elif re.match(r"^\d\d-\d\d$", day): d = dt.date.fromisoformat(f"{today.year}-{day}") if d < today: d = d.replace(year=today.year + 1) else: d = dt.date.fromisoformat(day) return d.isoformat() + (f"T{int(hh):02d}:{mm}" if hh else "") def due_date(card): return dt.date.fromisoformat(card.due[:10]) if card.due else None # ---------------------------------------------------------------- ops def place(cards, card, where): """where: 'top', 'bottom', or a card id to go just after.""" if where == "top": cards.insert(0, card) elif where in ("bottom", None, ""): cards.append(card) else: at = next((i for i, c in enumerate(cards) if c.id == where), None) cards.insert(0 if at is None else at + 1, card) # an anchor that has gone: the top of the column def later_slot(cards, card): """Later is kept in the order the cards come back: dated first, soonest first; someday at the bottom.""" if not card.until: return "bottom" before = [c for c in cards if c.until and c.until <= card.until] return before[-1].id if before else "top" def apply(board, op, today=None): """One change to the board. Returns the card it touched (or the cards tick woke).""" today = today or dt.date.today() kind = op["op"] if kind == "add": card = Card(id=op.get("id") or board.new_id(), title=op["title"], cat=op.get("cat", ""), due=op.get("due", ""), notes=list(op.get("notes", [])), every=op.get("every", ""), store=op.get("store", "")) col = op.get("column", "Todo") card.until = op.get("until", "") if col == "Later" else "" card.done = today.isoformat() if col == "Done" else "" place(board.columns[col], card, later_slot(board.columns[col], card) if col == "Later" else op.get("where", "bottom")) return card if kind == "tick": return tick(board, today) col, card = board.find(op["id"]) if kind == "edit": for key in ("title", "cat", "due", "every", "store"): if key in op: setattr(card, key, op[key]) if "note" in op: card.notes.append(op["note"]) if "notes" in op: card.notes = list(op["notes"]) return card if kind == "delete": board.take(card.id) return card if kind == "move": to, where = op["column"], op.get("where", "top") elif kind == "done": to, where = "Done", "top" elif kind == "later": to, where = "Later", None card.until = op.get("until", "") else: raise ValueError(f"unknown op {kind!r}") board.take(card.id) if col == "Done" and to != "Done" and card.every: unrepeat(board, card) if to == "Done" and col != "Done" and card.every: repeat(board, card, today) card.done = (card.done or today.isoformat()) if to == "Done" else "" if to != "Later": card.until = "" place(board.columns[to], card, later_slot(board.columns[to], card) if to == "Later" else where) return card def advance(when_, every): """A due date (with or without a time) moved on by one step of `every`. A month step keeps the day, or the month's last day when it has fewer (01-31 + 1m → 02-28).""" n, unit = EVERY.match(every).groups() n = int(n) day = dt.date.fromisoformat(when_[:10]) if unit in "dw": day += dt.timedelta(days=n * (7 if unit == "w" else 1)) else: months = day.month - 1 + n * (12 if unit == "y" else 1) year, month = day.year + months // 12, months % 12 + 1 last = (dt.date(year + month // 12, month % 12 + 1, 1) - dt.timedelta(days=1)).day day = dt.date(year, month, min(day.day, last)) return day.isoformat() + when_[10:] def repeat(board, card, today): """Ticking a repeating card makes the next one: the same card with its due moved on by `every`, as many steps as it takes to land after today (one late tick doesn't stack up missed ones). Without a due, the count starts today. It waits in Later until a short lead before it's due (a quarter of the step, at most two weeks), so a yearly job shows up two weeks ahead and a weekly one a day ahead.""" if not EVERY.match(card.every): return None due = card.due or today.isoformat() due = advance(due, card.every) while due[:10] <= today.isoformat(): due = advance(due, card.every) step = (dt.date.fromisoformat(advance(due, card.every)[:10]) - dt.date.fromisoformat(due[:10])).days until = (dt.date.fromisoformat(due[:10]) - dt.timedelta(days=max(1, min(14, step // 4)))).isoformat() nxt = Card(id=board.new_id(), title=card.title, cat=card.cat, due=due, every=card.every, notes=list(card.notes), store=card.store) if until <= today.isoformat(): board.columns["Todo"].append(nxt) else: nxt.until = until place(board.columns["Later"], nxt, later_slot(board.columns["Later"], nxt)) return nxt def unrepeat(board, card): """Un-ticking a repeating card takes back the next one its tick made: the open card with the same title and repeat, due after it, that nobody has started on.""" for col in ("Later", "Todo"): for c in board.columns[col]: if c.title == card.title and c.every == card.every and c.due > (card.due or ""): board.columns[col].remove(c) return c return None def tick(board, today): """Later cards whose day has come go back to the top of Todo, in the order they wait; a month's Done cards go to done/YYYY-MM.md once the month is over. Returns the cards that came back (the phone is told, from step 3).""" woke = [c for c in board.columns["Later"] if c.until and c.until <= today.isoformat()] for card in reversed(woke): board.take(card.id) card.until = "" board.columns["Todo"].insert(0, card) month = today.strftime("%Y-%m") old = [c for c in board.columns["Done"] if c.done and c.done[:7] < month] for card in old: board.take(card.id) path = BOARD / "done" / f"{card.done[:7]}.md" path.parent.mkdir(exist_ok=True) if not path.exists(): path.write_text(f"# Done in {card.done[:7]}\n\n") with path.open("a") as f: f.write(card_line(card, "Done") + "\n" + "".join(f" {n}\n" for n in card.notes)) return woke # ---------------------------------------------------------------- the one way in @contextmanager def locked(): with open(BOARD / ".lock", "w") as f: fcntl.flock(f, fcntl.LOCK_EX) yield def change(op, who="cli"): """Apply one op to the file on disk and commit it. The whole read-apply-write is under the lock.""" with locked(): board = load() before = render(board) result = apply(board, op) if render(board) != before: save(board) commit(f"{who}: {describe(op, result)}") return result def sync(ops, who="app"): """A batch of ops from the phone or a laptop, each with its own "opid", applied in order under one lock and one commit. An opid already seen is skipped, so a batch resent after a dropped connection changes nothing twice. An op whose card has gone (deleted or archived meanwhile) is dropped. A card the client added with an id that is already taken gets a new one, and the rest of the batch follows the new id. Returns (board, applied opids, dropped opids).""" seen_path = BOARD / ".seen" with locked(): seen = seen_path.read_text().split() if seen_path.exists() else [] known = set(seen) board = load() before = render(board) renamed, applied, dropped, done = {}, [], [], [] for op in ops: opid = str(op.get("opid", "")) if not opid or opid in known: applied.append(opid) continue op = {k: v for k, v in op.items() if k not in ("opid", "at")} for key in ("id", "where"): if op.get(key) in renamed: op[key] = renamed[op[key]] if op["op"] == "add" and op.get("id"): if any(c.id == op["id"] for _, c in board.cards()) or not re.fullmatch(r"[a-z0-9]{4}", op["id"]): renamed[op["id"]] = op["id"] = board.new_id() try: result = apply(board, op) done.append(describe(op, result)) applied.append(opid) except (KeyError, ValueError): dropped.append(opid) known.add(opid) seen.append(opid) if render(board) != before: save(board) commit(f"{who}: " + ("; ".join(done) if len(done) <= 3 else f"{len(done)} changes: " + "; ".join(done[:3]) + " …")[:300]) seen_path.write_text("\n".join(seen[-5000:]) + "\n") return board, applied, dropped def version(board): """A short fingerprint of the board, so a client can tell whether anything changed.""" import hashlib return hashlib.sha1(render(board).encode()).hexdigest()[:12] def describe(op, result): if op["op"] == "tick": return f"tick, {len(result)} back from Later" if result else "tick: archived Done" return f"{op['op']} ^{result.id} {result.title}"[:120] def commit(message): if "CAIRN_BOARD" in os.environ: # a test board: nothing to commit return git = ["git", "-C", str(ROOT)] paths = [p for p in ("board/tasks.md", "board/done") if (ROOT / p).exists()] # a missing path fails the add subprocess.run(git + ["add", "--", *paths], capture_output=True) r = subprocess.run(git + ["commit", "-q", "-m", message, "--", *paths], capture_output=True) if r.returncode == 0: # push in the background: a change never waits on GitHub, and an offline Pi just pushes on the next one subprocess.Popen(git + ["push", "-q"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) def as_json(board): return {"columns": {col: [asdict(c) for c in cards] for col, cards in board.columns.items()}, "categories": categories()}