This commit is contained in:
@@ -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.
|
||||
|
||||
233
authoring_server.py
Normal file → Executable file
233
authoring_server.py
Normal file → Executable file
@@ -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:
|
||||
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 = ""
|
||||
@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 = ""
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return {
|
||||
"running": self.running,
|
||||
@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,
|
||||
"log": self.log[-12000:],
|
||||
}
|
||||
if include_log:
|
||||
data["log"] = self.log[-12000:]
|
||||
return data
|
||||
|
||||
|
||||
class BuildQueue:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
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": 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:
|
||||
def _run_worker(self) -> None:
|
||||
while True:
|
||||
with self._lock:
|
||||
self.running = False
|
||||
self.finished_at = time.time()
|
||||
self.ok = ok
|
||||
self.message = message
|
||||
self.log = log
|
||||
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"""<!doctype html>
|
||||
@@ -352,13 +415,12 @@ APP_HTML = r"""<!doctype html>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Org Site Authoring</title>
|
||||
<style>
|
||||
:root { color-scheme: light; --bg: #f6f3ed; --panel: #fffdf8; --ink: #222; --muted: #64615b; --line: #d8d1c3; --accent: #18615b; --accent-2: #8f3f2b; --disabled: #ece7dc; }
|
||||
:root { color-scheme: light; --bg: #f6f3ed; --panel: #fffdf8; --ink: #222; --muted: #64615b; --line: #d8d1c3; --accent: #18615b; --accent-2: #8f3f2b; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--bg); color: var(--ink); }
|
||||
button, input, textarea, select { font: inherit; }
|
||||
button { border: 1px solid var(--line); background: #fff; color: var(--ink); min-height: 38px; padding: 0 12px; border-radius: 6px; cursor: pointer; }
|
||||
button.primary { background: var(--accent); color: white; border-color: var(--accent); }
|
||||
button:disabled, input:disabled, textarea:disabled, select:disabled { cursor: not-allowed; background: var(--disabled); color: var(--muted); }
|
||||
.app { min-height: 100vh; display: grid; grid-template-columns: minmax(260px, 360px) 1fr; }
|
||||
aside { border-right: 1px solid var(--line); background: #eee8dc; padding: 18px; overflow: auto; max-height: 100vh; }
|
||||
main { padding: 20px clamp(18px, 3vw, 42px); overflow: auto; max-height: 100vh; }
|
||||
@@ -369,6 +431,7 @@ APP_HTML = r"""<!doctype html>
|
||||
.status.running { border-color: var(--accent-2); }
|
||||
.status.ok { border-color: var(--accent); }
|
||||
.status.fail { border-color: #a12a2a; }
|
||||
.quick-link { display: inline-block; color: var(--accent); font-size: 13px; margin: -6px 0 14px; }
|
||||
.filters { display: grid; gap: 8px; margin-bottom: 14px; }
|
||||
.page-list { display: grid; gap: 7px; }
|
||||
.page-item { width: 100%; text-align: left; min-height: auto; padding: 9px 10px; background: var(--panel); }
|
||||
@@ -381,8 +444,13 @@ APP_HTML = r"""<!doctype html>
|
||||
textarea { min-height: 48vh; resize: vertical; font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; line-height: 1.45; font-size: 14px; }
|
||||
.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
||||
.hint { color: var(--muted); font-size: 13px; }
|
||||
.full { grid-column: 1 / -1; }
|
||||
.tags { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.tag { background: #e5eee9; color: #134d48; padding: 2px 7px; border-radius: 999px; font-size: 12px; }
|
||||
.queue-list { display: grid; gap: 8px; margin-bottom: 12px; }
|
||||
.queue-item { border: 1px solid var(--line); border-radius: 6px; background: var(--panel); padding: 9px 10px; font-size: 13px; }
|
||||
.queue-item strong { display: block; overflow-wrap: anywhere; }
|
||||
.queue-item span { color: var(--muted); overflow-wrap: anywhere; }
|
||||
pre { white-space: pre-wrap; overflow: auto; max-height: 280px; background: #201f1d; color: #f7f1e4; padding: 12px; border-radius: 6px; font-size: 12px; }
|
||||
@media (max-width: 860px) { .app { grid-template-columns: 1fr; } aside, main { max-height: none; } aside { border-right: 0; border-bottom: 1px solid var(--line); } .grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -394,6 +462,7 @@ APP_HTML = r"""<!doctype html>
|
||||
<h1>Authoring</h1>
|
||||
<button id="newBtn" type="button">New</button>
|
||||
</div>
|
||||
<a class="quick-link" href="https://author.zainezq.com">author.zainezq.com</a>
|
||||
<div id="status" class="status"></div>
|
||||
<div class="filters">
|
||||
<input id="search" type="search" placeholder="Filter pages" />
|
||||
@@ -433,6 +502,9 @@ APP_HTML = r"""<!doctype html>
|
||||
<label>Date
|
||||
<input name="date" placeholder="<2026-05-07 Thu 12:00>" />
|
||||
</label>
|
||||
<label class="full">Org file path
|
||||
<input name="targetPath" placeholder="blogs/2026/05-may/my-page.org" />
|
||||
</label>
|
||||
</div>
|
||||
<label>Content
|
||||
<textarea name="content" spellcheck="true"></textarea>
|
||||
@@ -444,12 +516,14 @@ APP_HTML = r"""<!doctype html>
|
||||
<span id="saveMessage" class="hint"></span>
|
||||
</div>
|
||||
</form>
|
||||
<h2>Build log</h2>
|
||||
<h2>Build queue</h2>
|
||||
<div id="queue" class="queue-list"></div>
|
||||
<h2>Latest build log</h2>
|
||||
<pre id="buildLog"></pre>
|
||||
</main>
|
||||
</div>
|
||||
<script>
|
||||
const state = { pages: [], currentPath: "", build: null };
|
||||
const state = { pages: [], currentPath: "", build: null, pathManual: false };
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const editor = $("#editor");
|
||||
const statusBox = $("#status");
|
||||
@@ -475,18 +549,33 @@ APP_HTML = r"""<!doctype html>
|
||||
return data;
|
||||
}
|
||||
|
||||
function setLocked(locked) {
|
||||
editor.querySelectorAll("input, textarea, select, button").forEach((el) => el.disabled = locked);
|
||||
$("#newBtn").disabled = locked;
|
||||
pagesBox.querySelectorAll("button").forEach((el) => el.disabled = locked);
|
||||
}
|
||||
|
||||
function renderStatus() {
|
||||
const build = state.build || {};
|
||||
statusBox.className = "status" + (build.running ? " running" : build.ok === true ? " ok" : build.ok === false ? " fail" : "");
|
||||
const latest = build.current || (build.recent && build.recent[build.recent.length - 1]);
|
||||
statusBox.className = "status" + (build.running ? " running" : latest && latest.ok === true ? " ok" : latest && latest.ok === false ? " fail" : "");
|
||||
statusBox.textContent = build.message || "Checking build status.";
|
||||
buildLog.textContent = build.log || "";
|
||||
setLocked(Boolean(build.running));
|
||||
renderQueue();
|
||||
}
|
||||
|
||||
function renderQueue() {
|
||||
const box = $("#queue");
|
||||
const build = state.build || {};
|
||||
const items = [];
|
||||
if (build.current) items.push(build.current);
|
||||
(build.pending || []).forEach((job) => items.push(job));
|
||||
(build.recent || []).slice().reverse().slice(0, 5).forEach((job) => items.push(job));
|
||||
box.innerHTML = "";
|
||||
if (!items.length) {
|
||||
box.innerHTML = `<div class="queue-item"><span>No queued builds.</span></div>`;
|
||||
return;
|
||||
}
|
||||
items.forEach((job) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "queue-item";
|
||||
row.innerHTML = `<strong>#${job.id} ${html(job.status)} · ${html(job.title)}</strong><span>${html(job.path)} · ${html(job.message)}</span>`;
|
||||
box.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function renderPages() {
|
||||
@@ -514,8 +603,10 @@ APP_HTML = r"""<!doctype html>
|
||||
|
||||
function setForm(page) {
|
||||
state.currentPath = page.path || "";
|
||||
state.pathManual = Boolean(page.path);
|
||||
editor.pageType.value = page.pageType || "blog";
|
||||
editor.section.value = "";
|
||||
editor.targetPath.value = page.path || "";
|
||||
editor.title.value = page.title || "";
|
||||
editor.slug.value = page.slug || slugify(page.title || "");
|
||||
editor.tags.value = (page.tags || []).join(", ");
|
||||
@@ -524,6 +615,8 @@ APP_HTML = r"""<!doctype html>
|
||||
editor.comments.checked = page.comments !== false;
|
||||
$("#formTitle").textContent = state.currentPath ? "Edit page" : "New page";
|
||||
$("#pathLabel").textContent = state.currentPath;
|
||||
editor.targetPath.disabled = Boolean(state.currentPath);
|
||||
updateSuggestedPath();
|
||||
saveMessage.textContent = "";
|
||||
}
|
||||
|
||||
@@ -540,32 +633,55 @@ APP_HTML = r"""<!doctype html>
|
||||
async function refreshBuild() {
|
||||
state.build = await api("/api/build");
|
||||
renderStatus();
|
||||
if (!state.build.running) await refreshPages();
|
||||
await refreshPages();
|
||||
}
|
||||
|
||||
editor.title.addEventListener("input", () => {
|
||||
if (!state.currentPath) editor.slug.value = slugify(editor.title.value);
|
||||
if (!state.currentPath) {
|
||||
editor.slug.value = slugify(editor.title.value);
|
||||
updateSuggestedPath();
|
||||
}
|
||||
});
|
||||
editor.slug.addEventListener("input", updateSuggestedPath);
|
||||
editor.pageType.addEventListener("change", updateSuggestedPath);
|
||||
editor.section.addEventListener("input", updateSuggestedPath);
|
||||
editor.date.addEventListener("input", updateSuggestedPath);
|
||||
editor.targetPath.addEventListener("input", () => { state.pathManual = true; });
|
||||
$("#search").addEventListener("input", renderPages);
|
||||
$("#typeFilter").addEventListener("change", renderPages);
|
||||
$("#newBtn").addEventListener("click", () => setForm({ pageType: "blog", date: currentOrgDate(), comments: true }));
|
||||
$("#resetBtn").addEventListener("click", () => state.currentPath ? loadPage(state.currentPath) : setForm({ pageType: "blog", date: currentOrgDate(), comments: true }));
|
||||
editor.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
saveMessage.textContent = "Saving and starting build.";
|
||||
saveMessage.textContent = "Saving and queueing build.";
|
||||
const payload = Object.fromEntries(new FormData(editor).entries());
|
||||
payload.path = state.currentPath;
|
||||
payload.comments = editor.comments.checked;
|
||||
try {
|
||||
const saved = await api("/api/page", { method: "POST", body: JSON.stringify(payload) });
|
||||
setForm(saved);
|
||||
saveMessage.textContent = "Build started. Editing is locked until it finishes.";
|
||||
saveMessage.textContent = "Saved. Build queued.";
|
||||
await refreshBuild();
|
||||
} catch (err) {
|
||||
saveMessage.textContent = err.message;
|
||||
}
|
||||
});
|
||||
|
||||
function updateSuggestedPath() {
|
||||
if (state.currentPath || state.pathManual) return;
|
||||
const slug = slugify(editor.slug.value || editor.title.value);
|
||||
if (editor.pageType.value === "post") {
|
||||
const section = slugify(editor.section.value || "");
|
||||
editor.targetPath.value = section === "untitled" ? `posts/${slug}.org` : `posts/${section}/${slug}.org`;
|
||||
return;
|
||||
}
|
||||
const match = editor.date.value.match(/<(\d{4})-(\d{2})-/);
|
||||
const year = match ? match[1] : String(new Date().getFullYear());
|
||||
const month = match ? Number(match[2]) : new Date().getMonth() + 1;
|
||||
const months = ["january","february","march","april","may","june","july","august","september","october","november","december"];
|
||||
editor.targetPath.value = `blogs/${year}/${String(month).padStart(2, "0")}-${months[month - 1]}/${slug}.org`;
|
||||
}
|
||||
|
||||
setForm({ pageType: "blog", date: currentOrgDate(), comments: true });
|
||||
refreshPages();
|
||||
refreshBuild();
|
||||
@@ -613,7 +729,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
return
|
||||
if parsed.path == "/api/build":
|
||||
self.send_json(BUILD_STATE.snapshot())
|
||||
self.send_json(BUILD_QUEUE.snapshot())
|
||||
return
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
|
||||
@@ -621,14 +737,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if self.path != "/api/page":
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
if BUILD_STATE.snapshot()["running"]:
|
||||
self.send_json({"error": "A build is already running. Wait for it to finish before editing."}, HTTPStatus.CONFLICT)
|
||||
return
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
data = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
saved = save_page(data)
|
||||
trigger_build()
|
||||
saved["queuedBuild"] = queue_build(saved)
|
||||
self.send_json(saved)
|
||||
except Exception as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
|
||||
@@ -7,4 +7,6 @@
|
||||
|
||||
This week was spent finalising the removals of the deployment flags, as well as starting the ESS work. I picked up a task to create a generic import module script.
|
||||
|
||||
This is a test
|
||||
|
||||
[[../../../assets/images/reviews/timesheets/2026-05-03-timesheet.png]]
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
|
||||
|
||||
* Posts:
|
||||
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">07-05-2026 12:18</span>@@
|
||||
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">07-05-2026 12:30</span>@@
|
||||
- [[file:posts-list.sync-conflict-20260417-233432-VT6366A.org][Posts List]] @@html:<span class="post-date">14-04-2026 16:36</span>@@
|
||||
- [[file:career/ha-dr.org][High Availability, Disaster Recovery and Business Continuity]] @@html:<span class="post-date">11-03-2026 17:18</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
|
||||
- [[file:career/javascript.org][Understands the Javascript language]] @@html:<span class="post-date">11-03-2026 16:52</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
|
||||
|
||||
Reference in New Issue
Block a user