This commit is contained in:
649
authoring_server.py
Normal file
649
authoring_server.py
Normal file
@@ -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"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<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; }
|
||||
* { 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; }
|
||||
.topbar { display: flex; align-items: center; gap: 10px; justify-content: space-between; margin-bottom: 16px; }
|
||||
h1 { font-size: 20px; margin: 0; font-weight: 700; }
|
||||
h2 { font-size: 15px; margin: 18px 0 8px; color: var(--muted); font-weight: 700; }
|
||||
.status { border: 1px solid var(--line); background: var(--panel); padding: 10px 12px; border-radius: 6px; font-size: 14px; margin-bottom: 14px; }
|
||||
.status.running { border-color: var(--accent-2); }
|
||||
.status.ok { border-color: var(--accent); }
|
||||
.status.fail { border-color: #a12a2a; }
|
||||
.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); }
|
||||
.page-item strong { display: block; font-size: 14px; margin-bottom: 2px; overflow-wrap: anywhere; }
|
||||
.page-item span { display: block; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
|
||||
form { display: grid; gap: 14px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
label { display: grid; gap: 5px; font-size: 13px; font-weight: 700; color: var(--muted); }
|
||||
input, textarea, select { width: 100%; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--ink); padding: 9px 10px; }
|
||||
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; }
|
||||
.tags { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.tag { background: #e5eee9; color: #134d48; padding: 2px 7px; border-radius: 999px; 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; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<aside>
|
||||
<div class="topbar">
|
||||
<h1>Authoring</h1>
|
||||
<button id="newBtn" type="button">New</button>
|
||||
</div>
|
||||
<div id="status" class="status"></div>
|
||||
<div class="filters">
|
||||
<input id="search" type="search" placeholder="Filter pages" />
|
||||
<select id="typeFilter">
|
||||
<option value="">Blogs and posts</option>
|
||||
<option value="blog">Blogs</option>
|
||||
<option value="post">Posts</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="pages" class="page-list"></div>
|
||||
</aside>
|
||||
<main>
|
||||
<form id="editor">
|
||||
<div class="topbar">
|
||||
<h1 id="formTitle">New page</h1>
|
||||
<span id="pathLabel" class="hint"></span>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<label>Page
|
||||
<select name="pageType">
|
||||
<option value="blog">Blog</option>
|
||||
<option value="post">Post</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Post section
|
||||
<input name="section" placeholder="Optional, for example career" />
|
||||
</label>
|
||||
<label>Title
|
||||
<input name="title" required />
|
||||
</label>
|
||||
<label>Slug
|
||||
<input name="slug" required />
|
||||
</label>
|
||||
<label>Tags
|
||||
<input name="tags" placeholder="life, review" />
|
||||
</label>
|
||||
<label>Date
|
||||
<input name="date" placeholder="<2026-05-07 Thu 12:00>" />
|
||||
</label>
|
||||
</div>
|
||||
<label>Content
|
||||
<textarea name="content" spellcheck="true"></textarea>
|
||||
</label>
|
||||
<label><span><input name="comments" type="checkbox" checked style="width:auto" /> Comments enabled</span></label>
|
||||
<div class="actions">
|
||||
<button class="primary" id="saveBtn" type="submit">Save and build</button>
|
||||
<button id="resetBtn" type="button">Reset</button>
|
||||
<span id="saveMessage" class="hint"></span>
|
||||
</div>
|
||||
</form>
|
||||
<h2>Build log</h2>
|
||||
<pre id="buildLog"></pre>
|
||||
</main>
|
||||
</div>
|
||||
<script>
|
||||
const state = { pages: [], currentPath: "", build: null };
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const editor = $("#editor");
|
||||
const statusBox = $("#status");
|
||||
const pagesBox = $("#pages");
|
||||
const buildLog = $("#buildLog");
|
||||
const saveMessage = $("#saveMessage");
|
||||
|
||||
function slugify(value) {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "untitled";
|
||||
}
|
||||
|
||||
function currentOrgDate() {
|
||||
const d = new Date();
|
||||
const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `<${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${days[d.getDay()]} ${pad(d.getHours())}:${pad(d.getMinutes())}>`;
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const res = await fetch(path, { headers: { "Content-Type": "application/json" }, ...options });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Request failed");
|
||||
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" : "");
|
||||
statusBox.textContent = build.message || "Checking build status.";
|
||||
buildLog.textContent = build.log || "";
|
||||
setLocked(Boolean(build.running));
|
||||
}
|
||||
|
||||
function renderPages() {
|
||||
const query = $("#search").value.toLowerCase();
|
||||
const type = $("#typeFilter").value;
|
||||
pagesBox.innerHTML = "";
|
||||
state.pages
|
||||
.filter((page) => (!type || page.pageType === type))
|
||||
.filter((page) => `${page.title} ${page.path} ${page.tags.join(" ")}`.toLowerCase().includes(query))
|
||||
.slice(0, 120)
|
||||
.forEach((page) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "page-item";
|
||||
btn.innerHTML = `<strong>${html(page.title)}</strong><span>${html(page.path)}</span><span class="tags">${page.tags.map((tag) => `<span class="tag">${html(tag)}</span>`).join("")}</span>`;
|
||||
btn.onclick = () => loadPage(page.path);
|
||||
pagesBox.appendChild(btn);
|
||||
});
|
||||
renderStatus();
|
||||
}
|
||||
|
||||
function html(value) {
|
||||
return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char]));
|
||||
}
|
||||
|
||||
function setForm(page) {
|
||||
state.currentPath = page.path || "";
|
||||
editor.pageType.value = page.pageType || "blog";
|
||||
editor.section.value = "";
|
||||
editor.title.value = page.title || "";
|
||||
editor.slug.value = page.slug || slugify(page.title || "");
|
||||
editor.tags.value = (page.tags || []).join(", ");
|
||||
editor.date.value = page.date || currentOrgDate();
|
||||
editor.content.value = page.content || "";
|
||||
editor.comments.checked = page.comments !== false;
|
||||
$("#formTitle").textContent = state.currentPath ? "Edit page" : "New page";
|
||||
$("#pathLabel").textContent = state.currentPath;
|
||||
saveMessage.textContent = "";
|
||||
}
|
||||
|
||||
async function loadPage(path) {
|
||||
const page = await api(`/api/page?path=${encodeURIComponent(path)}`);
|
||||
setForm(page);
|
||||
}
|
||||
|
||||
async function refreshPages() {
|
||||
state.pages = await api("/api/pages");
|
||||
renderPages();
|
||||
}
|
||||
|
||||
async function refreshBuild() {
|
||||
state.build = await api("/api/build");
|
||||
renderStatus();
|
||||
if (!state.build.running) await refreshPages();
|
||||
}
|
||||
|
||||
editor.title.addEventListener("input", () => {
|
||||
if (!state.currentPath) editor.slug.value = slugify(editor.title.value);
|
||||
});
|
||||
$("#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.";
|
||||
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.";
|
||||
await refreshBuild();
|
||||
} catch (err) {
|
||||
saveMessage.textContent = err.message;
|
||||
}
|
||||
});
|
||||
|
||||
setForm({ pageType: "blog", date: currentOrgDate(), comments: true });
|
||||
refreshPages();
|
||||
refreshBuild();
|
||||
setInterval(refreshBuild, 2500);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "OrgAuthoring/1.0"
|
||||
|
||||
def log_message(self, fmt: str, *args: Any) -> None:
|
||||
sys.stderr.write("%s - %s\n" % (formatdate(time.time()), fmt % args))
|
||||
|
||||
def send_json(self, data: Any, status: HTTPStatus = HTTPStatus.OK) -> None:
|
||||
body = json.dumps(data).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path == "/":
|
||||
body = APP_HTML.encode("utf-8")
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
if parsed.path == "/api/pages":
|
||||
self.send_json(list_pages())
|
||||
return
|
||||
if parsed.path == "/api/page":
|
||||
query = parse_qs(parsed.query)
|
||||
try:
|
||||
path = safe_relative_path(query.get("path", [""])[0])
|
||||
self.send_json(page_to_dict(read_page(path)))
|
||||
except Exception as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
return
|
||||
if parsed.path == "/api/build":
|
||||
self.send_json(BUILD_STATE.snapshot())
|
||||
return
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
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()
|
||||
self.send_json(saved)
|
||||
except Exception as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
port = int(os.environ.get("AUTHOR_PORT", "8765"))
|
||||
server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
|
||||
print(f"Authoring UI running at http://127.0.0.1:{port}")
|
||||
print("Press Ctrl-C to stop.")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user