#!/usr/bin/env python3 import json import os import sys import tempfile import time from datetime import datetime from pathlib import Path from urllib.request import Request, urlopen from urllib.error import URLError import base64 docstring = """ This script fetches recipes from the Nextcloud Cookbook API, merges them with any existing notes already written in the output Org file (cook dates, tips, etc.), and regenerates the file while preserving the org-roam header and all hand-written notes. It runs on a cron schedule (e.g. nightly) and emits a JSON summary line on stdout on success, or on stderr on failure, matching the same format used by calibre_org_sync. JSON summary format: {"script": "cookbook_org_sync", "status": "success", "duration_ms": 42, "output": "/home/zaine/master-folder/org_files/org_roam/20260307233451-recipes_cookbook.org", "timestamp": "2026-03-08T00:00:02.123456"} """ # ───────────────────────────────────────────────────────────── # Configuration – edit these values # ───────────────────────────────────────────────────────────── NEXTCLOUD_BASE_URL = "https://nextcloud.zainezq.com" NEXTCLOUD_USERNAME = os.environ.get("NEXTCLOUD_USER", "zaine") # or hard-code NEXTCLOUD_PASSWORD = os.environ.get("NEXTCLOUD_PASS", "Shakkal123!") # or hard-code RECIPES_ENDPOINT = "/apps/cookbook/api/v1/recipes" OUTPUT_ORG = Path( "/home/zaine/master-folder/org_files/org_roam/Misc/" "20260307233451-recipes_cookbook.org" ) # ───────────────────────────────────────────────────────────── # API fetch # ───────────────────────────────────────────────────────────── def fetch_recipes(): url = NEXTCLOUD_BASE_URL.rstrip("/") + RECIPES_ENDPOINT # Support either a pre-encoded token (NEXTCLOUD_TOKEN) or user:pass token = os.environ.get("NEXTCLOUD_TOKEN", "") if token: auth_header = f"Basic {token}" else: credentials = base64.b64encode( f"{NEXTCLOUD_USERNAME}:{NEXTCLOUD_PASSWORD}".encode() ).decode() auth_header = f"Basic {credentials}" headers = { "Authorization": auth_header, "Accept": "application/json", "OCS-APIRequest": "true", "User-Agent": ( "Mozilla/5.0 (X11; Linux x86_64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/122.0.0.0 Safari/537.36" ), } if os.environ.get("COOKBOOK_DEBUG"): print(f"[debug] GET {url}", file=sys.stderr) print(f"[debug] Headers: { {k: v for k, v in headers.items()} }", file=sys.stderr) req = Request(url, headers=headers) try: with urlopen(req, timeout=30) as resp: raw = resp.read() except URLError as exc: # Capture HTTP error body for easier debugging body = "" if hasattr(exc, "read"): try: body = exc.read().decode(errors="replace") except Exception: pass raise RuntimeError( f"HTTP error from Nextcloud API: {exc} — body: {body!r}" ) from exc try: data = json.loads(raw) except json.JSONDecodeError as exc: raise RuntimeError(f"Invalid JSON from API: {exc}") from exc if not isinstance(data, list): raise RuntimeError(f"Unexpected API response shape: {type(data)}") return data # ───────────────────────────────────────────────────────────── # Organise recipes by category # ───────────────────────────────────────────────────────────── def group_by_category(recipes): """Returns {category: [recipe, ...]} sorted alphabetically.""" grouped = {} for recipe in recipes: cat = (recipe.get("category") or "Uncategorised").strip() grouped.setdefault(cat, []).append(recipe) for cat in grouped: grouped[cat].sort(key=lambda r: r.get("name", "").lower()) return grouped # ───────────────────────────────────────────────────────────── # Preserve existing hand-written notes from the current file # ───────────────────────────────────────────────────────────── def parse_existing_notes(path): """ Walks the current org file and collects the PROPERTIES drawer content for each recipe heading (matched by RECIPE_ID property). Returns {recipe_id_str: {"cooked_dates": [...], "tips": str, "extra_props": [...]}} """ if not path.exists(): return {} lines = path.read_text(encoding="utf-8").splitlines() notes = {} i = 0 while i < len(lines): line = lines[i] # Detect a level-2 recipe heading "** Some Title :keywords:" if line.startswith("** "): recipe_id = None cooked_dates = [] tips_lines = [] extra_props = [] # Look for the PROPERTIES drawer immediately following j = i + 1 in_props = False while j < len(lines): l = lines[j].strip() if l == ":PROPERTIES:": in_props = True j += 1 continue if in_props: if l == ":END:": in_props = False j += 1 break if l.startswith(":RECIPE_ID:"): recipe_id = l.split(":", 2)[2].strip() elif l.startswith(":COOKED:"): # may be repeated val = l.split(":", 2)[2].strip() if val: cooked_dates.append(val) elif l.startswith(":TIPS:"): tips_lines.append(l.split(":", 2)[2].strip()) elif l and not l.startswith(":CATEGORY:") \ and not l.startswith(":KEYWORDS:") \ and not l.startswith(":CREATED:") \ and not l.startswith(":MODIFIED:") \ and not l.startswith(":URL:"): # Preserve unknown/custom properties extra_props.append(l) j += 1 continue break # stop if we hit something that is not the drawer if recipe_id: notes[recipe_id] = { "cooked_dates": cooked_dates, "tips": " ".join(tips_lines), "extra_props": extra_props, } i += 1 return notes # ───────────────────────────────────────────────────────────── # Org header handling # ───────────────────────────────────────────────────────────── def read_org_roam_header(path): """ Returns the org-roam PROPERTIES block (up to and including :END:). Raises RuntimeError if the file exists but has no such block. Returns None if the file does not exist. """ if not path.exists(): return None lines = path.read_text(encoding="utf-8").splitlines() for i, line in enumerate(lines): if line.strip() == ":END:": return "\n".join(lines[: i + 1]).rstrip() raise RuntimeError("No org-roam PROPERTIES drawer found in output file") # ───────────────────────────────────────────────────────────── # Org body generation # ───────────────────────────────────────────────────────────── def emit_org_body(grouped, existing_notes): lines = [] lines.append("#+title: Recipes Cookbook") lines.append("#+AUTHOR: Auto-generated") lines.append("#+filetags: :recipes:cooking:org:index:") lines.append("#+STARTUP: content") lines.append(f"#+PROPERTY: GENERATED_AT {datetime.now().isoformat()}") lines.append("") lines.append( "# How to add notes: under any recipe heading, add :COOKED: " "to record when you made it, and :TIPS: your tip text to add notes." ) lines.append( "# Multiple :COOKED: lines are supported. " "These are preserved across regenerations." ) lines.append("") for category in sorted(grouped.keys(), key=str.lower): lines.append(f"* {category}") lines.append("") for recipe in grouped[category]: rid = str(recipe.get("recipe_id") or recipe.get("id", "")) name = recipe.get("name", "Unnamed Recipe") keywords_raw = recipe.get("keywords", "") or "" keywords = [k.strip() for k in keywords_raw.split(",") if k.strip()] date_created = recipe.get("dateCreated", "") date_modified = recipe.get("dateModified", "") recipe_url = ( f"{NEXTCLOUD_BASE_URL.rstrip('/')}" f"/apps/cookbook/webapp/recipes/{rid}" ) # org tag string from keywords (spaces → underscores) tag_str = ":".join(k.replace(" ", "_") for k in keywords) tag_block = f":{tag_str}:" if tag_str else "" lines.append(f"** {name} {tag_block}".rstrip()) lines.append(":PROPERTIES:") lines.append(f":RECIPE_ID: {rid}") lines.append(f":CATEGORY: {category}") if keywords: lines.append(f":KEYWORDS: {keywords_raw}") lines.append(f":CREATED: {date_created}") lines.append(f":MODIFIED: {date_modified}") lines.append(f":URL: {recipe_url}") # Re-inject preserved notes note = existing_notes.get(rid, {}) for cd in note.get("cooked_dates", []): lines.append(f":COOKED: {cd}") if note.get("tips"): lines.append(f":TIPS: {note['tips']}") for ep in note.get("extra_props", []): lines.append(ep) lines.append(":END:") # ── Visible metadata (exported to HTML) ────────────── cooked = note.get("cooked_dates", []) cooked_str = ", ".join(cooked) if cooked else "/not yet made/" lines.append("#+ATTR_HTML: :class recipe-meta") lines.append("| | |") lines.append("|---|---|") lines.append(f"| *Category* | {category} |") if keywords: lines.append(f"| *Keywords* | {keywords_raw} |") lines.append(f"| *Created* | {date_created[:10] if date_created else '—'} |") lines.append(f"| *Modified* | {date_modified[:10] if date_modified else '—'} |") lines.append(f"| *Cooked* | {cooked_str} |") lines.append(f"| *Link* | [[{recipe_url}][Open in Nextcloud]] |") if note.get("tips"): lines.append("") lines.append(f"*Tips:* {note['tips']}") lines.append("") return "\n".join(lines) # ───────────────────────────────────────────────────────────── # Atomic write # ───────────────────────────────────────────────────────────── def atomic_write(path, content): path.parent.mkdir(parents=True, exist_ok=True) with tempfile.NamedTemporaryFile( "w", delete=False, dir=str(path.parent), encoding="utf-8" ) as tmp: tmp.write(content) temp_name = tmp.name os.replace(temp_name, path) # ───────────────────────────────────────────────────────────── # Main # ───────────────────────────────────────────────────────────── def main(): if not NEXTCLOUD_USERNAME or not NEXTCLOUD_PASSWORD: raise RuntimeError( "Nextcloud credentials not set. " "Export NEXTCLOUD_USER and NEXTCLOUD_PASS environment variables." ) # 1. Read existing org-roam header (must already exist) header = read_org_roam_header(OUTPUT_ORG) if header is None: raise RuntimeError( f"{OUTPUT_ORG} does not exist. " "Create it first with the org-roam ID block." ) # 2. Scrape any hand-written notes from the current file existing_notes = parse_existing_notes(OUTPUT_ORG) # 3. Fetch recipes from Nextcloud recipes = fetch_recipes() # 4. Group by category grouped = group_by_category(recipes) # 5. Generate new body and combine with header body = emit_org_body(grouped, existing_notes) content = header + "\n\n" + body # 6. Write atomically atomic_write(OUTPUT_ORG, content) if __name__ == "__main__": start = time.time() try: main() duration_ms = int((time.time() - start) * 1000) print(json.dumps({ "script": "cookbook_org_sync", "status": "success", "duration_ms": duration_ms, "output": str(OUTPUT_ORG), "timestamp": datetime.now().isoformat(), })) sys.exit(0) except Exception as exc: duration_ms = int((time.time() - start) * 1000) print(json.dumps({ "script": "cookbook_org_sync", "status": "failure", "duration_ms": duration_ms, "error": str(exc), }), file=sys.stderr) sys.exit(1)