#!/usr/bin/env python3 """cook — minimal store for recipes and tea notes, one markdown file per item. Frontmatter is generated only here (never hand-written) so it never drifts. All operations exit 1 on missing/already-existing targets instead of guessing. """ import argparse import re import sys from datetime import date from pathlib import Path WORKSPACE = Path(__file__).resolve().parents[3] COOK = WORKSPACE / "cook" RECEPTY = COOK / "recepty" CAJ = COOK / "caj" ASSETS = COOK / "assets" FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---\n", re.DOTALL) def parse_fm(text: str) -> dict[str, str]: """Parse the frontmatter block into a flat dict (list values joined by ',').""" m = FRONTMATTER_RE.match(text) if not m: return {} out: dict[str, str] = {} for line in m.group(1).splitlines(): if ":" in line: k, v = line.split(":", 1) out[k.strip()] = v.strip().strip("[]") return out def build_fm(fields: dict[str, str]) -> str: """Emit frontmatter; 'tags' is a list, everything else a plain scalar.""" lines = ["---"] for k, v in fields.items(): if not v: continue if k == "tags": lines.append(f"tags: [{v}]") else: lines.append(f"{k}: {v}") lines.append("---") return "\n".join(lines) def die(msg: str) -> None: print(f"ERROR: {msg}", file=sys.stderr) sys.exit(1) def find_file(slug: str) -> Path | None: for d in (RECEPTY, CAJ): p = d / f"{slug}.md" if p.is_file(): return p return None def require_file(slug: str) -> Path: p = find_file(slug) if not p: die(f"not found: {slug}") return p def type_dir(item_type: str) -> Path: if item_type == "recept": return RECEPTY if item_type == "caj": return CAJ die(f"invalid type: {item_type} (recept|caj)") raise AssertionError def load_items() -> list[tuple[Path, dict[str, str]]]: items = [] for d in (RECEPTY, CAJ): if not d.is_dir(): continue for p in sorted(d.glob("*.md")): items.append((p, parse_fm(p.read_text(encoding="utf-8")))) return items def get_body(text: str) -> str: m = FRONTMATTER_RE.match(text) return text[m.end() :] if m else text def cmd_add(args: argparse.Namespace) -> None: d = type_dir(args.type) d.mkdir(parents=True, exist_ok=True) target = d / f"{args.slug}.md" if target.exists() or find_file(args.slug): die(f"already exists: {args.slug} (use edit, or pick a new slug)") fm: dict[str, str] = { "type": args.type, "category": args.category or "", "added": date.today().isoformat(), } if args.type == "recept": fm["cuisine"] = args.cuisine or "" else: fm["origin"] = args.origin or "" if args.tags: fm["tags"] = ", ".join(args.tags) body = sys.stdin.read() if not sys.stdin.isatty() else "" if args.body_file: body = Path(args.body_file).read_text(encoding="utf-8") if body and not body.startswith("\n"): body = "\n" + body target.write_text(build_fm(fm) + "\n" + body, encoding="utf-8") print(target) def cmd_edit(args: argparse.Namespace) -> None: p = require_file(args.slug) print(p) print("---8<---") print(p.read_text(encoding="utf-8")) def cmd_list(args: argparse.Namespace) -> None: for p, fm in load_items(): if args.type and fm.get("type") != args.type: continue if args.category and fm.get("category") != args.category: continue if args.tag and args.tag not in [ t.strip() for t in fm.get("tags", "").split(",") ]: continue title = get_body(p.read_text(encoding="utf-8")).strip().splitlines() first = title[0].lstrip("# ").strip() if title else "" print( f"{p.stem}\t{fm.get('type', '?')}\t{fm.get('category', '?')}\t{fm.get('added', '?')}\t{first}" ) def cmd_show(args: argparse.Namespace) -> None: print(require_file(args.slug).read_text(encoding="utf-8")) def cmd_search(args: argparse.Namespace) -> None: needle = args.text.lower() for p, _ in load_items(): body = get_body(p.read_text(encoding="utf-8")) for i, line in enumerate(body.splitlines(), 1): if needle in line.lower(): print(f"{p.stem}:{i}: {line.strip()}") def cmd_rename(args: argparse.Namespace) -> None: src = require_file(args.old) if find_file(args.new) or args.old == args.new: die(f"target exists or same: {args.new}") dest = src.parent / f"{args.new}.md" src.rename(dest) a = ASSETS / args.old if a.is_dir(): a.rename(ASSETS / args.new) print(dest) def cmd_delete(args: argparse.Namespace) -> None: p = require_file(args.slug) p.unlink() a = ASSETS / args.slug if a.is_dir() and not any(a.iterdir()): a.rmdir() print(f"deleted: {p}") def cmd_validate(_: argparse.Namespace) -> None: ok = True for p, fm in load_items(): if not fm: print(f"FAIL {p}: no frontmatter") ok = False elif fm.get("type") not in ("recept", "caj"): print(f"FAIL {p}: invalid type: {fm.get('type')!r}") ok = False print("ok" if ok else "invalid") sys.exit(0 if ok else 1) def main() -> None: ap = argparse.ArgumentParser(prog="cook") sub = ap.add_subparsers(dest="cmd", required=True) sp = sub.add_parser("add") sp.add_argument("slug") sp.add_argument("--type", required=True, choices=["recept", "caj"]) sp.add_argument("--category") sp.add_argument("--cuisine") sp.add_argument("--origin") sp.add_argument("--tags", nargs="+") sp.add_argument("--body-file") sp.set_defaults(fn=cmd_add) sp = sub.add_parser("edit") sp.add_argument("slug") sp.set_defaults(fn=cmd_edit) sp = sub.add_parser("list") sp.add_argument("--type", choices=["recept", "caj"]) sp.add_argument("--category") sp.add_argument("--tag") sp.set_defaults(fn=cmd_list) sp = sub.add_parser("show") sp.add_argument("slug") sp.set_defaults(fn=cmd_show) sub.add_parser("validate").set_defaults(fn=cmd_validate) sp = sub.add_parser("search") sp.add_argument("text") sp.set_defaults(fn=cmd_search) sp = sub.add_parser("rename") sp.add_argument("old") sp.add_argument("new") sp.set_defaults(fn=cmd_rename) sp = sub.add_parser("delete") sp.add_argument("slug") sp.set_defaults(fn=cmd_delete) args = ap.parse_args() args.fn(args) if __name__ == "__main__": main()