initial 2

This commit is contained in:
2026-05-13 15:46:33 +01:00
parent 410a752abd
commit d4e441b596
23 changed files with 92163 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.venv/
__pycache__/
*.py[cod]
.pytest_cache/

101
Makefile Normal file
View File

@@ -0,0 +1,101 @@
.PHONY: test author author-install author-start author-stop author-stop-legacy author-restart author-status author-logs author-health author-diagnostics clean-venv help
VENV := .venv
PY := $(VENV)/bin/python
PIP := $(VENV)/bin/pip
AUTHOR_PORT := 8765
AUTHOR_URL := http://127.0.0.1:$(AUTHOR_PORT)
AUTHOR_PAGES_CHECK := /tmp/authoring-server-pages.json
AUTHOR_SERVICE := org-web-authoring.service
AUTHOR_UNIT_SRC := systemd/user/$(AUTHOR_SERVICE)
SYSTEMD_USER_DIR := $(HOME)/.config/systemd/user
AUTHOR_UNIT_DST := $(SYSTEMD_USER_DIR)/$(AUTHOR_SERVICE)
USER_ID := $(shell id -u)
SYSTEMD_RUNTIME_DIR ?= /run/user/$(USER_ID)
SYSTEMCTL_USER := env XDG_RUNTIME_DIR=$(SYSTEMD_RUNTIME_DIR) systemctl --user
JOURNALCTL_USER := env XDG_RUNTIME_DIR=$(SYSTEMD_RUNTIME_DIR) journalctl --user
test: $(VENV)
$(PY) -m unittest discover -s tests -p 'test_authoring_server.py' -v
$(VENV):
@echo "Generating virtual environment"
python3 -m venv $(VENV)
$(PIP) install -r requirements.txt
author: author-start
author-install: $(VENV)
@echo "Installing $(AUTHOR_SERVICE) for the current user..."
@mkdir -p "$(SYSTEMD_USER_DIR)"
install -m 0644 "$(AUTHOR_UNIT_SRC)" "$(AUTHOR_UNIT_DST)"
$(SYSTEMCTL_USER) daemon-reload
$(SYSTEMCTL_USER) enable "$(AUTHOR_SERVICE)"
author-start: author-install author-stop-legacy
@echo "Starting authoring UI with systemd..."
$(SYSTEMCTL_USER) start "$(AUTHOR_SERVICE)"
@$(MAKE) --no-print-directory author-health
author-stop:
@echo "Stopping authoring UI with systemd..."
$(SYSTEMCTL_USER) stop "$(AUTHOR_SERVICE)" || true
@$(MAKE) --no-print-directory author-stop-legacy
author-stop-legacy:
@echo "Stopping any legacy process on authoring port $(AUTHOR_PORT)..."
@fuser -k $(AUTHOR_PORT)/tcp >/dev/null 2>&1 || true
author-restart: author-install author-stop-legacy
@echo "Restarting authoring UI with systemd..."
$(SYSTEMCTL_USER) restart "$(AUTHOR_SERVICE)"
@$(MAKE) --no-print-directory author-health
author-health:
@echo "Waiting for authoring UI and page list to respond..."
@for i in 1 2 3 4 5 6 7 8 9 10; do \
if ! $(SYSTEMCTL_USER) is-active --quiet "$(AUTHOR_SERVICE)"; then \
echo "Authoring UI service is not active. Service status:"; \
$(SYSTEMCTL_USER) status --no-pager "$(AUTHOR_SERVICE)" || true; \
echo "Recent logs:"; \
$(JOURNALCTL_USER) -u "$(AUTHOR_SERVICE)" -n 80 --no-pager || true; \
exit 1; \
fi; \
if curl -fsS "$(AUTHOR_URL)/api/pages" -o "$(AUTHOR_PAGES_CHECK)" && $(PY) -c 'import json,sys; data=json.load(sys.stdin); sys.exit(0 if isinstance(data, list) and data else 1)' < "$(AUTHOR_PAGES_CHECK)"; then \
echo "Authoring UI is running on $(AUTHOR_URL)/"; \
exit 0; \
fi; \
sleep 1; \
done; \
echo "Authoring UI failed to respond. Service status:"; \
$(SYSTEMCTL_USER) status --no-pager "$(AUTHOR_SERVICE)" || true; \
echo "Recent logs:"; \
$(JOURNALCTL_USER) -u "$(AUTHOR_SERVICE)" -n 80 --no-pager || true; \
exit 1
author-status:
@$(SYSTEMCTL_USER) status --no-pager "$(AUTHOR_SERVICE)" || true
@curl -fsS "$(AUTHOR_URL)/api/pages" -o "$(AUTHOR_PAGES_CHECK)" && $(PY) -c 'import json,sys; print(f"Pages: {len(json.load(sys.stdin))}")' < "$(AUTHOR_PAGES_CHECK)" || true
author-diagnostics:
@curl -fsS "$(AUTHOR_URL)/api/diagnostics" || true
author-logs:
$(JOURNALCTL_USER) -u "$(AUTHOR_SERVICE)" -n 100 --no-pager
clean-venv:
@echo "Cleaning virtual environment..."
rm -rf $(VENV)
help:
@echo "Available targets:"
@echo " make test - Run authoring server tests"
@echo " make author - Install and start the authoring UI service"
@echo " make author-install - Install and enable the authoring UI service"
@echo " make author-stop - Stop the authoring UI service"
@echo " make author-restart - Restart the authoring UI service"
@echo " make author-status - Show authoring UI service status and page count"
@echo " make author-diagnostics - Show authoring UI runtime diagnostics"
@echo " make author-logs - Show recent authoring UI service logs"
@echo " make clean-venv - Remove virtual environment"

