diff --git a/.gitignore b/.gitignore index e577e14..0368fc6 100755 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .venv/ output/ backup/ -*~ \ No newline at end of file +__pycache__/ +*~ diff --git a/Makefile b/Makefile index d7497b8..41623c6 100755 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all clean norm help +.PHONY: all clean norm author help VENV := .venv PY := $(VENV)/bin/python @@ -36,6 +36,10 @@ norm: @echo "sorting out the backups..." find . -path ./backups -prune -o -type f -name '*~' -exec mv {} backups/ \; +author: + @echo "Starting local authoring UI..." + python3 authoring_server.py + # Show help message help: @echo "Available targets:" @@ -45,5 +49,6 @@ help: @echo " make clean-output - Remove output directory" @echo " make clean-venv - Remove venv directory" @echo " make norm - Move all files that end with ~ to the backup folder" + @echo " make author - Start the local authoring UI" @echo " make search - creates the search index" @echo " make help - Show this help message!" diff --git a/README.md b/README.md index fb8938f..0fd3595 100755 --- a/README.md +++ b/README.md @@ -3,3 +3,15 @@ This repository contains code for the website https://zainezq.com, which serves as a central familial hub for me. The site is written using Emacs and Org-mode, and exported using the `org-publish` functionality. See the `build-site.el` script for details on how the site is built and published. + +## Local authoring UI + +Run `make author` and open `http://127.0.0.1:8765` to create or edit blog and post Org files from a browser. + +The editor writes normal `.org` pages with the metadata used by the publishing pipeline: + +- blogs are created under `blogs/YYYY/MM-month/slug.org` +- posts are created under `posts/slug.org`, or `posts/section/slug.org` when a section is provided +- saving a page starts `emacs -Q --script build-site.el` and then regenerates the search index + +While that build is running, the UI disables editing and the API rejects further saves. The status panel unlocks the editor when publishing finishes. diff --git a/assets/scripts/zhd.js b/assets/scripts/zhd.js old mode 100644 new mode 100755 diff --git a/assets/styles/style.css b/assets/styles/style.css old mode 100644 new mode 100755 diff --git a/authoring_server.py b/authoring_server.py new file mode 100644 index 0000000..d414913 --- /dev/null +++ b/authoring_server.py @@ -0,0 +1,649 @@ +#!/usr/bin/env python3 +"""Local authoring UI for the Org published website.""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import threading +import time +from dataclasses import dataclass +from datetime import datetime +from email.utils import formatdate +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.parse import parse_qs, urlparse + + +ROOT = Path(__file__).resolve().parent +BLOGS_DIR = ROOT / "blogs" +POSTS_DIR = ROOT / "posts" +GENERATED_ORG_NAMES = { + "blogs-list.org", + "posts-list.org", + "career-list.org", + "sitemap.org", + "recently-updated.org", + "wip.org", +} +MONTH_NAMES = [ + "january", + "february", + "march", + "april", + "may", + "june", + "july", + "august", + "september", + "october", + "november", + "december", +] + + +def slugify(value: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + return slug or "untitled" + + +def normalise_tags(value: Any) -> list[str]: + if isinstance(value, str): + raw = re.split(r"[,:\s]+", value) + elif isinstance(value, list): + raw = [str(item) for item in value] + else: + raw = [] + tags = [] + for tag in raw: + if not tag.strip(): + continue + clean = slugify(tag) + if clean and clean not in tags: + tags.append(clean) + return tags + + +def org_date(dt: datetime) -> str: + return dt.strftime("<%Y-%m-%d %a %H:%M>") + + +def parse_org_datetime(value: str | None) -> datetime | None: + if not value: + return None + match = re.search(r"<(\d{4})-(\d{2})-(\d{2})(?:\s+\w+)?(?:\s+(\d{2}):(\d{2}))?>", value) + if not match: + return None + year, month, day, hour, minute = match.groups() + return datetime( + int(year), + int(month), + int(day), + int(hour or 12), + int(minute or 0), + ) + + +def html_escape(value: str) -> str: + return ( + value.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + ) + + +@dataclass +class OrgPage: + path: str + page_type: str + title: str + slug: str + tags: list[str] + content: str + date: str + comments: bool + options: str + wip: str | None = None + + +class BuildState: + def __init__(self) -> None: + self._lock = threading.Lock() + self.running = False + self.started_at: float | None = None + self.finished_at: float | None = None + self.ok: bool | None = None + self.message = "No build has run yet." + self.log = "" + + def snapshot(self) -> dict[str, Any]: + with self._lock: + return { + "running": self.running, + "startedAt": self.started_at, + "finishedAt": self.finished_at, + "ok": self.ok, + "message": self.message, + "log": self.log[-12000:], + } + + def start(self) -> bool: + with self._lock: + if self.running: + return False + self.running = True + self.started_at = time.time() + self.finished_at = None + self.ok = None + self.message = "Build started. The editor is locked until publishing finishes." + self.log = "" + return True + + def finish(self, ok: bool, message: str, log: str) -> None: + with self._lock: + self.running = False + self.finished_at = time.time() + self.ok = ok + self.message = message + self.log = log + + +BUILD_STATE = BuildState() + + +def safe_relative_path(path: str) -> Path: + rel = Path(path) + if rel.is_absolute() or ".." in rel.parts: + raise ValueError("Path must stay inside this repository.") + full = (ROOT / rel).resolve() + if not full.is_relative_to(ROOT): + raise ValueError("Path must stay inside this repository.") + if full.suffix != ".org": + raise ValueError("Only .org files can be edited.") + if not (full.is_relative_to(BLOGS_DIR) or full.is_relative_to(POSTS_DIR)): + raise ValueError("Only blogs/ and posts/ files can be edited here.") + return full + + +def read_page(path: Path) -> OrgPage: + text = path.read_text(encoding="utf-8") + meta: dict[str, str] = {} + body_lines: list[str] = [] + in_header = True + for line in text.splitlines(): + if in_header and line.startswith("#+"): + key, _, value = line[2:].partition(":") + meta[key.strip().upper()] = value.strip() + else: + in_header = False + body_lines.append(line) + rel = path.relative_to(ROOT).as_posix() + page_type = "blog" if path.is_relative_to(BLOGS_DIR) else "post" + slug = meta.get("SLUG") or path.stem + tags = normalise_tags(meta.get("FILETAGS", "")) + return OrgPage( + path=rel, + page_type=page_type, + title=meta.get("TITLE", path.stem), + slug=slug, + tags=tags, + content="\n".join(body_lines).lstrip("\n"), + date=meta.get("DATE", org_date(datetime.fromtimestamp(path.stat().st_mtime))), + comments=meta.get("COMMENTS", "t").lower() == "t", + options=meta.get("OPTIONS", "num:nil"), + wip=meta.get("WIP"), + ) + + +def page_to_dict(page: OrgPage) -> dict[str, Any]: + return { + "path": page.path, + "pageType": page.page_type, + "title": page.title, + "slug": page.slug, + "tags": page.tags, + "content": page.content, + "date": page.date, + "comments": page.comments, + "options": page.options, + "wip": page.wip or "", + } + + +def list_pages() -> list[dict[str, Any]]: + pages = [] + for base, page_type in ((BLOGS_DIR, "blog"), (POSTS_DIR, "post")): + if not base.exists(): + continue + for path in sorted(base.rglob("*.org")): + if path.name in GENERATED_ORG_NAMES or "sync-conflict" in path.name: + continue + try: + page = read_page(path) + except UnicodeDecodeError: + continue + parsed = parse_org_datetime(page.date) + pages.append( + { + "path": page.path, + "pageType": page_type, + "title": page.title, + "slug": page.slug, + "tags": page.tags, + "date": page.date, + "timestamp": parsed.timestamp() if parsed else path.stat().st_mtime, + } + ) + return sorted(pages, key=lambda item: item["timestamp"], reverse=True) + + +def target_path(data: dict[str, Any], existing_path: str | None) -> Path: + if existing_path: + return safe_relative_path(existing_path) + title = str(data.get("title") or "").strip() + slug = slugify(str(data.get("slug") or title)) + page_type = str(data.get("pageType") or "blog") + if page_type == "blog": + dt = parse_org_datetime(str(data.get("date") or "")) or datetime.now() + folder = BLOGS_DIR / str(dt.year) / f"{dt.month:02d}-{MONTH_NAMES[dt.month - 1]}" + return folder / f"{slug}.org" + if page_type == "post": + raw_section = str(data.get("section") or "").strip() + section = slugify(raw_section) if raw_section else "" + folder = POSTS_DIR / section if section else POSTS_DIR + return folder / f"{slug}.org" + raise ValueError("pageType must be blog or post.") + + +def render_org(data: dict[str, Any], previous: OrgPage | None) -> str: + title = str(data.get("title") or "").strip() + if not title: + raise ValueError("Title is required.") + slug = slugify(str(data.get("slug") or title)) + tags = normalise_tags(data.get("tags", [])) + content = str(data.get("content") or "").replace("\r\n", "\n").strip() + date = str(data.get("date") or "").strip() + if not parse_org_datetime(date): + date = previous.date if previous else org_date(datetime.now()) + options = str(data.get("options") or (previous.options if previous else "num:nil")).strip() + comments = bool(data.get("comments", True)) + lines = [ + f"#+TITLE: {title}", + f"#+OPTIONS: {options}", + f"#+DATE: {date}", + f"#+filetags: {''.join(f':{tag}' for tag in tags)}:", + ] + wip = str(data.get("wip") or (previous.wip if previous else "") or "").strip() + if wip: + lines.append(f"#+WIP: {wip}") + lines.extend( + [ + f"#+COMMENTS: {'t' if comments else ''}", + f"#+SLUG: {slug}", + "", + content, + "", + ] + ) + return "\n".join(lines) + + +def save_page(data: dict[str, Any]) -> dict[str, Any]: + existing_path = data.get("path") or None + target = target_path(data, str(existing_path) if existing_path else None) + previous = read_page(target) if target.exists() else None + if not target.parent.exists(): + target.parent.mkdir(parents=True) + if target.exists() and not existing_path: + raise ValueError(f"{target.relative_to(ROOT)} already exists.") + target.write_text(render_org(data, previous), encoding="utf-8") + return page_to_dict(read_page(target)) + + +def run_build() -> None: + venv_python = ROOT / ".venv" / "bin" / "python" + venv_pip = ROOT / ".venv" / "bin" / "pip" + commands = [["emacs", "-Q", "--script", "build-site.el"]] + if not venv_python.exists(): + commands.extend( + [ + [sys.executable, "-m", "venv", ".venv"], + [str(venv_pip), "install", "-r", "requirements.txt"], + ] + ) + commands.append([str(venv_python), "search-index-json.py"]) + combined = [] + ok = True + for command in commands: + combined.append(f"$ {' '.join(command)}\n") + proc = subprocess.run( + command, + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + combined.append(proc.stdout) + if proc.returncode != 0: + ok = False + combined.append(f"\nCommand exited with {proc.returncode}.\n") + break + message = "Build complete. The site output and search index were regenerated." if ok else "Build failed. Check the log below." + BUILD_STATE.finish(ok, message, "".join(combined)) + + +def trigger_build() -> None: + if not BUILD_STATE.start(): + return + thread = threading.Thread(target=run_build, daemon=True) + thread.start() + + +APP_HTML = r""" + +
+ + +