diff --git a/bash-scripts/system-clean.sh b/bash-scripts/system-clean.sh new file mode 100755 index 0000000..e5d53f0 --- /dev/null +++ b/bash-scripts/system-clean.sh @@ -0,0 +1,97 @@ +#!/bin/bash + +# ============================ +# Colours +# ============================ +RED="\e[31m" +GREEN="\e[32m" +YELLOW="\e[33m" +BLUE="\e[34m" +MAGENTA="\e[35m" +CYAN="\e[36m" +BOLD="\e[1m" +RESET="\e[0m" + +line() { + echo -e "${CYAN}────────────────────────────────────────────────────────${RESET}" +} + +# ============================ +# DATE HEADER +# ============================ +echo -e "${BOLD}${MAGENTA}=== System Clean Run: $(date '+%Y-%m-%d %H:%M:%S') ===${RESET}" +line + +echo -e "${BOLD}${GREEN}=== Ubuntu System Cleanup ===${RESET}" +line + +# ============================ +# 1. Clean APT cache +# ============================ +echo -y >/dev/null 2>&1 +sudo apt autoclean -y >/dev/null 2>&1 +sudo apt clean -y >/dev/null 2>&1 +echo -e "${GREEN}✓ APT cache cleaned.${RESET}" +line + +# ============================ +# 2. Journal logs cleanup +# ============================ +echo -e "${BOLD}${BLUE}[2/8] Cleaning journal logs (keeping 100MB)...${RESET}" +echo -e "${CYAN}Current journal disk usage:${RESET}" +journalctl --disk-usage || true +sudo journalctl --vacuum-size=100M >/dev/null 2>&1 +echo -e "${GREEN}✓ Journal logs vacuumed.${RESET}" +line + +# ============================ +# Remove old logs +# ============================ +echo -e "${BOLD}${BLUE}[3/8] Removing rotated logs...${RESET}" +sudo find /var/log -type f -name "*.gz" -delete +sudo find /var/log -type f -name "*.1" -delete +echo -e "${GREEN}✓ Old log archives removed.${RESET}" +line + +# ============================ +# 4. Clear /tmp and /var/tmp +# ============================ +echo -e "${BOLD}${BLUE}[4/8] Clearing temp directories...${RESET}" +sudo rm -rf /tmp/* /var/tmp/* +echo -e "${GREEN}✓ Temporary files cleared.${RESET}" +line + +# ============================ +# 5. Free RAM (cache) +# ============================ +echo -e "${BOLD}${BLUE}[5/8] Dropping filesystem caches (safe)...${RESET}" +sync +echo 3 | sudo tee /proc/sys/vm/drop_caches >/dev/null +echo -e "${GREEN}✓ RAM caches dropped.${RESET}" +line + +# ============================ +# 6. Reset failed services +# ============================ +echo -e "${BOLD}${BLUE}[6/8] Resetting failed systemd units...${RESET}" +sudo systemctl reset-failed >/dev/null 2>&1 +echo -e "${GREEN}✓ Failed units reset.${RESET}" +line + +# ============================ +# 7. Disk usage snapshot +# ============================ +echo -e "${BOLD}${BLUE}[7/8] Largest directories under / ...${RESET}" +du -h --max-depth=1 / 2>/dev/null | sort -hr | head -n 10 +echo -e "${GREEN}✓ Disk usage shown.${RESET}" +line + +# ============================ +# 8. CPU usage snapshot +# ============================ +echo -e "${BOLD}${BLUE}[8/8] Top CPU-consuming processes...${RESET}" +ps -eo pid,ppid,cmd,%mem,%cpu --sort=-%cpu | head +echo -e "${GREEN}✓ CPU snapshot shown.${RESET}" +line + +echo -e "${BOLD}${GREEN}=== Cleanup Complete! ===${RESET}" diff --git a/bash-scripts/system-clean.sh~ b/bash-scripts/system-clean.sh~ new file mode 100644 index 0000000..e69de29 diff --git a/cleaners/clean-halifax-actual.py b/cleaners/clean-halifax-actual.py old mode 100644 new mode 100755 diff --git a/cleaners/clean-halifax-actual.py~ b/cleaners/clean-halifax-actual.py~ old mode 100644 new mode 100755 diff --git a/cleaners/nationwide-cleaner.py b/cleaners/nationwide-cleaner.py old mode 100644 new mode 100755 diff --git a/cleaners/nationwide-cleaner.py~ b/cleaners/nationwide-cleaner.py~ old mode 100644 new mode 100755 diff --git a/cookbook_org_sync.py b/cookbook_org_sync.py new file mode 100644 index 0000000..802352e --- /dev/null +++ b/cookbook_org_sync.py @@ -0,0 +1,377 @@ +#!/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/" + "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) diff --git a/cookbook_org_sync.py~ b/cookbook_org_sync.py~ new file mode 100644 index 0000000..7816dc5 --- /dev/null +++ b/cookbook_org_sync.py~ @@ -0,0 +1,338 @@ +#!/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", "") # or hard-code +NEXTCLOUD_PASSWORD = os.environ.get("NEXTCLOUD_PASS", "") # or hard-code + +RECIPES_ENDPOINT = "/apps/cookbook/api/v1/recipes" + +OUTPUT_ORG = Path( + "/home/zaine/master-folder/org_files/org_roam/" + "20260307233451-recipes_cookbook.org" +) + +# ───────────────────────────────────────────────────────────── +# API fetch +# ───────────────────────────────────────────────────────────── + +def fetch_recipes(): + url = NEXTCLOUD_BASE_URL.rstrip("/") + RECIPES_ENDPOINT + credentials = base64.b64encode( + f"{NEXTCLOUD_USERNAME}:{NEXTCLOUD_PASSWORD}".encode() + ).decode() + + req = Request(url, headers={ + "Authorization": f"Basic {credentials}", + "Accept": "application/json", + "OCS-APIRequest": "true", + }) + + try: + with urlopen(req, timeout=30) as resp: + raw = resp.read() + except URLError as exc: + raise RuntimeError(f"Failed to reach Nextcloud API: {exc}") 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:") + + # Placeholder reminder if never cooked + if not note.get("cooked_dates"): + lines.append( + "# Add :COOKED: in the drawer above " + "when you first make this." + ) + + 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) diff --git a/cover_to_pdf.py b/cover_to_pdf.py old mode 100644 new mode 100755 diff --git a/discord-integrations/actual-reminder.py b/discord-integrations/actual-reminder.py old mode 100644 new mode 100755 diff --git a/discord-integrations/actual-reminder.py~ b/discord-integrations/actual-reminder.py~ old mode 100644 new mode 100755 diff --git a/discord-integrations/countdown.py b/discord-integrations/countdown.py old mode 100644 new mode 100755 diff --git a/discord-integrations/monitor.ps1 b/discord-integrations/monitor.ps1 old mode 100644 new mode 100755 diff --git a/discord-integrations/monitor.ps1~ b/discord-integrations/monitor.ps1~ old mode 100644 new mode 100755 diff --git a/legacy/crawler.py b/legacy/crawler.py old mode 100644 new mode 100755 diff --git a/legacy/fetch.py b/legacy/fetch.py old mode 100644 new mode 100755 diff --git a/legacy/gen-hash/generate-hash.js b/legacy/gen-hash/generate-hash.js old mode 100644 new mode 100755 diff --git a/legacy/gen-hash/package-lock.json b/legacy/gen-hash/package-lock.json old mode 100644 new mode 100755 diff --git a/legacy/gen-hash/package.json b/legacy/gen-hash/package.json old mode 100644 new mode 100755 diff --git a/legacy/halifax/clean-halifax-actual-2.py b/legacy/halifax/clean-halifax-actual-2.py old mode 100644 new mode 100755 diff --git a/legacy/halifax/halifax_state.json b/legacy/halifax/halifax_state.json old mode 100644 new mode 100755 diff --git a/legacy/halifax/new-cleaned.py b/legacy/halifax/new-cleaned.py old mode 100644 new mode 100755 diff --git a/legacy/insert_services.py b/legacy/insert_services.py old mode 100644 new mode 100755 diff --git a/legacy/move_rss.py b/legacy/move_rss.py old mode 100644 new mode 100755 diff --git a/legacy/website_pages.xlsx b/legacy/website_pages.xlsx old mode 100644 new mode 100755