View File

@@ -0,0 +1,26 @@
## Authoring service
Standalone local authoring UI for the Org website content in:
`/home/zaine/master-folder/org_files/org_web`
Run:
```sh
make author
```
Then open `http://127.0.0.1:8765`.
The service is installed as the user systemd unit `org-web-authoring.service`.
Useful commands:
- `make test` runs the authoring server unit tests.
- `make author-install` installs and enables the user service.
- `make author-restart` restarts the service.
- `make author-status` shows service status and editable page count.
- `make author-diagnostics` shows runtime diagnostics.
- `make author-logs` shows recent service logs.
The service code runs from this repository. The editable content root is set by `AUTHOR_CONTENT_ROOT` in `systemd/user/org-web-authoring.service`; it currently points at the website repository. Hidden-details backups are stored in this repository under `backups/hidden-details`.

9854
assets/content/hidden-details.json Executable file

File diff suppressed because it is too large Load Diff

1089
assets/scripts/hidden-details.js Executable file

File diff suppressed because it is too large Load Diff

5204
authoring_server.py Executable file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

3
requirements.txt Executable file
View File

@@ -0,0 +1,3 @@
beautifulsoup4
lxml
legacy-cgi

View File

@@ -0,0 +1,18 @@
[Unit]
Description=Org web authoring UI
After=network.target
[Service]
Type=simple
WorkingDirectory=/home/zaine/master-folder/projects/authoring-service
Environment=AUTHOR_CONTENT_ROOT=/home/zaine/master-folder/org_files/org_web
Environment=AUTHOR_HIDDEN_BACKUP_DIR=/home/zaine/master-folder/projects/authoring-service/backups/hidden-details
Environment=AUTHOR_PORT=8765
Environment=PYTHONUNBUFFERED=1
ExecStartPre=/home/zaine/master-folder/projects/authoring-service/.venv/bin/python -c "import authoring_server as s; pages=s.list_pages(); print(f'Authoring root: {s.ROOT}'); print(f'Editable pages: {len(pages)}'); raise SystemExit(0 if pages else 1)"
ExecStart=/home/zaine/master-folder/projects/authoring-service/.venv/bin/python -u /home/zaine/master-folder/projects/authoring-service/authoring_server.py
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target

355
tests/test_authoring_server.py Executable file
View File

