diff --git a/README.md b/README.md index 0fd3595..cafc5d1 100755 --- a/README.md +++ b/README.md @@ -6,12 +6,13 @@ The site is written using Emacs and Org-mode, and exported using the `org-publis ## 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. +Run `make author` and open `http://127.0.0.1:8765` to create or edit blog and post Org files from a browser. When deployed behind the reverse proxy, use `https://author.zainezq.com`. 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 +- blogs default to `blogs/YYYY/MM-month/slug.org` +- posts default to `posts/slug.org`, or `posts/section/slug.org` when a section is provided +- new pages can use an explicit path under `blogs/` or `posts/` - 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. +Saves enqueue builds. The editor remains usable while publishing runs, and the queue section shows the current build, pending builds, and recent build results. diff --git a/authoring_server.py b/authoring_server.py old mode 100644 new mode 100755 index d414913..55b06dd --- a/authoring_server.py +++ b/authoring_server.py @@ -10,7 +10,7 @@ import subprocess import sys import threading import time -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from email.utils import formatdate from http import HTTPStatus @@ -112,49 +112,101 @@ class OrgPage: wip: str | None = None -class BuildState: +@dataclass +class BuildJob: + id: int + path: str + title: str + queued_at: float = field(default_factory=time.time) + started_at: float | None = None + finished_at: float | None = None + ok: bool | None = None + message: str = "Queued" + log: str = "" + + @property + def status(self) -> str: + if self.finished_at is not None: + return "done" if self.ok else "failed" + if self.started_at is not None: + return "running" + return "queued" + + def to_dict(self, include_log: bool = False) -> dict[str, Any]: + data = { + "id": self.id, + "path": self.path, + "title": self.title, + "queuedAt": self.queued_at, + "startedAt": self.started_at, + "finishedAt": self.finished_at, + "ok": self.ok, + "status": self.status, + "message": self.message, + } + if include_log: + data["log"] = self.log[-12000:] + return data + + +class BuildQueue: 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 = "" + self._next_id = 1 + self._pending: list[BuildJob] = [] + self._current: BuildJob | None = None + self._recent: list[BuildJob] = [] + self._worker: threading.Thread | None = None def snapshot(self) -> dict[str, Any]: with self._lock: + current = self._current.to_dict(include_log=True) if self._current else None + recent = [job.to_dict(include_log=True) for job in self._recent[-10:]] + pending = [job.to_dict() for job in self._pending] + latest = current or (recent[-1] if recent else None) + message = latest["message"] if latest else "No builds have run yet." return { - "running": self.running, - "startedAt": self.started_at, - "finishedAt": self.finished_at, - "ok": self.ok, - "message": self.message, - "log": self.log[-12000:], + "running": current is not None, + "queued": len(pending), + "message": message, + "current": current, + "pending": pending, + "recent": recent, + "log": latest.get("log", "") if latest else "", } - def start(self) -> bool: + def enqueue(self, path: str, title: str) -> BuildJob: 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 + job = BuildJob(self._next_id, path, title) + self._next_id += 1 + self._pending.append(job) + if self._worker is None or not self._worker.is_alive(): + self._worker = threading.Thread(target=self._run_worker, daemon=True) + self._worker.start() + return job - 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 + def _run_worker(self) -> None: + while True: + with self._lock: + if not self._pending: + self._current = None + return + job = self._pending.pop(0) + job.started_at = time.time() + job.message = "Publishing site and search index." + self._current = job + ok, message, log = run_build_commands() + with self._lock: + job.finished_at = time.time() + job.ok = ok + job.message = message + job.log = log + self._recent.append(job) + self._recent = self._recent[-20:] + self._current = None -BUILD_STATE = BuildState() +BUILD_QUEUE = BuildQueue() def safe_relative_path(path: str) -> Path: @@ -171,6 +223,17 @@ def safe_relative_path(path: str) -> Path: return full +def safe_target_path(path: str, slug: str) -> Path: + candidate = path.strip() + if not candidate: + raise ValueError("Path is required.") + if candidate.endswith("/"): + candidate = f"{candidate}{slug}.org" + elif not Path(candidate).suffix: + candidate = f"{candidate}.org" + return safe_relative_path(candidate) + + def read_page(path: Path) -> OrgPage: text = path.read_text(encoding="utf-8") meta: dict[str, str] = {} @@ -248,6 +311,9 @@ def target_path(data: dict[str, Any], existing_path: str | None) -> Path: return safe_relative_path(existing_path) title = str(data.get("title") or "").strip() slug = slugify(str(data.get("slug") or title)) + explicit_path = str(data.get("targetPath") or "").strip() + if explicit_path: + return safe_target_path(explicit_path, slug) page_type = str(data.get("pageType") or "blog") if page_type == "blog": dt = parse_org_datetime(str(data.get("date") or "")) or datetime.now() @@ -306,7 +372,7 @@ def save_page(data: dict[str, Any]) -> dict[str, Any]: return page_to_dict(read_page(target)) -def run_build() -> None: +def run_build_commands() -> tuple[bool, str, str]: venv_python = ROOT / ".venv" / "bin" / "python" venv_pip = ROOT / ".venv" / "bin" / "pip" commands = [["emacs", "-Q", "--script", "build-site.el"]] @@ -335,14 +401,11 @@ def run_build() -> None: 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)) + return ok, message, "".join(combined) -def trigger_build() -> None: - if not BUILD_STATE.start(): - return - thread = threading.Thread(target=run_build, daemon=True) - thread.start() +def queue_build(page: dict[str, Any]) -> dict[str, Any]: + return BUILD_QUEUE.enqueue(page["path"], page["title"]).to_dict() APP_HTML = r""" @@ -352,13 +415,12 @@ APP_HTML = r""" Org Site Authoring @@ -394,6 +462,7 @@ APP_HTML = r"""

Authoring

+ author.zainezq.com
@@ -433,6 +502,9 @@ APP_HTML = r""" +