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
|
## 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:
|
The editor writes normal `.org` pages with the metadata used by the publishing pipeline:
|
||||||
|
|
||||||
- blogs are created under `blogs/YYYY/MM-month/slug.org`
|
- blogs default to `blogs/YYYY/MM-month/slug.org`
|
||||||
- posts are created under `posts/slug.org`, or `posts/section/slug.org` when a section is provided
|
- 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
|
- 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 sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from email.utils import formatdate
|
from email.utils import formatdate
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
@@ -112,49 +112,101 @@ class OrgPage:
|
|||||||
wip: str | None = None
|
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:
|
def __init__(self) -> None:
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self.running = False
|
self._next_id = 1
|
||||||
self.started_at: float | None = None
|
self._pending: list[BuildJob] = []
|
||||||
self.finished_at: float | None = None
|
self._current: BuildJob | None = None
|
||||||
self.ok: bool | None = None
|
self._recent: list[BuildJob] = []
|
||||||
self.message = "No build has run yet."
|
self._worker: threading.Thread | None = None
|
||||||
self.log = ""
|
|
||||||
|
|
||||||
def snapshot(self) -> dict[str, Any]:
|
def snapshot(self) -> dict[str, Any]:
|
||||||
with self._lock:
|
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 {
|
return {
|
||||||
"running": self.running,
|
"running": current is not None,
|
||||||
"startedAt": self.started_at,
|
"queued": len(pending),
|
||||||
"finishedAt": self.finished_at,
|
"message": message,
|
||||||
"ok": self.ok,
|
"current": current,
|
||||||
"message": self.message,
|
"pending": pending,
|
||||||
"log": self.log[-12000:],
|
"recent": recent,
|
||||||
|
"log": latest.get("log", "") if latest else "",
|
||||||
}
|
}
|
||||||
|
|
||||||
def start(self) -> bool:
|
def enqueue(self, path: str, title: str) -> BuildJob:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self.running:
|
job = BuildJob(self._next_id, path, title)
|
||||||
return False
|
self._next_id += 1
|
||||||
self.running = True
|
self._pending.append(job)
|
||||||
self.started_at = time.time()
|
if self._worker is None or not self._worker.is_alive():
|
||||||
self.finished_at = None
|
self._worker = threading.Thread(target=self._run_worker, daemon=True)
|
||||||
self.ok = None
|
self._worker.start()
|
||||||
self.message = "Build started. The editor is locked until publishing finishes."
|
return job
|
||||||
self.log = ""
|
|
||||||
return True
|
|
||||||
|
|
||||||
def finish(self, ok: bool, message: str, log: str) -> None:
|
def _run_worker(self) -> None:
|
||||||
with self._lock:
|
while True:
|
||||||
self.running = False
|
with self._lock:
|
||||||
self.finished_at = time.time()
|
if not self._pending:
|
||||||
self.ok = ok
|
self._current = None
|
||||||
self.message = message
|
return
|
||||||
self.log = log
|
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:
|
def safe_relative_path(path: str) -> Path:
|
||||||
@@ -171,6 +223,17 @@ def safe_relative_path(path: str) -> Path:
|
|||||||
return full
|
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:
|
def read_page(path: Path) -> OrgPage:
|
||||||
text = path.read_text(encoding="utf-8")
|
text = path.read_text(encoding="utf-8")
|
||||||
meta: dict[str, str] = {}
|
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)
|
return safe_relative_path(existing_path)
|
||||||
title = str(data.get("title") or "").strip()
|
title = str(data.get("title") or "").strip()
|
||||||
slug = slugify(str(data.get("slug") or title))
|
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")
|
page_type = str(data.get("pageType") or "blog")
|
||||||
if page_type == "blog":
|
if page_type == "blog":
|
||||||
dt = parse_org_datetime(str(data.get("date") or "")) or datetime.now()
|
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))
|
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_python = ROOT / ".venv" / "bin" / "python"
|
||||||
venv_pip = ROOT / ".venv" / "bin" / "pip"
|
venv_pip = ROOT / ".venv" / "bin" / "pip"
|
||||||
commands = [["emacs", "-Q", "--script", "build-site.el"]]
|
commands = [["emacs", "-Q", "--script", "build-site.el"]]
|
||||||
@@ -335,14 +401,11 @@ def run_build() -> None:
|
|||||||
combined.append(f"\nCommand exited with {proc.returncode}.\n")
|
combined.append(f"\nCommand exited with {proc.returncode}.\n")
|
||||||
break
|
break
|
||||||
message = "Build complete. The site output and search index were regenerated." if ok else "Build failed. Check the log below."
|
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:
|
def queue_build(page: dict[str, Any]) -> dict[str, Any]:
|
||||||
if not BUILD_STATE.start():
|
return BUILD_QUEUE.enqueue(page["path"], page["title"]).to_dict()
|
||||||
return
|
|
||||||
thread = threading.Thread(target=run_build, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
|
|
||||||
|
|
||||||
APP_HTML = r"""<!doctype html>
|
APP_HTML = r"""<!doctype html>
|
||||||
@@ -352,13 +415,12 @@ APP_HTML = r"""<!doctype html>
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Org Site Authoring</title>
|
<title>Org Site Authoring</title>
|
||||||
<style>
|
<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; }
|
* { 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); }
|
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, 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 { 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.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; }
|
.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; }
|
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; }
|
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.running { border-color: var(--accent-2); }
|
||||||
.status.ok { border-color: var(--accent); }
|
.status.ok { border-color: var(--accent); }
|
||||||
.status.fail { border-color: #a12a2a; }
|
.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; }
|
.filters { display: grid; gap: 8px; margin-bottom: 14px; }
|
||||||
.page-list { display: grid; gap: 7px; }
|
.page-list { display: grid; gap: 7px; }
|
||||||
.page-item { width: 100%; text-align: left; min-height: auto; padding: 9px 10px; background: var(--panel); }
|
.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; }
|
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; }
|
.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
||||||
.hint { color: var(--muted); font-size: 13px; }
|
.hint { color: var(--muted); font-size: 13px; }
|
||||||
|
.full { grid-column: 1 / -1; }
|
||||||
.tags { display: flex; gap: 6px; flex-wrap: wrap; }
|
.tags { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
.tag { background: #e5eee9; color: #134d48; padding: 2px 7px; border-radius: 999px; font-size: 12px; }
|
.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; }
|
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; } }
|
@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>
|
</style>
|
||||||
@@ -394,6 +462,7 @@ APP_HTML = r"""<!doctype html>
|
|||||||
<h1>Authoring</h1>
|
<h1>Authoring</h1>
|
||||||
<button id="newBtn" type="button">New</button>
|
<button id="newBtn" type="button">New</button>
|
||||||
</div>
|
</div>
|
||||||
|
<a class="quick-link" href="https://author.zainezq.com">author.zainezq.com</a>
|
||||||
<div id="status" class="status"></div>
|
<div id="status" class="status"></div>
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<input id="search" type="search" placeholder="Filter pages" />
|
<input id="search" type="search" placeholder="Filter pages" />
|
||||||
@@ -433,6 +502,9 @@ APP_HTML = r"""<!doctype html>
|
|||||||
<label>Date
|
<label>Date
|
||||||
<input name="date" placeholder="<2026-05-07 Thu 12:00>" />
|
<input name="date" placeholder="<2026-05-07 Thu 12:00>" />
|
||||||
</label>
|
</label>
|
||||||
|
<label class="full">Org file path
|
||||||
|
<input name="targetPath" placeholder="blogs/2026/05-may/my-page.org" />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label>Content
|
<label>Content
|
||||||
<textarea name="content" spellcheck="true"></textarea>
|
<textarea name="content" spellcheck="true"></textarea>
|
||||||
@@ -444,12 +516,14 @@ APP_HTML = r"""<!doctype html>
|
|||||||
<span id="saveMessage" class="hint"></span>
|
<span id="saveMessage" class="hint"></span>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
<pre id="buildLog"></pre>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
const state = { pages: [], currentPath: "", build: null };
|
const state = { pages: [], currentPath: "", build: null, pathManual: false };
|
||||||
const $ = (selector) => document.querySelector(selector);
|
const $ = (selector) => document.querySelector(selector);
|
||||||
const editor = $("#editor");
|
const editor = $("#editor");
|
||||||
const statusBox = $("#status");
|
const statusBox = $("#status");
|
||||||
@@ -475,18 +549,33 @@ APP_HTML = r"""<!doctype html>
|
|||||||
return data;
|
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() {
|
function renderStatus() {
|
||||||
const build = state.build || {};
|
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.";
|
statusBox.textContent = build.message || "Checking build status.";
|
||||||
buildLog.textContent = build.log || "";
|
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() {
|
function renderPages() {
|
||||||
@@ -514,8 +603,10 @@ APP_HTML = r"""<!doctype html>
|
|||||||
|
|
||||||
function setForm(page) {
|
function setForm(page) {
|
||||||
state.currentPath = page.path || "";
|
state.currentPath = page.path || "";
|
||||||
|
state.pathManual = Boolean(page.path);
|
||||||
editor.pageType.value = page.pageType || "blog";
|
editor.pageType.value = page.pageType || "blog";
|
||||||
editor.section.value = "";
|
editor.section.value = "";
|
||||||
|
editor.targetPath.value = page.path || "";
|
||||||
editor.title.value = page.title || "";
|
editor.title.value = page.title || "";
|
||||||
editor.slug.value = page.slug || slugify(page.title || "");
|
editor.slug.value = page.slug || slugify(page.title || "");
|
||||||
editor.tags.value = (page.tags || []).join(", ");
|
editor.tags.value = (page.tags || []).join(", ");
|
||||||
@@ -524,6 +615,8 @@ APP_HTML = r"""<!doctype html>
|
|||||||
editor.comments.checked = page.comments !== false;
|
editor.comments.checked = page.comments !== false;
|
||||||
$("#formTitle").textContent = state.currentPath ? "Edit page" : "New page";
|
$("#formTitle").textContent = state.currentPath ? "Edit page" : "New page";
|
||||||
$("#pathLabel").textContent = state.currentPath;
|
$("#pathLabel").textContent = state.currentPath;
|
||||||
|
editor.targetPath.disabled = Boolean(state.currentPath);
|
||||||
|
updateSuggestedPath();
|
||||||
saveMessage.textContent = "";
|
saveMessage.textContent = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,32 +633,55 @@ APP_HTML = r"""<!doctype html>
|
|||||||
async function refreshBuild() {
|
async function refreshBuild() {
|
||||||
state.build = await api("/api/build");
|
state.build = await api("/api/build");
|
||||||
renderStatus();
|
renderStatus();
|
||||||
if (!state.build.running) await refreshPages();
|
await refreshPages();
|
||||||
}
|
}
|
||||||
|
|
||||||
editor.title.addEventListener("input", () => {
|
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);
|
$("#search").addEventListener("input", renderPages);
|
||||||
$("#typeFilter").addEventListener("change", renderPages);
|
$("#typeFilter").addEventListener("change", renderPages);
|
||||||
$("#newBtn").addEventListener("click", () => setForm({ pageType: "blog", date: currentOrgDate(), comments: true }));
|
$("#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 }));
|
$("#resetBtn").addEventListener("click", () => state.currentPath ? loadPage(state.currentPath) : setForm({ pageType: "blog", date: currentOrgDate(), comments: true }));
|
||||||
editor.addEventListener("submit", async (event) => {
|
editor.addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
saveMessage.textContent = "Saving and starting build.";
|
saveMessage.textContent = "Saving and queueing build.";
|
||||||
const payload = Object.fromEntries(new FormData(editor).entries());
|
const payload = Object.fromEntries(new FormData(editor).entries());
|
||||||
payload.path = state.currentPath;
|
payload.path = state.currentPath;
|
||||||
payload.comments = editor.comments.checked;
|
payload.comments = editor.comments.checked;
|
||||||
try {
|
try {
|
||||||
const saved = await api("/api/page", { method: "POST", body: JSON.stringify(payload) });
|
const saved = await api("/api/page", { method: "POST", body: JSON.stringify(payload) });
|
||||||
setForm(saved);
|
setForm(saved);
|
||||||
saveMessage.textContent = "Build started. Editing is locked until it finishes.";
|
saveMessage.textContent = "Saved. Build queued.";
|
||||||
await refreshBuild();
|
await refreshBuild();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
saveMessage.textContent = err.message;
|
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 });
|
setForm({ pageType: "blog", date: currentOrgDate(), comments: true });
|
||||||
refreshPages();
|
refreshPages();
|
||||||
refreshBuild();
|
refreshBuild();
|
||||||
@@ -613,7 +729,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||||
return
|
return
|
||||||
if parsed.path == "/api/build":
|
if parsed.path == "/api/build":
|
||||||
self.send_json(BUILD_STATE.snapshot())
|
self.send_json(BUILD_QUEUE.snapshot())
|
||||||
return
|
return
|
||||||
self.send_error(HTTPStatus.NOT_FOUND)
|
self.send_error(HTTPStatus.NOT_FOUND)
|
||||||
|
|
||||||
@@ -621,14 +737,11 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if self.path != "/api/page":
|
if self.path != "/api/page":
|
||||||
self.send_error(HTTPStatus.NOT_FOUND)
|
self.send_error(HTTPStatus.NOT_FOUND)
|
||||||
return
|
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:
|
try:
|
||||||
length = int(self.headers.get("Content-Length", "0"))
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
data = json.loads(self.rfile.read(length).decode("utf-8"))
|
data = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||||
saved = save_page(data)
|
saved = save_page(data)
|
||||||
trigger_build()
|
saved["queuedBuild"] = queue_build(saved)
|
||||||
self.send_json(saved)
|
self.send_json(saved)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
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 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]]
|
[[../../../assets/images/reviews/timesheets/2026-05-03-timesheet.png]]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
|
See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
|
||||||
|
|
||||||
* Posts:
|
* 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: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/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>@@
|
- [[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