@@ -0,0 +1,355 @@
import os
import tempfile
import unittest
from datetime import datetime
from pathlib import Path
from unittest import mock
import authoring_server as server
class AuthoringServerTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
self.blogs = self.root / "blogs"
self.posts = self.root / "posts"
self.lima = self.root / "lima"
self.home = self.root / "home"
self.tags = self.root / "tags"
self.hzone = self.root / "assets" / "images" / "hzone"
self.blogs.mkdir()
self.posts.mkdir()
self.lima.mkdir()
self.home.mkdir()
self.tags.mkdir()
patches = {
"ROOT": self.root,
"BLOGS_DIR": self.blogs,
"POSTS_DIR": self.posts,
"LIMA_DIR": self.lima,
"IMAGE_ASSETS_DIR": self.root / "assets" / "images",
"HZONE_ASSETS_DIR": self.hzone,
}
self.patchers = [mock.patch.object(server, name, value) for name, value in patches.items()]
for patcher in self.patchers:
patcher.start()
def tearDown(self):
for patcher in reversed(self.patchers):
patcher.stop()
self.tmp.cleanup()
class UtilityTests(AuthoringServerTestCase):
def test_resolve_root_ignores_invalid_author_root(self):
wrong_root = self.root / "empty"
wrong_root.mkdir()
(self.root / "authoring_server.py").write_text("", encoding="utf-8")
with mock.patch.dict(os.environ, {"AUTHOR_ROOT": str(wrong_root), "GITHUB_WORKSPACE": ""}), mock.patch.object(server.Path, "cwd", return_value=self.root):
self.assertEqual(server.resolve_root(), self.root)
def test_slugify_normalises_text_and_keeps_fallback(self):
self.assertEqual(server.slugify("Hello, Org Web!"), "hello-org-web")
self.assertEqual(server.slugify(" "), "untitled")
def test_normalise_tags_accepts_strings_and_deduplicates(self):
self.assertEqual(
server.normalise_tags("Life, review:Life Emacs"),
["life", "review", "emacs"],
)
def test_parse_org_datetime_handles_date_and_optional_time(self):
self.assertEqual(
server.parse_org_datetime("<2026-05-07 Thu 14:35>"),
datetime(2026, 5, 7, 14, 35),
)
self.assertEqual(
server.parse_org_datetime("<2026-05-07 Thu>"),
datetime(2026, 5, 7, 12, 0),
)
self.assertIsNone(server.parse_org_datetime("2026-05-07"))
def test_safe_relative_path_allows_expected_content_roots(self):
self.assertEqual(
server.safe_relative_path("blogs/example.org"),
self.blogs / "example.org",
)
self.assertEqual(
server.safe_relative_path("posts/career/example.org"),
self.posts / "career" / "example.org",
)
self.assertEqual(
server.safe_relative_path("lima/index.md"),
self.lima / "index.md",
)
self.assertEqual(
server.safe_relative_path("home/notes.org"),
self.home / "notes.org",
)
def test_safe_relative_path_rejects_escapes_and_wrong_locations(self):
for path in ("../secret.org", "/tmp/secret.org", "sitemap.org", "assets/style.org", "tags/life.org"):
with self.subTest(path=path):
with self.assertRaises(ValueError):
server.safe_relative_path(path)
def test_image_dimensions_detects_png_and_gif(self):
png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 8 + (640).to_bytes(4, "big") + (480).to_bytes(4, "big")
gif = b"GIF89a" + (320).to_bytes(2, "little") + (200).to_bytes(2, "little")
self.assertEqual(server.image_dimensions(png, ".png"), (640, 480))
self.assertEqual(server.image_dimensions(gif, ".gif"), (320, 200))
self.assertIsNone(server.image_dimensions(b"not an image", ".png"))
def test_parse_upload_form_reads_attachment_and_page_path(self):
boundary = "----authoring-test"
content_type = f"multipart/form-data; boundary={boundary}"
body = (
f"--{boundary}\r\n"
'Content-Disposition: form-data; name="pagePath"\r\n\r\n'
"lima/index.md\r\n"
f"--{boundary}\r\n"
'Content-Disposition: form-data; name="attachment"; filename="photo.png"\r\n'
"Content-Type: image/png\r\n\r\n"
).encode("utf-8") + b"image bytes\r\n" + f"--{boundary}--\r\n".encode("utf-8")
filename, payload, page_path = server.parse_upload_form(content_type, body)
self.assertEqual(filename, "photo.png")
self.assertEqual(payload, b"image bytes")
self.assertEqual(page_path, "lima/index.md")
class PageRenderingTests(AuthoringServerTestCase):
def test_render_org_writes_metadata_and_body(self):
rendered = server.render_org(
{
"title": "A New Note",
"slug": "Custom Slug",
"tags": ["Life", "life", "Review"],
"content": "Body text",
"date": "<2026-05-07 Thu 10:30>",
"comments": False,
"wip": "draft",
},
previous=None,
)
self.assertIn("#+TITLE: A New Note", rendered)
self.assertIn("#+DATE: <2026-05-07 Thu 10:30>", rendered)
self.assertIn("#+filetags: :life:review:", rendered)
self.assertIn("#+COMMENTS: ", rendered)
self.assertIn("#+SLUG: custom-slug", rendered)
self.assertIn("#+WIP: draft", rendered)
self.assertTrue(rendered.endswith("Body text\n"))
def test_render_markdown_adds_or_replaces_title_heading(self):
self.assertEqual(
server.render_markdown({"title": "Family Update", "content": "Body"}),
"# Family Update\n\nBody\n",
)
self.assertEqual(
server.render_markdown({"title": "New Title", "content": "## Old\n\nBody"}),
"# New Title\n\nBody\n",
)
def test_save_page_creates_blog_and_round_trips_content(self):
saved = server.save_page(
{
"pageType": "blog",
"title": "Test Post",
"slug": "test-post",
"date": "<2026-05-07 Thu 09:00>",
"tags": "test, blog",
"content": "The body",
"comments": True,
}
)
self.assertEqual(saved["path"], "blogs/2026/05-may/test-post.org")
self.assertEqual(saved["title"], "Test Post")
self.assertEqual(saved["tags"], ["test", "blog"])
self.assertEqual(saved["content"], "The body")
def test_save_page_creates_lima_markdown(self):
saved = server.save_page(
{
"pageType": "lima",
"title": "Lima Entry",
"slug": "lima-entry",
"content": "Some markdown",
}
)
path = self.lima / "lima-entry.md"
self.assertEqual(saved["path"], "lima/lima-entry.md")
self.assertEqual(path.read_text(encoding="utf-8"), "# Lima Entry\n\nSome markdown\n")
def test_list_pages_excludes_generated_and_sync_conflict_files(self):
(self.blogs / "keep.org").write_text("#+TITLE: Keep\n#+DATE: <2026-05-07 Thu 12:00>\n", encoding="utf-8")
(self.posts / "posts-list.org").write_text("#+TITLE: Generated\n", encoding="utf-8")
(self.blogs / "note.sync-conflict-1.org").write_text("#+TITLE: Conflict\n", encoding="utf-8")
(self.lima / "index.md").write_text("# Lima Home\n", encoding="utf-8")
(self.home / "notes.org").write_text("#+TITLE: Notes\n", encoding="utf-8")
(self.tags / "life.org").write_text("#+TITLE: Tag\n", encoding="utf-8")
paths = [page["path"] for page in server.list_pages()]
self.assertEqual(set(paths), {"blogs/keep.org", "home/notes.org", "lima/index.md"})
def test_list_pages_skips_files_that_fail_to_read(self):
good = self.blogs / "keep.org"
bad = self.blogs / "bad.org"
good.write_text("#+TITLE: Keep\n#+DATE: <2026-05-07 Thu 12:00>\n", encoding="utf-8")
bad.write_text("#+TITLE: Bad\n", encoding="utf-8")
original_read_page = server.read_page
def read_page(path):
if path == bad:
raise OSError("file disappeared")
return original_read_page(path)
with mock.patch.object(server, "read_page", side_effect=read_page):
paths = [page["path"] for page in server.list_pages()]
self.assertEqual(paths, ["blogs/keep.org"])
def test_server_diagnostics_reports_root_and_page_count(self):
(self.blogs / "keep.org").write_text("#+TITLE: Keep\n#+DATE: <2026-05-07 Thu 12:00>\n", encoding="utf-8")
diagnostics = server.server_diagnostics()
self.assertEqual(diagnostics["root"], self.root.as_posix())
self.assertEqual(diagnostics["pageCount"], 1)
self.assertEqual(diagnostics["firstPage"], "blogs/keep.org")
def test_relative_asset_path_is_calculated_from_lima_page_directory(self):
self.assertEqual(
server.relative_asset_path("lima/family/update.md", "assets/images/hzone/pic.png"),
"../../assets/images/hzone/pic.png",
)
self.assertEqual(
server.relative_asset_path("blogs/example.org", "assets/images/hzone/pic.png"),
"../assets/images/hzone/pic.png",
)
def test_save_upload_stores_post_images_by_section_and_returns_org_link(self):
with mock.patch.object(server, "datetime") as datetime_mock:
datetime_mock.now.return_value = datetime(2026, 5, 7, 9, 30)
saved = server.save_upload(
"Stress In Workplace.png",
b"image bytes",
"posts/career/management-of-self.org",
)
target = self.root / "assets" / "images" / "career" / "2026-05-07-stress-in-workplace.png"
self.assertEqual(target.read_bytes(), b"image bytes")
self.assertEqual(saved["path"], "assets/images/career/2026-05-07-stress-in-workplace.png")
self.assertEqual(saved["relativeUrl"], "../../assets/images/career/2026-05-07-stress-in-workplace.png")
self.assertEqual(saved["insertText"], "[[../../assets/images/career/2026-05-07-stress-in-workplace.png]]")
def test_save_upload_keeps_blog_images_in_blog_folder(self):
with mock.patch.object(server, "datetime") as datetime_mock:
datetime_mock.now.return_value = datetime(2026, 5, 7, 9, 30)
saved = server.save_upload(
"Lunch.jpg",
b"image bytes",
"blogs/2026/05-may/lunch.org",
)
self.assertEqual(saved["path"], "assets/images/blogs/2026-05-07-lunch.jpg")
self.assertEqual(saved["insertText"], "[[../../../assets/images/blogs/2026-05-07-lunch.jpg]]")
def test_save_upload_inserts_absolute_public_url_for_markdown(self):
with mock.patch.object(server, "datetime") as datetime_mock:
datetime_mock.now.return_value = datetime(2026, 5, 9, 9, 30)
saved = server.save_upload(
"Screen Shot.png",
b"image bytes",
"lima/index.md",
)
self.assertEqual(saved["path"], "assets/images/hzone/2026-05-09-screen-shot.png")
self.assertEqual(saved["relativeUrl"], "../assets/images/hzone/2026-05-09-screen-shot.png")
self.assertEqual(
saved["insertText"],
"![2026-05-09-screen-shot.png](https://zainezq.com/assets/images/hzone/2026-05-09-screen-shot.png)",
)
def test_save_upload_rejects_disallowed_extensions(self):
with self.assertRaises(ValueError):
server.save_upload("shell.php", b"<?php")
with self.assertRaises(ValueError):
server.save_upload("movie.mp4", b"video")
class BuildQueueTests(unittest.TestCase):
def test_snapshot_reports_recent_completed_job(self):
queue = server.BuildQueue()
job = server.BuildJob(1, "blogs/post.org", "Post")
job.started_at = 1.0
job.finished_at = 2.0
job.ok = True
job.message = "Done"
job.log = "build log"
queue._recent.append(job)
snapshot = queue.snapshot()
self.assertFalse(snapshot["running"])
self.assertEqual(snapshot["message"], "Done")
self.assertEqual(snapshot["recent"][0]["status"], "done")
self.assertEqual(snapshot["log"], "build log")
def test_queue_build_enqueues_from_page_data(self):
queue = server.BuildQueue()
with mock.patch.object(server, "BUILD_QUEUE", queue), mock.patch.object(queue, "_run_worker"):
queued = server.queue_build({"path": "blogs/post.org", "title": "Post"})
self.assertEqual(queued["id"], 1)
self.assertEqual(queued["status"], "queued")
self.assertEqual(queued["path"], "blogs/post.org")
def test_run_build_commands_streams_output_to_callback(self):
root = Path(tempfile.mkdtemp())
try:
venv_python = root / ".venv" / "bin" / "python"
venv_python.parent.mkdir(parents=True)
venv_python.write_text("", encoding="utf-8")
seen = []
class FakeProcess:
def __init__(self, lines, return_code=0):
self.stdout = iter(lines)
self.return_code = return_code
def wait(self):
return self.return_code
processes = [
FakeProcess(["emacs line 1\n", "emacs line 2\n"]),
FakeProcess(["index line\n"]),
]
with mock.patch.object(server, "ROOT", root), mock.patch.object(server.subprocess, "Popen", side_effect=processes):
ok, message, log = server.run_build_commands(seen.append)
self.assertTrue(ok)
self.assertIn("Build complete", message)
self.assertIn("emacs line 1\n", seen)
self.assertIn("index line\n", seen)
self.assertEqual(log, "".join(seen))
finally:
for path in sorted(root.rglob("*"), reverse=True):
if path.is_file():
path.unlink()
else:
path.rmdir()
root.rmdir()
if __name__ == "__main__":
unittest.main()