Files
org_web/authoring_server.py
Zaine Arch 0ef179385d
All checks were successful
Build Org Website / build (push) Successful in 49s
adding a hidden authoring service support
2026-05-10 15:20:34 +01:00

2836 lines
118 KiB
Python
Executable File

#!/usr/bin/env python3
"""Local authoring UI for the Org published website."""
from __future__ import annotations
import json
import os
import posixpath
import re
import struct
import subprocess
import sys
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime
from email.parser import BytesParser
from email.policy import default as email_default_policy
from email.utils import formatdate
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, Callable
from urllib.parse import parse_qs, urlparse
def looks_like_content_root(path: Path) -> bool:
return (path / "authoring_server.py").exists() and (
(path / "blogs").exists()
or (path / "posts").exists()
or (path / "lima").exists()
)
def resolve_root() -> Path:
env_root = os.environ.get("AUTHOR_ROOT")
if env_root:
resolved = Path(env_root).expanduser().resolve()
if looks_like_content_root(resolved):
return resolved
candidates = [
Path.cwd(),
Path(__file__).resolve().parent,
]
workspace = os.environ.get("GITHUB_WORKSPACE")
if workspace:
candidates.insert(0, Path(workspace))
for base in list(candidates):
candidates.extend(base.parents)
seen = set()
for candidate in candidates:
resolved = candidate.expanduser().resolve()
if resolved in seen:
continue
seen.add(resolved)
if looks_like_content_root(resolved):
return resolved
return Path(__file__).resolve().parent
ROOT = resolve_root()
BLOGS_DIR = ROOT / "blogs"
POSTS_DIR = ROOT / "posts"
LIMA_DIR = ROOT / "lima"
IMAGE_ASSETS_DIR = ROOT / "assets" / "images"
HZONE_ASSETS_DIR = IMAGE_ASSETS_DIR / "hzone"
HIDDEN_DETAILS_JS = ROOT / "assets" / "scripts" / "hidden-details.js"
HIDDEN_CONTENT_JSON = ROOT / "assets" / "content" / "hidden-details.json"
HIDDEN_BACKUP_DIR = ROOT / "backups" / "hidden-details"
EXCLUDED_CONTENT_DIR_NAMES = {
".agents",
".codex",
".git",
".packages",
".venv",
"__pycache__",
"assets",
"backups",
"output",
"tags",
}
GENERATED_ORG_NAMES = {
"blogs-list.org",
"books-list.org",
"posts-list.org",
"career-list.org",
"sitemap.org",
"recently-updated.org",
"wip.org",
}
GENERATED_CONTENT_NAMES = GENERATED_ORG_NAMES | {"lima-list.org"}
ALLOWED_UPLOAD_EXTENSIONS = {
".png",
".jpg",
".jpeg",
".gif",
".webp",
".svg",
}
MONTH_NAMES = [
"january",
"february",
"march",
"april",
"may",
"june",
"july",
"august",
"september",
"october",
"november",
"december",
]
HIDDEN_CONTENT_TYPES = [
"quote",
"poem",
"hidden dialogue",
"journal entry",
"rare event",
"loading screen message",
"secret interaction",
"hidden tooltip",
"Future Z message",
"Young Z memory fragment",
"Sensei Chi wisdom entry",
"Aphy system message",
"Lima note/message",
"dream sequence",
"terminal log",
"fake error message",
"recurring joke",
"seasonal event",
"weather-based event",
"hover message",
"hidden achievement",
"guestbook entry",
"hidden conversation",
"family layer",
"search toast",
"search route",
"keyboard secret",
]
HIDDEN_CHARACTERS = ["Lima", "Aphy", "Sensei Chi", "Young Z", "Future Z", "Z"]
HIDDEN_TONES = ["warm", "funny", "nostalgic", "wise", "strange", "soft", "hopeful", "protective", "melancholy"]
HIDDEN_RARITIES = ["common", "uncommon", "rare", "very rare", "seasonal", "timed"]
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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
@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
@dataclass
class ContentPage:
path: str
page_type: str
title: str
slug: str
tags: list[str]
content: str
date: str
comments: bool
options: str
format: str
wip: str | None = None
@dataclass
class BuildJob:
id: int
path: str
title: str
queued_at: float = field(default_factory=time.time)
started_at: float | None = None
finished_at: float | None = None
ok: bool | None = None
message: str = "Queued"
log: str = ""
@property
def status(self) -> str:
if self.finished_at is not None:
return "done" if self.ok else "failed"
if self.started_at is not None:
return "running"
return "queued"
def to_dict(self, include_log: bool = False) -> dict[str, Any]:
data = {
"id": self.id,
"path": self.path,
"title": self.title,
"queuedAt": self.queued_at,
"startedAt": self.started_at,
"finishedAt": self.finished_at,
"ok": self.ok,
"status": self.status,
"message": self.message,
}
if include_log:
data["log"] = self.log[-12000:]
return data
class BuildQueue:
def __init__(self) -> None:
self._lock = threading.Lock()
self._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 enqueue(self, path: str, title: str) -> BuildJob:
with self._lock:
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 _run_worker(self) -> None:
while True:
with self._lock:
if not self._pending:
self._current = None
return
job = self._pending.pop(0)
job.started_at = time.time()
job.message = "Publishing site and search index."
self._current = job
def append_log(chunk: str) -> None:
with self._lock:
job.log = (job.log + chunk)[-200000:]
ok, message, log = run_build_commands(append_log)
with self._lock:
job.finished_at = time.time()
job.ok = ok
job.message = message
job.log = log[-200000:]
self._recent.append(job)
self._recent = self._recent[-20:]
self._current = None
BUILD_QUEUE = BuildQueue()
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.name in GENERATED_CONTENT_NAMES or "sync-conflict" in full.name:
raise ValueError("Generated and sync-conflict files are not editable here.")
rel_parts = full.relative_to(ROOT).parts
if any(part in EXCLUDED_CONTENT_DIR_NAMES for part in rel_parts):
raise ValueError("This path is outside the editable content folders.")
if full.suffix == ".org":
return full
if full.suffix == ".md" and full.is_relative_to(LIMA_DIR):
return full
raise ValueError("Only .org content files and .md files under lima can be edited here.")
def safe_target_path(path: str, slug: str, page_type: str) -> Path:
candidate = path.strip()
if not candidate:
raise ValueError("Path is required.")
default_ext = ".md" if page_type == "lima" else ".org"
if candidate.endswith("/"):
candidate = f"{candidate}{slug}{default_ext}"
elif not Path(candidate).suffix:
candidate = f"{candidate}{default_ext}"
return safe_relative_path(candidate)
def markdown_title(content: str, fallback: str) -> str:
for line in content.splitlines():
match = re.match(r"^#{1,6}\s+(.+?)\s*$", line)
if match:
return match.group(1).strip()
return fallback.replace("-", " ").replace("_", " ").title()
def read_markdown_page(path: Path) -> ContentPage:
content = path.read_text(encoding="utf-8")
rel = path.relative_to(ROOT).as_posix()
title = markdown_title(content, path.stem)
return ContentPage(
path=rel,
page_type="lima",
title=title,
slug=path.stem,
tags=[],
content=content,
date="",
comments=True,
options="",
format="markdown",
)
def read_page(path: Path) -> ContentPage:
if path.suffix == ".md" and path.is_relative_to(LIMA_DIR):
return read_markdown_page(path)
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()
if path.is_relative_to(BLOGS_DIR):
page_type = "blog"
elif path.is_relative_to(POSTS_DIR):
page_type = "post"
else:
page_type = "page"
slug = meta.get("SLUG") or path.stem
tags = normalise_tags(meta.get("FILETAGS", ""))
return ContentPage(
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"),
format="org",
wip=meta.get("WIP"),
)
def page_to_dict(page: ContentPage) -> 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,
"format": page.format,
"wip": page.wip or "",
}
def server_diagnostics() -> dict[str, Any]:
pages = list_pages()
try:
cwd = Path.cwd().as_posix()
except OSError as exc:
cwd = f"<unavailable: {exc}>"
return {
"root": ROOT.as_posix(),
"cwd": cwd,
"executable": sys.executable,
"pid": os.getpid(),
"pageCount": len(pages),
"firstPage": pages[0]["path"] if pages else "",
}
def list_pages() -> list[dict[str, Any]]:
pages = []
org_paths = []
if ROOT.exists():
try:
for path in ROOT.rglob("*.org"):
try:
rel_parts = path.relative_to(ROOT).parts
except ValueError:
continue
if any(part in EXCLUDED_CONTENT_DIR_NAMES for part in rel_parts):
continue
org_paths.append(path)
except OSError:
org_paths = []
try:
md_paths = list(LIMA_DIR.rglob("*.md")) if LIMA_DIR.exists() else []
except OSError:
md_paths = []
for path in sorted(org_paths + md_paths):
if path.name in GENERATED_CONTENT_NAMES or "sync-conflict" in path.name:
continue
try:
page = read_page(path)
parsed = parse_org_datetime(page.date)
timestamp = parsed.timestamp() if parsed else path.stat().st_mtime
except (OSError, UnicodeDecodeError, ValueError):
continue
pages.append(
{
"path": page.path,
"pageType": page.page_type,
"title": page.title,
"slug": page.slug,
"tags": page.tags,
"date": page.date,
"format": page.format,
"timestamp": timestamp,
}
)
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")
explicit_path = str(data.get("targetPath") or "").strip()
if explicit_path:
return safe_target_path(explicit_path, slug, page_type)
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"
if page_type == "lima":
return LIMA_DIR / f"{slug}.md"
if page_type == "page":
return ROOT / f"{slug}.org"
raise ValueError("pageType must be blog, post, page, or lima.")
def render_markdown(data: dict[str, Any]) -> str:
content = str(data.get("content") or "").replace("\r\n", "\n").strip()
title = str(data.get("title") or "").strip()
if not title:
raise ValueError("Title is required.")
content = re.sub(
r'<a\b[^>]*>\s*<img\b[^>]*\bsrc="([^"]+)"[^>]*\balt="([^"]*)"[^>]*>\s*</a>',
lambda match: f"![{match.group(2)}]({match.group(1)})",
content,
flags=re.IGNORECASE,
)
if content:
if re.search(r"^#{1,6}[^\S\r\n]+.+?[^\S\r\n]*$", content, flags=re.MULTILINE):
content = re.sub(
r"^#{1,6}[^\S\r\n]+.+?[^\S\r\n]*$",
f"# {title}",
content,
count=1,
flags=re.MULTILINE,
)
else:
content = f"# {title}\n\n{content}"
return content + "\n"
return f"# {title}\n"
def render_org(data: dict[str, Any], previous: ContentPage | 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.")
if target.suffix == ".md":
target.write_text(render_markdown(data), encoding="utf-8")
else:
target.write_text(render_org(data, previous), encoding="utf-8")
return page_to_dict(read_page(target))
def image_dimensions(payload: bytes, ext: str) -> tuple[int, int] | None:
if ext == ".png" and payload.startswith(b"\x89PNG\r\n\x1a\n") and len(payload) >= 24:
width, height = struct.unpack(">II", payload[16:24])
return width, height
if ext == ".gif" and payload[:6] in {b"GIF87a", b"GIF89a"} and len(payload) >= 10:
width, height = struct.unpack("<HH", payload[6:10])
return width, height
if ext in {".jpg", ".jpeg"} and payload.startswith(b"\xff\xd8"):
i = 2
while i + 9 < len(payload):
if payload[i] != 0xFF:
i += 1
continue
marker = payload[i + 1]
i += 2
if marker in {0xD8, 0xD9}:
continue
if i + 2 > len(payload):
break
size = int.from_bytes(payload[i:i + 2], "big")
if size < 2:
break
if marker in {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}:
if i + 7 <= len(payload):
height = int.from_bytes(payload[i + 3:i + 5], "big")
width = int.from_bytes(payload[i + 5:i + 7], "big")
return width, height
break
i += size
return None
def relative_asset_path(page_path: str, asset_path: str) -> str:
page = safe_relative_path(page_path) if page_path else ROOT / "index.org"
page_rel = page.relative_to(ROOT).as_posix()
page_output_dir = posixpath.dirname(page_rel)
return posixpath.relpath(asset_path, page_output_dir or ".")
def gallery_image_html(filename: str, asset_path: str, page_path: str, payload: bytes, ext: str) -> str:
absolute_url = f"https://zainezq.com/{asset_path}"
relative_url = relative_asset_path(page_path, asset_path)
dims = image_dimensions(payload, ext)
width, height = dims if dims else (1920, 1080)
alt = html_escape(filename)
return (
f'<a href="{absolute_url}" data-img="{absolute_url}" data-alt="{alt}" '
f'data-width="{width}" data-height="{height}">'
f'<img src="{relative_url}" alt="{alt}" style="cursor: zoom-in;"></a>'
)
def attachment_image_dir(page_path: str) -> Path:
page = safe_relative_path(page_path) if page_path else ROOT / "index.org"
if page.suffix == ".md" and page.is_relative_to(LIMA_DIR):
return HZONE_ASSETS_DIR
if page.is_relative_to(POSTS_DIR):
rel = page.relative_to(POSTS_DIR)
section = rel.parts[0] if len(rel.parts) > 1 else "posts"
return IMAGE_ASSETS_DIR / slugify(section)
if page.is_relative_to(BLOGS_DIR):
return IMAGE_ASSETS_DIR / "blogs"
rel = page.relative_to(ROOT)
if len(rel.parts) > 1:
return IMAGE_ASSETS_DIR / slugify(rel.parts[0])
return IMAGE_ASSETS_DIR / "pages"
def save_upload(filename: str, payload: bytes, page_path: str = "") -> dict[str, str]:
original = Path(filename or "attachment").name
ext = Path(original).suffix.lower()
if ext not in ALLOWED_UPLOAD_EXTENSIONS:
raise ValueError("Only common image files can be uploaded.")
now = datetime.now()
target_dir = attachment_image_dir(page_path)
target_dir.mkdir(parents=True, exist_ok=True)
stem = slugify(Path(original).stem)
prefix = "" if re.match(r"^\d{4}-\d{2}-\d{2}-", stem) else f"{now.strftime('%Y-%m-%d')}-"
target = target_dir / f"{prefix}{stem}{ext}"
counter = 2
while target.exists():
target = target_dir / f"{prefix}{stem}-{counter}{ext}"
counter += 1
target.write_bytes(payload)
rel = target.relative_to(ROOT).as_posix()
absolute_url = f"https://zainezq.com/{rel}"
relative_url = relative_asset_path(page_path, rel)
is_markdown = page_path.endswith(".md")
insert_text = f"![{target.name}]({absolute_url})" if is_markdown else f"[[{relative_url}]]"
return {
"url": absolute_url,
"relativeUrl": relative_url,
"path": rel,
"markdown": insert_text,
"insertText": insert_text,
"filename": target.name,
}
def find_js_const_literal(source: str, name: str) -> str:
marker = f"const {name} ="
start = source.find(marker)
if start == -1:
raise ValueError(f"Could not find const {name}.")
value_start = source.find("=", start) + 1
while value_start < len(source) and source[value_start].isspace():
value_start += 1
opener = source[value_start]
pairs = {"[": "]", "{": "}"}
if opener not in pairs:
raise ValueError(f"const {name} is not an array or object.")
closer = pairs[opener]
depth = 0
in_string = False
escape = False
for index in range(value_start, len(source)):
char = source[index]
if in_string:
if escape:
escape = False
elif char == "\\":
escape = True
elif char == '"':
in_string = False
continue
if char == '"':
in_string = True
elif char == opener:
depth += 1
elif char == closer:
depth -= 1
if depth == 0:
return source[value_start:index + 1]
raise ValueError(f"Could not parse const {name}.")
def js_literal_to_json(value: str) -> Any:
cleaned = re.sub(r"//.*", "", value)
cleaned = re.sub(r"(/\*.*?\*/)", "", cleaned, flags=re.DOTALL)
cleaned = re.sub(r"([{\[,]\s*)([A-Za-z_$][\w$]*)\s*:", r'\1"\2":', cleaned)
cleaned = re.sub(r",(\s*[\]}])", r"\1", cleaned)
return json.loads(cleaned)
def detect_hidden_characters(text: str) -> list[str]:
lowered = text.lower()
found = []
for character in HIDDEN_CHARACTERS:
if character.lower() in lowered:
found.append(character)
return found
def hidden_entry_id(content_type: str, index: int, title: str) -> str:
return f"{slugify(content_type)}-{index + 1:03d}-{slugify(title)[:42]}"
def hidden_title(content_type: str, content: str, index: int) -> str:
text = re.sub(r"\s+", " ", content).strip()
if not text:
return f"{content_type.title()} {index + 1}"
return text[:58] + ("..." if len(text) > 58 else "")
def make_hidden_entry(content_type: str, content: str, index: int, **extra: Any) -> dict[str, Any]:
now_iso = datetime.now().date().isoformat()
title = str(extra.pop("title", "") or hidden_title(content_type, content, index))
characters = extra.pop("characters", None) or detect_hidden_characters(f"{title} {content}")
tags = extra.pop("tags", None) or [slugify(character) for character in characters]
entry = {
"id": hidden_entry_id(content_type, index, title),
"type": content_type,
"title": title,
"content": content,
"characters": characters,
"emotionalTone": extra.pop("emotionalTone", "warm"),
"rarity": extra.pop("rarity", "common"),
"triggerConditions": extra.pop("triggerConditions", ""),
"tags": tags,
"category": extra.pop("category", content_type),
"pageLocation": extra.pop("pageLocation", ""),
"familyLayer": extra.pop("familyLayer", ""),
"enabled": extra.pop("enabled", True),
"createdDate": extra.pop("createdDate", now_iso),
"modifiedDate": extra.pop("modifiedDate", now_iso),
"notes": extra.pop("notes", ""),
"audioSettings": extra.pop("audioSettings", ""),
"animationTrigger": extra.pop("animationTrigger", ""),
"cssClassHooks": extra.pop("cssClassHooks", ""),
"chainReferences": extra.pop("chainReferences", []),
"continuationLinks": extra.pop("continuationLinks", []),
}
entry.update(extra)
return entry
def migrate_hidden_entries_from_js() -> list[dict[str, Any]]:
source = HIDDEN_DETAILS_JS.read_text(encoding="utf-8")
family_layers = js_literal_to_json(find_js_const_literal(source, "familyLayers"))
details = js_literal_to_json(find_js_const_literal(source, "details"))
poems = js_literal_to_json(find_js_const_literal(source, "poems"))
greetings = js_literal_to_json(find_js_const_literal(source, "greetings"))
night_messages = js_literal_to_json(find_js_const_literal(source, "nightMessages"))
lore = js_literal_to_json(find_js_const_literal(source, "lore"))
search_toasts = js_literal_to_json(find_js_const_literal(source, "SEARCH_TOASTS"))
search_routes = js_literal_to_json(find_js_const_literal(source, "SEARCH_ROUTES"))
keyboard_secrets = js_literal_to_json(find_js_const_literal(source, "KEYBOARD_SECRETS"))
long_keyboard_secrets = js_literal_to_json(find_js_const_literal(source, "LONG_KEYBOARD_SECRETS"))
entries: list[dict[str, Any]] = []
add = entries.append
for index, item in enumerate(family_layers):
add(make_hidden_entry("family layer", item, index, familyLayer=str(index), category="Family Layer Index"))
for index, item in enumerate(details):
add(make_hidden_entry("hidden tooltip", item, index, category="footer and whispers"))
for index, item in enumerate(poems):
add(make_hidden_entry("poem", item, index, category="poems", emotionalTone="soft"))
for index, item in enumerate(greetings):
add(make_hidden_entry("loading screen message", item, index, category="homepage greeting"))
for index, item in enumerate(night_messages):
add(make_hidden_entry("rare event", item, index, category="late night", rarity="timed", triggerConditions="hour >= 22 or hour < 5"))
for key, content_type in [
("quotes", "quote"),
("journals", "journal entry"),
("warnings", "fake error message"),
("dreams", "dream sequence"),
("cassettes", "terminal log"),
("fakeUsers", "guestbook entry"),
("homepageTakeovers", "rare event"),
]:
for index, item in enumerate(lore.get(key, [])):
add(make_hidden_entry(content_type, item, index, category=key, rarity="rare" if key in {"warnings", "dreams", "homepageTakeovers"} else "common"))
for index, item in enumerate(lore.get("conversations", [])):
title = " / ".join(str(part) for part in item[::2]) or f"Conversation {index + 1}"
add(make_hidden_entry("hidden conversation", "\n".join(str(part) for part in item), index, title=title, category="conversations", dialogue=item))
for index, (season, item) in enumerate((lore.get("seasonal", {}) or {}).items()):
add(make_hidden_entry("seasonal event", item, index, title=f"{season.title()} note", category="seasonal", rarity="seasonal", triggerConditions=f"season is {season}", season=season))
for index, item in enumerate(lore.get("roomLinks", [])):
href, label = item
add(make_hidden_entry("secret interaction", label, index, title=label, category="room links", pageLocation=href, triggerConditions="secret link injected into page"))
for index, (query, message) in enumerate(search_toasts.items()):
add(make_hidden_entry("search toast", message, index, title=f"Search: {query}", category="search", triggerConditions=query, query=query))
for index, (query, route) in enumerate(search_routes.items()):
add(make_hidden_entry("search route", route, index, title=f"Route: {query}", category="search", pageLocation=route, triggerConditions=query, query=query))
for index, item in enumerate(keyboard_secrets + long_keyboard_secrets):
content = item.get("message") or item.get("route") or ("play tiny Aphy song" if item.get("song") else "")
add(make_hidden_entry("keyboard secret", content, index, title=f"Keyboard: {item.get('phrase')}", category="keyboard", pageLocation=item.get("route", ""), triggerConditions=item.get("phrase", ""), keyboard=item))
return entries
def normalize_hidden_entry(raw: dict[str, Any], existing: dict[str, Any] | None = None) -> dict[str, Any]:
today = datetime.now().date().isoformat()
entry = existing.copy() if existing else {}
entry.update(raw)
title = str(entry.get("title") or "").strip()
content = str(entry.get("content") or "").replace("\r\n", "\n")
content_type = str(entry.get("type") or "quote").strip()
if content_type not in HIDDEN_CONTENT_TYPES:
raise ValueError(f"Unsupported hidden content type: {content_type}")
if not title:
raise ValueError("Every hidden entry needs a title.")
if not content and content_type not in {"search route"}:
raise ValueError(f"{title} needs content.")
entry["id"] = slugify(str(entry.get("id") or title))
entry["title"] = title
entry["type"] = content_type
entry["content"] = content
entry["characters"] = [str(item).strip() for item in entry.get("characters", []) if str(item).strip()]
entry["emotionalTone"] = str(entry.get("emotionalTone") or "warm")
entry["rarity"] = str(entry.get("rarity") or "common")
entry["triggerConditions"] = str(entry.get("triggerConditions") or "")
entry["tags"] = normalise_tags(entry.get("tags", []))
entry["category"] = str(entry.get("category") or content_type)
entry["pageLocation"] = str(entry.get("pageLocation") or "")
entry["familyLayer"] = str(entry.get("familyLayer") or "")
entry["enabled"] = bool(entry.get("enabled", True))
entry["createdDate"] = str(entry.get("createdDate") or today)
entry["modifiedDate"] = today
entry["notes"] = str(entry.get("notes") or "")
entry["audioSettings"] = str(entry.get("audioSettings") or "")
entry["animationTrigger"] = str(entry.get("animationTrigger") or "")
entry["cssClassHooks"] = str(entry.get("cssClassHooks") or "")
entry["chainReferences"] = [str(item).strip() for item in entry.get("chainReferences", []) if str(item).strip()]
entry["continuationLinks"] = [str(item).strip() for item in entry.get("continuationLinks", []) if str(item).strip()]
return entry
def load_hidden_store() -> dict[str, Any]:
migrated = False
if HIDDEN_CONTENT_JSON.exists():
data = json.loads(HIDDEN_CONTENT_JSON.read_text(encoding="utf-8"))
entries = data.get("entries", [])
else:
entries = migrate_hidden_entries_from_js()
migrated = True
data = {
"schemaVersion": 1,
"generatedFrom": "assets/scripts/hidden-details.js",
"generatedAt": datetime.now().isoformat(timespec="seconds"),
"entries": entries,
}
normalized = [normalize_hidden_entry(entry, entry) for entry in entries]
return {
"schemaVersion": 1,
"source": HIDDEN_DETAILS_JS.relative_to(ROOT).as_posix(),
"contentPath": HIDDEN_CONTENT_JSON.relative_to(ROOT).as_posix(),
"migratedFromJs": migrated,
"types": HIDDEN_CONTENT_TYPES,
"characters": HIDDEN_CHARACTERS,
"tones": HIDDEN_TONES,
"rarities": HIDDEN_RARITIES,
"entries": normalized,
"recommendations": hidden_architecture_recommendations(),
}
def hidden_architecture_recommendations() -> dict[str, Any]:
return {
"storage": "Use assets/content/hidden-details.json as the friendly source of truth and regenerate the editable constants in assets/scripts/hidden-details.js.",
"backups": "Every save writes timestamped backups for both JSON and JS under backups/hidden-details/.",
"versioning": "Commit the JSON and generated JS together so the live site and authoring history stay aligned.",
"collaboration": "For simultaneous editing, add per-entry modified timestamps and resolve conflicts by entry id before writing.",
"scalability": "The schema is entry-based, so future rooms, arcs, audio cues, and relationship graphs can be added without rewriting the editor.",
}
def hidden_entries_by_type(entries: list[dict[str, Any]], content_type: str) -> list[dict[str, Any]]:
return [entry for entry in entries if entry.get("enabled", True) and entry.get("type") == content_type]
def hidden_contents(entries: list[dict[str, Any]], content_type: str) -> list[str]:
return [str(entry.get("content", "")) for entry in hidden_entries_by_type(entries, content_type)]
def generated_hidden_content_block(entries: list[dict[str, Any]]) -> str:
family_layers = hidden_contents(entries, "family layer")
details = hidden_contents(entries, "hidden tooltip") + hidden_contents(entries, "hover message")
poems = hidden_contents(entries, "poem")
greetings = hidden_contents(entries, "loading screen message")
night_messages = [
entry["content"] for entry in hidden_entries_by_type(entries, "rare event")
if "night" in f"{entry.get('category', '')} {entry.get('triggerConditions', '')}".lower()
]
quote_like = hidden_contents(entries, "quote") + hidden_contents(entries, "Sensei Chi wisdom entry") + hidden_contents(entries, "Aphy system message") + hidden_contents(entries, "Lima note/message") + hidden_contents(entries, "Future Z message") + hidden_contents(entries, "Young Z memory fragment")
lore = {
"quotes": quote_like,
"conversations": [
entry.get("dialogue") if isinstance(entry.get("dialogue"), list) else [line for line in str(entry.get("content", "")).splitlines() if line.strip()]
for entry in hidden_entries_by_type(entries, "hidden conversation")
],
"journals": hidden_contents(entries, "journal entry"),
"warnings": hidden_contents(entries, "fake error message"),
"dreams": hidden_contents(entries, "dream sequence"),
"cassettes": hidden_contents(entries, "terminal log"),
"fakeUsers": hidden_contents(entries, "guestbook entry"),
"seasonal": {
str(entry.get("season") or entry.get("triggerConditions") or entry.get("title", "")).lower().replace("season is ", ""): entry.get("content", "")
for entry in hidden_entries_by_type(entries, "seasonal event")
},
"homepageTakeovers": [
entry["content"] for entry in hidden_entries_by_type(entries, "rare event")
if "homepage" in f"{entry.get('category', '')} {entry.get('triggerConditions', '')}".lower()
],
"roomLinks": [
[entry.get("pageLocation", ""), entry.get("content", "") or entry.get("title", "")]
for entry in hidden_entries_by_type(entries, "secret interaction")
if entry.get("pageLocation")
],
}
search_toasts = {
entry.get("query") or entry.get("triggerConditions") or entry.get("title", ""): entry.get("content", "")
for entry in hidden_entries_by_type(entries, "search toast")
}
search_routes = {
entry.get("query") or entry.get("triggerConditions") or entry.get("title", ""): entry.get("pageLocation") or entry.get("content", "")
for entry in hidden_entries_by_type(entries, "search route")
}
keyboard_secrets = []
long_keyboard_secrets = []
for entry in hidden_entries_by_type(entries, "keyboard secret"):
secret = dict(entry.get("keyboard") or {})
secret["phrase"] = secret.get("phrase") or entry.get("triggerConditions") or entry.get("title", "")
if entry.get("pageLocation"):
secret["route"] = entry.get("pageLocation")
elif entry.get("content") == "play tiny Aphy song":
secret["song"] = True
else:
secret["message"] = entry.get("content", "")
target = long_keyboard_secrets if len(secret["phrase"]) > 8 or " " in secret["phrase"] else keyboard_secrets
target.append(secret)
def js_const(name: str, value: Any) -> str:
return f" const {name} = {json.dumps(value, ensure_ascii=False, indent=4)};\n"
return (
" // -----------------------------\n"
" // EDITABLE CONTENT\n"
" // -----------------------------\n"
" // Generated by the hidden narrative authoring page.\n"
" // Friendly source of truth: assets/content/hidden-details.json\n\n"
f"{js_const('familyLayers', family_layers)}\n"
f"{js_const('details', details)}\n"
f"{js_const('poems', poems)}\n"
f"{js_const('greetings', greetings)}\n"
f"{js_const('nightMessages', night_messages)}\n"
f"{js_const('lore', lore)}\n"
" // Exact search text -> toast message.\n"
f"{js_const('SEARCH_TOASTS', search_toasts)}\n"
" // Exact search text -> hidden page route.\n"
f"{js_const('SEARCH_ROUTES', search_routes)}\n"
" // Short typed phrases. Stored in sessionStorage as a rolling key chain.\n"
f"{js_const('KEYBOARD_SECRETS', keyboard_secrets)}\n"
" // Longer typed phrases and character names.\n"
f"{js_const('LONG_KEYBOARD_SECRETS', long_keyboard_secrets)}\n"
)
def backup_hidden_file(path: Path, stamp: str) -> None:
if not path.exists():
return
HIDDEN_BACKUP_DIR.mkdir(parents=True, exist_ok=True)
target = HIDDEN_BACKUP_DIR / f"{stamp}-{path.name}"
target.write_text(path.read_text(encoding="utf-8"), encoding="utf-8")
def replace_hidden_editable_block(source: str, entries: list[dict[str, Any]]) -> str:
start = source.find(" // -----------------------------\n // EDITABLE CONTENT")
end = source.find(" // -----------------------------\n // STATE HELPERS")
if start == -1 or end == -1 or end <= start:
raise ValueError("Could not find editable hidden content block.")
return source[:start] + generated_hidden_content_block(entries) + "\n" + source[end:]
def save_hidden_store(payload: dict[str, Any]) -> dict[str, Any]:
current = {entry["id"]: entry for entry in load_hidden_store()["entries"]}
entries = [normalize_hidden_entry(entry, current.get(str(entry.get("id", "")))) for entry in payload.get("entries", [])]
ids = [entry["id"] for entry in entries]
if len(ids) != len(set(ids)):
raise ValueError("Entry ids must be unique.")
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup_hidden_file(HIDDEN_DETAILS_JS, stamp)
backup_hidden_file(HIDDEN_CONTENT_JSON, stamp)
HIDDEN_CONTENT_JSON.parent.mkdir(parents=True, exist_ok=True)
HIDDEN_CONTENT_JSON.write_text(
json.dumps(
{
"schemaVersion": 1,
"generatedAt": datetime.now().isoformat(timespec="seconds"),
"entries": entries,
},
ensure_ascii=False,
indent=2,
) + "\n",
encoding="utf-8",
)
source = HIDDEN_DETAILS_JS.read_text(encoding="utf-8")
HIDDEN_DETAILS_JS.write_text(replace_hidden_editable_block(source, entries), encoding="utf-8")
saved = load_hidden_store()
saved["message"] = "stored safely. future-you will probably smile at this one."
saved["backupStamp"] = stamp
return saved
def parse_upload_form(content_type: str, body: bytes) -> tuple[str, bytes, str]:
if not content_type.lower().startswith("multipart/form-data"):
raise ValueError("Uploads must use multipart/form-data.")
message = BytesParser(policy=email_default_policy).parsebytes(
f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n".encode("utf-8") + body
)
if not message.is_multipart():
raise ValueError("Upload form is not multipart.")
filename = ""
payload = b""
page_path = ""
for part in message.iter_parts():
name = part.get_param("name", header="content-disposition")
if name == "attachment":
filename = part.get_filename() or ""
payload = part.get_payload(decode=True) or b""
elif name == "pagePath":
raw_value = part.get_payload(decode=True) or b""
page_path = raw_value.decode(part.get_content_charset() or "utf-8", errors="replace")
if not filename:
raise ValueError("No attachment was uploaded.")
if not payload:
raise ValueError("Attachment is empty.")
return filename, payload, page_path
def run_build_commands(log_callback: Callable[[str], None] | None = None) -> tuple[bool, str, str]:
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 = []
def append_log(text: str) -> None:
combined.append(text)
if log_callback:
log_callback(text)
ok = True
for command in commands:
append_log(f"$ {' '.join(command)}\n")
env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1"
proc = subprocess.Popen(
command,
cwd=ROOT,
env=env,
text=True,
bufsize=1,
stderr=subprocess.STDOUT,
stdout=subprocess.PIPE,
)
assert proc.stdout is not None
for line in proc.stdout:
append_log(line)
return_code = proc.wait()
if return_code != 0:
ok = False
append_log(f"\nCommand exited with {return_code}.\n")
break
message = "Build complete. The site output and search index were regenerated." if ok else "Build failed. Check the log below."
return ok, message, "".join(combined)
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>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/png" sizes="48x48" href="https://zainezq.com/assets/icons/pen-icon.png" />
<title>Org Site Authoring</title>
<style>
:root {
color-scheme: dark;
--bg: #11100d;
--bg-2: #191814;
--sidebar: #1d1a16;
--panel: #f3ead7;
--panel-2: #e7dac0;
--panel-dark: #242019;
--ink: #282016;
--ink-light: #f7ecd5;
--muted: #9d9078;
--muted-dark: #695d4b;
--line: rgba(202, 179, 126, 0.28);
--line-strong: rgba(217, 190, 124, 0.56);
--accent: #b89145;
--accent-2: #7d2f36;
--accent-3: #446a57;
--focus: rgba(184, 145, 69, 0.3);
--shadow: 0 24px 70px rgba(0, 0, 0, 0.34);
--paper-shadow: 0 22px 48px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.52);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background:
radial-gradient(circle at 18% 12%, rgba(125, 47, 54, 0.24), transparent 28%),
radial-gradient(circle at 84% 18%, rgba(68, 106, 87, 0.24), transparent 24%),
linear-gradient(135deg, #0f0e0b 0%, #17140f 42%, #211b14 100%);
color: var(--ink-light);
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
opacity: 0.2;
background-image:
linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px);
background-size: 34px 34px, 34px 34px;
mask-image: linear-gradient(90deg, rgba(0,0,0,0.9), rgba(0,0,0,0.35));
}
button, input, textarea, select { font: inherit; }
button {
border: 1px solid var(--line);
background: rgba(243, 234, 215, 0.08);
color: var(--ink-light);
min-height: 38px;
padding: 0 12px;
border-radius: 7px;
cursor: pointer;
transition: transform 140ms ease, background 140ms ease, border-color 140ms ease, box-shadow 140ms ease, color 140ms ease;
}
button:hover { transform: translateY(-1px); border-color: var(--line-strong); background: rgba(243, 234, 215, 0.14); box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18); }
button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible, summary:focus-visible { outline: 0; box-shadow: 0 0 0 3px var(--focus); border-color: var(--accent); }
button.primary { background: linear-gradient(180deg, #c79d4e, #96702f); color: #18130d; border-color: #d6b566; font-weight: 820; box-shadow: 0 12px 28px rgba(184, 145, 69, 0.22); }
button.primary:hover { background: linear-gradient(180deg, #d3ad5b, #a87d35); border-color: #e0c378; }
button.icon { min-width: 38px; padding: 0 10px; font-weight: 800; font-family: Georgia, "Times New Roman", serif; }
.app { position: relative; min-height: 100vh; display: grid; grid-template-columns: minmax(320px, 430px) 1fr; }
aside {
border-right: 1px solid var(--line);
background: linear-gradient(180deg, rgba(29, 26, 22, 0.96), rgba(18, 17, 14, 0.98));
padding: 20px;
overflow: auto;
max-height: 100vh;
box-shadow: 18px 0 42px rgba(0, 0, 0, 0.22);
}
main { padding: 28px clamp(20px, 3vw, 46px); overflow: auto; max-height: 100vh; }
.topbar { display: flex; align-items: center; gap: 12px; justify-content: space-between; margin-bottom: 18px; }
h1 { font-family: Georgia, "Times New Roman", serif; font-size: 25px; margin: 0; font-weight: 700; letter-spacing: 0; color: #f8edd7; }
main h1 { font-size: 31px; color: var(--panel); text-shadow: 0 2px 24px rgba(0, 0, 0, 0.34); }
h2 { font-size: 12px; margin: 24px 0 10px; color: #c4a766; font-weight: 840; text-transform: uppercase; letter-spacing: 0.08em; }
.status {
border: 1px solid var(--line);
background: rgba(243, 234, 215, 0.08);
padding: 11px 13px;
border-radius: 8px;
font-size: 13px;
margin-bottom: 14px;
color: #dfd0b5;
box-shadow: inset 3px 0 0 rgba(184, 145, 69, 0.68);
}
.status.running { border-color: rgba(214, 181, 102, 0.62); background: rgba(214, 181, 102, 0.12); }
.status.ok { border-color: rgba(88, 139, 111, 0.7); background: rgba(68, 106, 87, 0.16); box-shadow: inset 3px 0 0 #5e9a78; }
.status.fail { border-color: rgba(158, 62, 70, 0.76); background: rgba(125, 47, 54, 0.18); box-shadow: inset 3px 0 0 #9d3d46; }
.quick-link { display: inline-block; color: #cfb36e; font-size: 13px; margin: -10px 0 14px; text-decoration: none; border-bottom: 1px solid rgba(207, 179, 110, 0.35); }
.quick-link:hover { color: #f1d68d; border-bottom-color: currentColor; }
.filters { display: grid; gap: 9px; margin-bottom: 16px; }
.page-list { display: grid; gap: 5px; }
.tree-dir { margin: 2px 0; }
.tree-dir summary {
list-style: none;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
min-height: 38px;
padding: 7px 9px;
border: 1px solid transparent;
border-radius: 7px;
cursor: pointer;
color: #eee0c4;
}
.tree-dir summary::-webkit-details-marker { display: none; }
.tree-dir summary:hover, .page-item:hover { background: rgba(243, 234, 215, 0.08); border-color: rgba(217, 190, 124, 0.32); }
.tree-dir summary::before { content: ">"; display: inline-block; width: 14px; color: #c8ad69; font-family: "SFMono-Regular", Consolas, monospace; }
.tree-dir[open] > summary::before { content: "v"; }
.tree-label { display: flex; align-items: baseline; gap: 6px; min-width: 0; }
.tree-label strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 760; }
.tree-label span { color: var(--muted); font-size: 12px; white-space: nowrap; }
.tree-children { display: grid; gap: 4px; margin-left: 15px; padding-left: 11px; border-left: 1px solid rgba(217, 190, 124, 0.22); }
.tree-add { min-height: 28px; padding: 0 8px; font-size: 12px; color: #e0c680; background: rgba(184, 145, 69, 0.1); opacity: 0.9; }
.tree-add:hover { opacity: 1; background: rgba(184, 145, 69, 0.18); }
.page-item { width: 100%; text-align: left; min-height: auto; padding: 10px 11px; background: transparent; border-color: transparent; }
.page-item.active { border-color: rgba(214, 181, 102, 0.65); background: rgba(243, 234, 215, 0.13); box-shadow: inset 3px 0 0 var(--accent), 0 12px 24px rgba(0, 0, 0, 0.18); }
.page-item.unsaved { border-color: rgba(203, 130, 75, 0.78); }
.page-item strong { display: block; font-size: 14px; margin-bottom: 3px; overflow-wrap: anywhere; font-weight: 780; color: #f3e7ce; }
.page-item span { display: block; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
.page-item .filename { color: #cab68d; }
form {
display: grid;
gap: 16px;
border: 1px solid rgba(217, 190, 124, 0.32);
border-radius: 8px;
background: linear-gradient(180deg, rgba(36, 32, 25, 0.96), rgba(26, 23, 18, 0.96));
padding: clamp(16px, 2.2vw, 26px);
box-shadow: var(--shadow);
}
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 7px;
align-items: center;
border: 1px solid rgba(217, 190, 124, 0.28);
background: rgba(17, 16, 13, 0.52);
border-radius: 8px;
padding: 8px;
}
.toolbar input[type="file"] { display: none; }
.link-picker { display: grid; grid-template-columns: minmax(220px, 1fr) auto auto; gap: 8px; align-items: center; border: 1px solid rgba(217, 190, 124, 0.28); background: rgba(17, 16, 13, 0.52); border-radius: 8px; padding: 8px; }
label { display: grid; gap: 6px; font-size: 12px; font-weight: 820; color: #c7ad74; text-transform: uppercase; letter-spacing: 0.06em; }
label span { text-transform: none; letter-spacing: 0; color: #ddcfb6; }
input, textarea, select {
width: 100%;
border: 1px solid rgba(107, 91, 57, 0.6);
border-radius: 8px;
background: var(--panel);
color: var(--ink);
padding: 11px 12px;
transition: border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
box-shadow: inset 0 1px 5px rgba(55, 36, 16, 0.1);
}
input::placeholder, textarea::placeholder { color: #8a7b61; }
input:hover, textarea:hover, select:hover { border-color: rgba(214, 181, 102, 0.86); }
textarea {
min-height: 49vh;
resize: vertical;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
line-height: 1.58;
font-size: 14px;
background:
linear-gradient(rgba(83, 63, 32, 0.06) 1px, transparent 1px),
linear-gradient(90deg, rgba(125, 47, 54, 0.1) 1px, transparent 1px),
var(--panel);
background-size: 100% 30px, 54px 100%, auto;
box-shadow: var(--paper-shadow);
}
.content-layout { display: grid; gap: 12px; }
.content-layout.previewing { grid-template-columns: minmax(0, 1fr) minmax(280px, 1fr); align-items: start; }
.preview-panel {
border: 1px solid rgba(107, 91, 57, 0.6);
border-radius: 8px;
background:
linear-gradient(rgba(83, 63, 32, 0.045) 1px, transparent 1px),
var(--panel);
background-size: 100% 30px, auto;
color: var(--ink);
padding: 18px 20px;
min-height: 49vh;
overflow: auto;
overflow-wrap: anywhere;
text-transform: none;
letter-spacing: 0;
box-shadow: var(--paper-shadow);
}
.preview-panel h1, .preview-panel h2, .preview-panel h3 { color: #3a2416; margin: 0.8em 0 0.35em; font-family: Georgia, "Times New Roman", serif; letter-spacing: 0; text-transform: none; }
.preview-panel h1 { font-size: 27px; }
.preview-panel h2 { font-size: 22px; }
.preview-panel h3 { font-size: 18px; }
.preview-panel p, .preview-panel ul, .preview-panel ol, .preview-panel blockquote { margin: 0 0 0.8em; }
.preview-panel blockquote { border-left: 3px solid #9f7d3d; padding-left: 11px; color: #604f3a; background: rgba(184, 145, 69, 0.08); }
.preview-panel img, .preview-panel video { max-width: 100%; height: auto; border-radius: 6px; border: 1px solid rgba(107, 91, 57, 0.24); }
.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.hint { color: #b3a181; font-size: 13px; overflow-wrap: anywhere; }
.dirty-hint { color: #e0b06f; }
.full { grid-column: 1 / -1; }
.tags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 5px; }
.tag { background: rgba(68, 106, 87, 0.25); color: #d5eadb; border: 1px solid rgba(104, 151, 121, 0.36); padding: 2px 7px; border-radius: 999px; font-size: 12px; }
.queue-list { display: grid; gap: 8px; margin-bottom: 12px; }
.queue-item { border: 1px solid rgba(217, 190, 124, 0.26); border-radius: 8px; background: rgba(243, 234, 215, 0.08); color: #e7dac0; padding: 11px 12px; font-size: 13px; box-shadow: 0 10px 24px rgba(0, 0, 0, 0.16); }
.queue-item strong { display: block; overflow-wrap: anywhere; color: #f3e6c9; }
.queue-item span { color: #b9a789; overflow-wrap: anywhere; }
pre { white-space: pre-wrap; overflow: auto; max-height: 280px; background: #0d0c0a; color: #ecdcbf; border: 1px solid rgba(217, 190, 124, 0.24); padding: 15px; border-radius: 8px; font-size: 12px; box-shadow: var(--shadow); }
@media (max-width: 980px) { .content-layout.previewing { 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; } .link-picker { 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>
<a class="quick-link" href="https://author.zainezq.com">author.zainezq.com</a>
<a class="quick-link" href="/hidden">Hidden narrative desk</a>
<div id="status" class="status"></div>
<div class="filters">
<input id="search" type="search" placeholder="Filter pages" />
<select id="typeFilter">
<option value="">All files</option>
<option value="blog">Blogs</option>
<option value="post">Posts</option>
<option value="page">Pages</option>
<option value="lima">Lima</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>
<option value="page">Page</option>
<option value="lima">Lima</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>
<label class="full">Org file path
<input name="targetPath" placeholder="blogs/2026/05-may/my-page.org" />
</label>
</div>
<div id="mdToolbar" class="toolbar">
<button class="icon" type="button" data-md="bold" title="Bold">B</button>
<button class="icon" type="button" data-md="italic" title="Italic"><i>I</i></button>
<button class="icon" type="button" data-md="underline" title="Underline"><u>U</u></button>
<button type="button" data-md="h1">H1</button>
<button type="button" data-md="h2">H2</button>
<button type="button" data-md="h3">H3</button>
<button type="button" data-md="bullet">List</button>
<button type="button" data-md="numbered">1. List</button>
<button type="button" data-md="quote">Quote</button>
<button type="button" data-md="link">Link</button>
<button type="button" id="pageLinkBtn">Link to page</button>
<button type="button" id="attachBtn">Insert image</button>
<input id="attachInput" type="file" accept="image/*" />
</div>
<div id="pageLinkPicker" class="link-picker" hidden>
<select id="pageLinkSelect"></select>
<button type="button" id="insertPageLinkBtn">Insert link</button>
<button type="button" id="cancelPageLinkBtn">Cancel</button>
</div>
<div id="contentLayout" class="content-layout">
<label>Content
<textarea name="content" spellcheck="true"></textarea>
</label>
<label id="previewWrap" hidden>Preview
<div id="markdownPreview" class="preview-panel"></div>
</label>
</div>
<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="discardBtn" type="button">Discard changes</button>
<span id="saveMessage" class="hint"></span>
</div>
</form>
<h2>Build queue</h2>
<div id="queue" class="queue-list"></div>
<h2>Latest build log</h2>
<pre id="buildLog"></pre>
</main>
</div>
<script>
const draftStorageKey = "orgAuthoringDrafts:v1";
const state = { pages: [], currentPath: "", build: null, pathManual: false, treeOpen: {}, drafts: {}, loadingForm: false };
const $ = (selector) => document.querySelector(selector);
const editor = $("#editor");
const mdToolbar = $("#mdToolbar");
const attachInput = $("#attachInput");
const attachBtn = $("#attachBtn");
const pageLinkPicker = $("#pageLinkPicker");
const pageLinkSelect = $("#pageLinkSelect");
const contentLayout = $("#contentLayout");
const previewWrap = $("#previewWrap");
const markdownPreview = $("#markdownPreview");
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())}>`;
}
function loadDrafts() {
try {
return JSON.parse(localStorage.getItem(draftStorageKey) || "{}");
} catch (_err) {
return {};
}
}
function saveDrafts() {
try {
localStorage.setItem(draftStorageKey, JSON.stringify(state.drafts));
} catch (_err) {
saveMessage.textContent = "Browser storage is full; draft could not be saved.";
}
}
function draftKey(path = undefined) {
if (path !== undefined) return path || "__new__";
return state.currentPath || editor.targetPath.value || "__new__";
}
function hasDraft(path = undefined) {
return Object.prototype.hasOwnProperty.call(state.drafts, draftKey(path));
}
function editorSnapshot() {
return {
pageType: editor.pageType.value,
section: editor.section.value,
targetPath: editor.targetPath.value,
title: editor.title.value,
slug: editor.slug.value,
tags: editor.tags.value,
date: editor.date.value,
content: editor.content.value,
comments: editor.comments.checked,
pathManual: state.pathManual,
};
}
function applyDraft(page, draft) {
return {
...page,
pageType: draft.pageType,
section: draft.section,
path: page.path || draft.targetPath || "",
title: draft.title,
slug: draft.slug,
tags: typeof draft.tags === "string" ? draft.tags.split(",").map((tag) => tag.trim()).filter(Boolean) : page.tags,
date: draft.date,
content: draft.content,
comments: draft.comments,
targetPath: draft.targetPath,
pathManual: draft.pathManual,
};
}
function rememberDraft() {
if (state.loadingForm) return;
const key = draftKey();
state.drafts[key] = { ...editorSnapshot(), updatedAt: Date.now() };
saveDrafts();
renderDraftState("Unsaved changes.");
if (state.pages.length) renderPages();
}
function clearDraft(path = undefined) {
const key = draftKey(path);
if (!hasDraft(key)) return;
delete state.drafts[key];
saveDrafts();
renderDraftState("");
if (state.pages.length) renderPages();
}
function renderDraftState(message = null) {
const dirty = hasDraft();
saveMessage.classList.toggle("dirty-hint", dirty);
if (message !== null) saveMessage.textContent = message;
if (!message && dirty) saveMessage.textContent = "Unsaved changes.";
}
async function api(path, options = {}) {
const res = await fetch(path, { headers: { "Content-Type": "application/json" }, ...options });
let data = null;
try {
data = await res.clone().json();
} catch (_err) {
data = { error: await res.text().catch(() => "") };
}
if (!res.ok) throw new Error(data.error || "Request failed");
return data;
}
function renderStatus() {
const build = state.build || {};
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 || "";
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() {
const query = $("#search").value.toLowerCase();
const type = $("#typeFilter").value;
pagesBox.innerHTML = "";
const matches = state.pages
.filter((page) => (!type || page.pageType === type))
.filter((page) => `${page.title} ${page.path} ${page.tags.join(" ")}`.toLowerCase().includes(query))
.sort((a, b) => a.path.localeCompare(b.path));
if (!matches.length) {
pagesBox.innerHTML = `<div class="queue-item"><span>No files matched.</span></div>`;
renderStatus();
return;
}
const tree = buildPageTree(matches);
renderTree(tree, pagesBox, query !== "");
renderStatus();
}
function buildPageTree(pages) {
const root = { dirs: new Map(), pages: [], path: "" };
pages.forEach((page) => {
const parts = page.path.split("/");
const filename = parts.pop();
let node = root;
let dirPath = "";
parts.forEach((part) => {
dirPath = dirPath ? `${dirPath}/${part}` : part;
if (!node.dirs.has(part)) node.dirs.set(part, { dirs: new Map(), pages: [], path: dirPath });
node = node.dirs.get(part);
});
node.pages.push({ ...page, filename });
});
return root;
}
function renderTree(node, container, expandAll = false) {
[...node.dirs.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.forEach(([name, child]) => container.appendChild(renderDirectory(name, child, expandAll)));
node.pages
.sort((a, b) => a.filename.localeCompare(b.filename))
.forEach((page) => container.appendChild(renderPageButton(page)));
}
function renderDirectory(name, node, expandAll) {
const details = document.createElement("details");
details.className = "tree-dir";
const savedOpen = Object.prototype.hasOwnProperty.call(state.treeOpen, node.path) ? state.treeOpen[node.path] : null;
details.open = expandAll || (savedOpen === null ? node.path.split("/").length <= 2 || containsCurrentPath(node) : savedOpen);
details.addEventListener("toggle", () => {
if (!expandAll) state.treeOpen[node.path] = details.open;
});
const summary = document.createElement("summary");
const label = document.createElement("span");
label.className = "tree-label";
label.innerHTML = `<strong>${html(name)}</strong><span>${countPages(node)} files</span>`;
const add = document.createElement("button");
add.type = "button";
add.className = "tree-add";
add.textContent = "New";
add.title = `New file in ${node.path}`;
add.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
newPageInDirectory(node.path);
};
summary.append(label, add);
const children = document.createElement("div");
children.className = "tree-children";
renderTree(node, children, expandAll);
details.append(summary, children);
return details;
}
function renderPageButton(page) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = `page-item${page.path === state.currentPath ? " active" : ""}${hasDraft(page.path) ? " unsaved" : ""}`;
const draftLabel = hasDraft(page.path) ? `<span class="dirty-hint">Unsaved changes</span>` : "";
btn.innerHTML = `<strong>${html(page.title)}</strong><span class="filename">${html(page.filename || page.path)}</span><span>${html(page.path)}</span>${draftLabel}${page.tags.length ? `<span class="tags">${page.tags.map((tag) => `<span class="tag">${html(tag)}</span>`).join("")}</span>` : ""}`;
btn.onclick = () => loadPage(page.path);
return btn;
}
function countPages(node) {
let count = node.pages.length;
node.dirs.forEach((child) => { count += countPages(child); });
return count;
}
function containsCurrentPath(node) {
return Boolean(state.currentPath && (state.currentPath.startsWith(`${node.path}/`) || node.pages.some((page) => page.path === state.currentPath)));
}
function newPageInDirectory(dirPath) {
const pageType = pageTypeForDirectory(dirPath);
setForm({ pageType, date: currentOrgDate(), comments: true });
editor.section.value = pageType === "post" ? postSectionForDirectory(dirPath) : "";
editor.targetPath.value = `${dirPath}/`;
state.pathManual = true;
updateEditorMode();
saveMessage.textContent = `New file will be created in ${dirPath}.`;
editor.title.focus();
}
function pageTypeForDirectory(dirPath) {
if (dirPath === "lima" || dirPath.startsWith("lima/")) return "lima";
if (dirPath === "posts" || dirPath.startsWith("posts/")) return "post";
if (dirPath === "blogs" || dirPath.startsWith("blogs/")) return "blog";
return "page";
}
function postSectionForDirectory(dirPath) {
if (!dirPath.startsWith("posts/")) return "";
return dirPath.slice("posts/".length);
}
function html(value) {
return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
}
function previewImageUrl(src) {
const value = String(src || "");
if (value.startsWith("https://author.zainezq.com/assets/")) {
return value.replace("https://author.zainezq.com/assets/", "https://zainezq.com/assets/");
}
const relativeAsset = value.match(/^(?:\.\.\/)+assets\/(.+)$/);
if (relativeAsset) return `https://zainezq.com/assets/${relativeAsset[1]}`;
if (value.startsWith("/assets/")) return `https://zainezq.com${value}`;
if (value.startsWith("assets/")) return `https://zainezq.com/${value}`;
return value;
}
function renderInlineMarkdown(value) {
let out = html(value);
out = out.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, src) => `<img src="${previewImageUrl(src)}" alt="${alt}">`);
out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
out = out.replace(/\*([^*]+)\*/g, '<em>$1</em>');
out = out.replace(/&lt;u&gt;(.+?)&lt;\/u&gt;/g, '<u>$1</u>');
return out;
}
function markdownToHtml(markdown) {
const lines = String(markdown || "").replace(/\r\n/g, "\n").split("\n");
const blocks = [];
let paragraph = [];
let list = null;
function flushParagraph() {
if (!paragraph.length) return;
blocks.push(`<p>${renderInlineMarkdown(paragraph.join(" "))}</p>`);
paragraph = [];
}
function closeList() {
if (!list) return;
blocks.push(`<${list.type}>${list.items.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join("")}</${list.type}>`);
list = null;
}
lines.forEach((line) => {
const trimmed = line.trim();
if (!trimmed) {
flushParagraph();
closeList();
return;
}
const heading = trimmed.match(/^(#{1,3})\s+(.+)$/);
if (heading) {
flushParagraph();
closeList();
blocks.push(`<h${heading[1].length}>${renderInlineMarkdown(heading[2])}</h${heading[1].length}>`);
return;
}
const bullet = trimmed.match(/^[-*]\s+(.+)$/);
const numbered = trimmed.match(/^\d+\.\s+(.+)$/);
if (bullet || numbered) {
flushParagraph();
const type = bullet ? "ul" : "ol";
if (!list || list.type !== type) {
closeList();
list = { type, items: [] };
}
list.items.push((bullet || numbered)[1]);
return;
}
const quote = trimmed.match(/^>\s?(.+)$/);
if (quote) {
flushParagraph();
closeList();
blocks.push(`<blockquote>${renderInlineMarkdown(quote[1])}</blockquote>`);
return;
}
closeList();
paragraph.push(trimmed);
});
flushParagraph();
closeList();
return blocks.join("\n");
}
function updateMarkdownPreview() {
markdownPreview.innerHTML = markdownToHtml(editor.content.value);
}
function setForm(page, options = {}) {
const draft = options.useDraft === false ? null : state.drafts[draftKey(page.path || page.targetPath || "")];
const restoredDraft = Boolean(draft);
if (draft) page = applyDraft(page, draft);
state.loadingForm = true;
state.currentPath = page.path || "";
state.pathManual = Object.prototype.hasOwnProperty.call(page, "pathManual") ? Boolean(page.pathManual) : Boolean(page.path);
editor.pageType.value = page.pageType || "blog";
editor.section.value = page.section || "";
editor.targetPath.value = page.targetPath || page.path || "";
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;
editor.targetPath.disabled = Boolean(state.currentPath);
updateEditorMode();
updateSuggestedPath();
updateMarkdownPreview();
state.loadingForm = false;
if (state.pages.length) renderPages();
renderDraftState(restoredDraft ? "Unsaved changes restored." : "");
}
async function loadPage(path, options = {}) {
const page = await api(`/api/page?path=${encodeURIComponent(path)}`);
setForm(page, options);
}
async function discardChanges() {
const path = state.currentPath;
const key = draftKey();
if (!state.drafts[key]) {
saveMessage.textContent = "No unsaved changes to discard.";
return;
}
if (!confirm("Discard unsaved changes for this file?")) return;
delete state.drafts[key];
saveDrafts();
if (path) {
await loadPage(path, { useDraft: false });
} else {
setForm({ pageType: "blog", date: currentOrgDate(), comments: true }, { useDraft: false });
}
saveMessage.textContent = "Unsaved changes discarded.";
renderPages();
}
async function refreshPages() {
try {
state.pages = await api("/api/pages");
populatePageLinkSelect();
renderPages();
} catch (err) {
if (state.pages.length) {
renderPages();
saveMessage.textContent = `Could not refresh file list: ${err.message}`;
} else {
pagesBox.innerHTML = `<div class="queue-item"><span>${html(err.message)}</span></div>`;
}
}
}
async function refreshBuild() {
const wasRunning = Boolean(state.build && state.build.running);
try {
state.build = await api("/api/build");
renderStatus();
if (wasRunning && !state.build.running) await refreshPages();
} catch (err) {
statusBox.className = "status fail";
statusBox.textContent = `Could not refresh build status: ${err.message}`;
}
}
editor.title.addEventListener("input", () => {
if (!state.currentPath) {
editor.slug.value = slugify(editor.title.value);
updateSuggestedPath();
}
});
editor.slug.addEventListener("input", updateSuggestedPath);
editor.pageType.addEventListener("change", () => {
if (!state.currentPath) {
state.pathManual = false;
editor.targetPath.value = "";
}
updateEditorMode();
updateSuggestedPath();
});
editor.section.addEventListener("input", updateSuggestedPath);
editor.date.addEventListener("input", updateSuggestedPath);
editor.targetPath.addEventListener("input", () => { state.pathManual = true; });
mdToolbar.querySelectorAll("[data-md]").forEach((button) => {
button.addEventListener("click", () => applyMarkdown(button.dataset.md));
});
attachBtn.addEventListener("click", () => attachInput.click());
$("#pageLinkBtn").addEventListener("click", showPageLinkPicker);
$("#insertPageLinkBtn").addEventListener("click", insertSelectedPageLink);
$("#cancelPageLinkBtn").addEventListener("click", () => { pageLinkPicker.hidden = true; });
attachInput.addEventListener("change", uploadAttachment);
editor.content.addEventListener("input", updateMarkdownPreview);
editor.addEventListener("input", rememberDraft);
editor.addEventListener("change", rememberDraft);
$("#search").addEventListener("input", renderPages);
$("#typeFilter").addEventListener("change", renderPages);
$("#newBtn").addEventListener("click", () => setForm({ pageType: "blog", date: currentOrgDate(), comments: true }));
$("#discardBtn").addEventListener("click", discardChanges);
editor.addEventListener("submit", async (event) => {
event.preventDefault();
saveMessage.textContent = "Saving and queueing build.";
const payload = Object.fromEntries(new FormData(editor).entries());
payload.path = state.currentPath;
payload.comments = editor.comments.checked;
const previousDraftKey = draftKey();
try {
const saved = await api("/api/page", { method: "POST", body: JSON.stringify(payload) });
delete state.drafts[previousDraftKey];
delete state.drafts[draftKey(saved.path)];
saveDrafts();
setForm(saved, { useDraft: false });
saveMessage.textContent = "Saved. Build queued.";
await refreshPages();
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 === "lima") {
editor.targetPath.value = `lima/${slug}.md`;
return;
}
if (editor.pageType.value === "page") {
editor.targetPath.value = `${slug}.org`;
return;
}
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`;
}
function updateEditorMode() {
const isLima = editor.pageType.value === "lima";
const isOrg = !isLima;
mdToolbar.querySelectorAll("[data-md='link']").forEach((button) => {
button.textContent = isLima ? "Link" : "Org link";
});
editor.tags.closest("label").hidden = isLima;
editor.date.closest("label").hidden = isLima;
editor.comments.closest("label").hidden = isLima;
editor.section.closest("label").hidden = editor.pageType.value !== "post";
attachBtn.hidden = false;
previewWrap.hidden = !isLima;
contentLayout.classList.toggle("previewing", isLima);
pageLinkPicker.hidden = true;
const pathLabel = editor.targetPath.closest("label").firstChild;
if (pathLabel) pathLabel.textContent = isLima ? "Markdown file path" : "Org file path";
editor.targetPath.placeholder = isLima ? "lima/family-update.md" : "blogs/2026/05-may/my-page.org";
updateMarkdownPreview();
}
function selectedText() {
const area = editor.content;
return {
start: area.selectionStart,
end: area.selectionEnd,
text: area.value.slice(area.selectionStart, area.selectionEnd),
};
}
function replaceSelection(value, selectStart = null, selectEnd = null) {
const area = editor.content;
const { start, end } = selectedText();
area.setRangeText(value, start, end, "end");
area.focus();
if (selectStart !== null && selectEnd !== null) {
area.setSelectionRange(start + selectStart, start + selectEnd);
}
updateMarkdownPreview();
rememberDraft();
}
function toggleLinePrefix(prefix, fallback, ordered = false) {
const area = editor.content;
let { start, end, text } = selectedText();
if (start === end) {
start = area.value.lastIndexOf("\n", start - 1) + 1;
const lineEnd = area.value.indexOf("\n", end);
end = lineEnd === -1 ? area.value.length : lineEnd;
text = area.value.slice(start, end);
}
const value = text || fallback;
const lines = value.split("\n");
const hasPrefix = lines.every((line) => ordered ? /^\d+\.\s/.test(line) : line.startsWith(prefix));
const replacement = lines.map((line, index) => {
if (hasPrefix) return ordered ? line.replace(/^\d+\.\s/, "") : line.slice(prefix.length);
return ordered ? `${index + 1}. ${line || fallback}` : `${prefix}${line || fallback}`;
}).join("\n");
area.setRangeText(replacement, start, end, "end");
area.setSelectionRange(start, start + replacement.length);
area.focus();
updateMarkdownPreview();
rememberDraft();
}
function toggleWrap(prefix, suffix, fallback) {
const area = editor.content;
const { start, end, text } = selectedText();
const before = area.value.slice(start - prefix.length, start);
const after = area.value.slice(end, end + suffix.length);
if (text && text.startsWith(prefix) && text.endsWith(suffix)) {
const inner = text.slice(prefix.length, text.length - suffix.length);
area.setRangeText(inner, start, end, "select");
area.setSelectionRange(start, start + inner.length);
} else if (text && before === prefix && after === suffix) {
area.setSelectionRange(start - prefix.length, end + suffix.length);
area.setRangeText(text, start - prefix.length, end + suffix.length, "select");
area.setSelectionRange(start - prefix.length, start - prefix.length + text.length);
} else {
const value = text || fallback;
area.setRangeText(`${prefix}${value}${suffix}`, start, end, "select");
area.setSelectionRange(start + prefix.length, start + prefix.length + value.length);
}
area.focus();
updateMarkdownPreview();
rememberDraft();
}
function applyMarkdown(action) {
const { text } = selectedText();
const sample = text || "text";
const isLima = editor.pageType.value === "lima";
if (action === "bold") toggleWrap(isLima ? "**" : "*", isLima ? "**" : "*", sample);
if (action === "italic") toggleWrap(isLima ? "*" : "/", isLima ? "*" : "/", sample);
if (action === "underline") toggleWrap(isLima ? "<u>" : "_", isLima ? "</u>" : "_", sample);
if (action === "h1") toggleLinePrefix(isLima ? "# " : "* ", "Heading");
if (action === "h2") toggleLinePrefix(isLima ? "## " : "** ", "Heading");
if (action === "h3") toggleLinePrefix(isLima ? "### " : "*** ", "Heading");
if (action === "bullet") toggleLinePrefix(isLima ? "- " : "- ", "List item");
if (action === "numbered") toggleLinePrefix("", "List item", true);
if (action === "quote") {
if (isLima) {
toggleLinePrefix("> ", "Quote");
} else {
const body = text || "Quote";
if (body.startsWith("#+begin_quote") && body.trimEnd().endsWith("#+end_quote")) {
replaceSelection(body.replace(/^#\+begin_quote\s*\n?/i, "").replace(/\n?#\+end_quote\s*$/i, ""));
} else {
replaceSelection(`#+begin_quote\n${body}\n#+end_quote`, 14, 14 + body.length);
}
}
}
if (action === "link") {
const label = sample === "text" ? "link text" : sample;
if (isLima) {
replaceSelection(`[${label}](https://)`, 1, 1 + label.length);
} else {
replaceSelection(`[[https://][${label}]]`, 11, 11);
}
}
}
function pageUrl(page) {
const path = page.path.replace(/\.(org|md)$/i, ".html");
return `https://zainezq.com/${path}`;
}
function currentEditorPath() {
return state.currentPath || editor.targetPath.value || "";
}
function dirname(path) {
const index = path.lastIndexOf("/");
return index === -1 ? "" : path.slice(0, index);
}
function relativePath(fromDir, toPath) {
const fromParts = fromDir ? fromDir.split("/").filter(Boolean) : [];
const toParts = toPath.split("/").filter(Boolean);
while (fromParts.length && toParts.length && fromParts[0] === toParts[0]) {
fromParts.shift();
toParts.shift();
}
return [...fromParts.map(() => ".."), ...toParts].join("/") || toPath;
}
function orgFileLink(page) {
const current = currentEditorPath();
const target = current ? relativePath(dirname(current), page.path) : page.path;
return `[[file:${target}][${page.title}]]`;
}
function markdownPageLink(page, label) {
return `[${label || page.title}](${pageUrl(page)})`;
}
function populatePageLinkSelect() {
const selected = pageLinkSelect.value;
pageLinkSelect.innerHTML = "";
state.pages.forEach((page) => {
const option = document.createElement("option");
option.value = page.path;
option.textContent = `${page.title} - ${page.path}`;
pageLinkSelect.appendChild(option);
});
if (selected && state.pages.some((page) => page.path === selected)) {
pageLinkSelect.value = selected;
}
}
function showPageLinkPicker() {
populatePageLinkSelect();
pageLinkPicker.hidden = false;
pageLinkSelect.focus();
}
function insertSelectedPageLink() {
const page = state.pages.find((item) => item.path === pageLinkSelect.value);
if (!page) return;
const { text } = selectedText();
const isLima = editor.pageType.value === "lima";
replaceSelection(isLima ? markdownPageLink(page, text || page.title) : orgFileLink(page));
pageLinkPicker.hidden = true;
}
async function uploadAttachment() {
const file = attachInput.files[0];
if (!file) return;
saveMessage.textContent = "Uploading image.";
const body = new FormData();
body.append("attachment", file);
body.append("pagePath", currentEditorPath() || "index.org");
try {
const res = await fetch("/api/upload", { method: "POST", body });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Upload failed");
replaceSelection(`\n${data.insertText || data.markdown}\n`);
saveMessage.textContent = "Image inserted.";
} catch (err) {
saveMessage.textContent = err.message;
} finally {
attachInput.value = "";
}
}
state.drafts = loadDrafts();
setForm({ pageType: "blog", date: currentOrgDate(), comments: true });
refreshPages();
refreshBuild();
setInterval(refreshBuild, 2500);
</script>
</body>
</html>
"""
HIDDEN_APP_HTML = r"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Hidden Narrative Desk</title>
<style>
:root {
color-scheme: light;
--bg: #efe7d4;
--ink: #30261d;
--muted: #7a6b58;
--paper: #fff8e8;
--paper-2: #f6edd9;
--line: rgba(89, 68, 42, 0.2);
--line-strong: rgba(89, 68, 42, 0.42);
--rose: #9a4c5b;
--moss: #4d715a;
--gold: #ae812f;
--blue: #436f87;
--shadow: 0 18px 38px rgba(57, 39, 19, 0.14);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--ink);
background:
linear-gradient(rgba(77, 113, 90, 0.045) 1px, transparent 1px),
linear-gradient(90deg, rgba(154, 76, 91, 0.04) 1px, transparent 1px),
radial-gradient(circle at 8% 8%, rgba(154, 76, 91, 0.14), transparent 28%),
radial-gradient(circle at 92% 10%, rgba(77, 113, 90, 0.13), transparent 26%),
var(--bg);
background-size: 28px 28px, 28px 28px, auto, auto, auto;
}
button, input, textarea, select { font: inherit; }
button {
min-height: 38px;
border: 1px solid var(--line);
border-radius: 7px;
background: #fff7e8;
color: var(--ink);
padding: 0 12px;
cursor: pointer;
box-shadow: 0 5px 14px rgba(57, 39, 19, 0.08);
}
button:hover { border-color: var(--line-strong); transform: translateY(-1px); }
button.primary { background: #2f513d; color: #fff8e8; border-color: #2f513d; font-weight: 800; }
button.danger { color: #8b2d3d; border-color: rgba(139, 45, 61, 0.28); }
input, textarea, select {
width: 100%;
border: 1px solid var(--line);
border-radius: 7px;
background: #fffdf4;
color: var(--ink);
padding: 10px 11px;
}
textarea { min-height: 150px; resize: vertical; line-height: 1.55; }
label { display: grid; gap: 6px; font-size: 12px; color: #735f43; font-weight: 780; text-transform: uppercase; letter-spacing: 0.04em; }
.shell { display: grid; grid-template-columns: 360px minmax(0, 1fr) 360px; min-height: 100vh; }
aside, main { padding: 20px; }
aside { background: rgba(255, 248, 232, 0.78); border-right: 1px solid var(--line); overflow: auto; max-height: 100vh; }
.right { border-left: 1px solid var(--line); border-right: 0; }
main { overflow: auto; max-height: 100vh; }
h1, h2, h3 { font-family: Georgia, "Times New Roman", serif; letter-spacing: 0; }
h1 { margin: 0; font-size: 30px; }
h2 { margin: 0 0 12px; font-size: 19px; }
h3 { margin: 0; font-size: 17px; }
.subtle { color: var(--muted); font-size: 13px; line-height: 1.45; }
.top { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 16px; }
.status { border: 1px solid rgba(77, 113, 90, 0.24); border-left: 4px solid var(--moss); background: rgba(255, 248, 232, 0.82); padding: 11px 12px; border-radius: 7px; margin: 12px 0; font-size: 13px; }
.filters, .stack { display: grid; gap: 10px; }
.pills { display: flex; flex-wrap: wrap; gap: 6px; }
.pill { border: 1px solid var(--line); border-radius: 999px; padding: 3px 8px; background: #fff7e8; color: #654f37; font-size: 12px; }
.entry-list { display: grid; gap: 8px; margin-top: 14px; }
.entry-card {
text-align: left;
min-height: auto;
display: grid;
gap: 6px;
background: rgba(255, 253, 244, 0.92);
border-color: rgba(89, 68, 42, 0.16);
padding: 12px;
}
.entry-card.active { outline: 2px solid rgba(77, 113, 90, 0.38); background: #fffdf4; }
.entry-card[draggable="true"] { cursor: grab; }
.entry-card strong { font-size: 14px; }
.entry-meta { display: flex; flex-wrap: wrap; gap: 5px; align-items: center; }
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; background: var(--moss); }
.dot.rare { background: var(--rose); }
.dot.timed, .dot.seasonal { background: var(--blue); }
.editor-card, .panel {
background: rgba(255, 248, 232, 0.92);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
padding: 16px;
}
.editor-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.full { grid-column: 1 / -1; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.template-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.preview {
min-height: 190px;
white-space: pre-wrap;
line-height: 1.65;
background:
linear-gradient(rgba(89, 68, 42, 0.055) 1px, transparent 1px),
#fffdf4;
background-size: 100% 30px, auto;
border: 1px solid var(--line);
border-radius: 8px;
padding: 16px;
font-family: Georgia, "Times New Roman", serif;
}
.preview blockquote { margin: 0; padding-left: 12px; border-left: 3px solid var(--gold); color: #5b4a37; }
details { border: 1px solid var(--line); border-radius: 8px; background: rgba(255, 253, 244, 0.68); padding: 10px 12px; }
summary { cursor: pointer; font-weight: 800; color: #5b4730; }
.timeline { display: grid; gap: 8px; }
.time-row { border-left: 3px solid rgba(77, 113, 90, 0.34); padding-left: 10px; font-size: 13px; }
.graph { min-height: 260px; position: relative; overflow: hidden; }
.graph svg { width: 100%; height: 260px; display: block; }
.node { position: absolute; transform: translate(-50%, -50%); background: #fffdf4; border: 1px solid var(--line-strong); border-radius: 999px; padding: 5px 8px; font-size: 12px; max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.architecture { font-size: 13px; line-height: 1.5; }
.architecture code { background: rgba(77, 113, 90, 0.1); padding: 1px 4px; border-radius: 4px; }
.disabled { opacity: 0.48; }
@media (max-width: 1180px) { .shell { grid-template-columns: 320px 1fr; } .right { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--line); max-height: none; } }
@media (max-width: 780px) { .shell { grid-template-columns: 1fr; } aside, main { max-height: none; } aside { border-right: 0; border-bottom: 1px solid var(--line); } .editor-grid, .template-row { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="shell">
<aside>
<div class="top">
<div>
<h1>Hidden Desk</h1>
<div class="subtle">A small room for the secret layer.</div>
</div>
<a href="/" class="subtle">Pages</a>
</div>
<div id="status" class="status">Aphy is opening the drawer.</div>
<div class="filters">
<input id="search" type="search" placeholder="Search memories, triggers, notes" />
<select id="typeFilter"></select>
<select id="characterFilter"></select>
<select id="toneFilter"></select>
<select id="rarityFilter"></select>
</div>
<h2>Quick Add</h2>
<div class="template-row">
<button data-template="Lima note/message">Lima note</button>
<button data-template="Aphy system message">Aphy line</button>
<button data-template="poem">Poem</button>
<button data-template="hidden conversation">Dialogue</button>
<button data-template="rare event">Rare event</button>
<button data-template="keyboard secret">Secret trigger</button>
</div>
<div id="entryList" class="entry-list"></div>
</aside>
<main>
<div class="top">
<div>
<h1 id="deskTitle">New hidden entry</h1>
<div id="deskSub" class="subtle">Forms, not raw JavaScript.</div>
</div>
<div class="actions">
<button id="undoBtn" type="button">Undo</button>
<button id="redoBtn" type="button">Redo</button>
<button id="saveBtn" class="primary" type="button">Save hidden layer</button>
</div>
</div>
<section class="editor-card">
<div class="editor-grid">
<label>Title / label<input id="title" /></label>
<label>Type<select id="type"></select></label>
<label>Characters<input id="characters" placeholder="Lima, Aphy" /></label>
<label>Emotional tone<select id="tone"></select></label>
<label>Rarity<select id="rarity"></select></label>
<label>Category<input id="category" placeholder="dreams, search, footer" /></label>
<label>Family Layer Index<input id="familyLayer" placeholder="0-5, or arc name" /></label>
<label>Page / location<input id="pageLocation" placeholder="/play/lima-note.html" /></label>
<label class="full">Trigger conditions<input id="triggerConditions" placeholder="search: lima, hour >= 22, hover, click 7 times" /></label>
<label class="full">Content / body<textarea id="content" spellcheck="true"></textarea></label>
<label>Tags<input id="tags" placeholder="warm, layer-2, october" /></label>
<label>Enabled<select id="enabled"><option value="true">Enabled</option><option value="false">Resting for now</option></select></label>
<label>Audio settings<input id="audioSettings" placeholder="optional" /></label>
<label>Animation trigger<input id="animationTrigger" placeholder="optional" /></label>
<label>CSS class hooks<input id="cssClassHooks" placeholder="hidden-late-night" /></label>
<label>Chain references<input id="chainReferences" placeholder="entry-id, another-id" /></label>
<label>Continuation links<input id="continuationLinks" placeholder="entry-id, /play/page.html" /></label>
<label class="full">Notes / internal comments<textarea id="notes"></textarea></label>
</div>
<div class="actions" style="margin-top:12px">
<button id="newBtn" type="button">New</button>
<button id="duplicateBtn" type="button">Duplicate</button>
<button id="deleteBtn" class="danger" type="button">Delete</button>
<span id="autosave" class="subtle"></span>
</div>
</section>
<section class="panel" style="margin-top:14px">
<h2>Live Preview</h2>
<div id="preview" class="preview"></div>
</section>
</main>
<aside class="right">
<details open>
<summary>Story connections</summary>
<div id="graph" class="graph"></div>
</details>
<details open style="margin-top:10px">
<summary>Timeline / history</summary>
<div id="timeline" class="timeline"></div>
</details>
<details style="margin-top:10px">
<summary>Architecture proposal</summary>
<div class="architecture">
<p><strong>Source of truth:</strong> edit <code>assets/content/hidden-details.json</code>, then regenerate the editable constants in <code>assets/scripts/hidden-details.js</code>.</p>
<p><strong>Schema:</strong> every entry has id, title, content, characters, tone, rarity, triggers, tags, category, page, layer, enabled state, dates, notes, optional audio, animation, CSS hooks, chains, and continuations.</p>
<p><strong>State:</strong> the browser keeps one selected entry, local draft autosave, and undo/redo snapshots. The server validates and writes atomically with backups.</p>
<p><strong>Search:</strong> simple local indexing across body, trigger, tags, characters, category, and notes. This can later become Lunr if the collection grows large.</p>
<p><strong>Expansion:</strong> add arc folders, approval states, richer graph editing, import/export, conflict resolution, and per-character writing prompts.</p>
</div>
</details>
<details style="margin-top:10px">
<summary>Example screen map</summary>
<div class="architecture">
<p><strong>Left:</strong> soft filters, quick templates, draggable memory cards.</p>
<p><strong>Center:</strong> the writing form and preview desk.</p>
<p><strong>Right:</strong> relationships, history, storage notes, and future architecture.</p>
</div>
</details>
</aside>
</div>
<script>
const draftKey = "hiddenNarrativeDeskDraft:v1";
const state = { entries: [], filtered: [], selectedId: "", meta: {}, undo: [], redo: [], draggingId: "" };
const fields = ["title","type","characters","tone","rarity","category","familyLayer","pageLocation","triggerConditions","content","tags","enabled","audioSettings","animationTrigger","cssClassHooks","chainReferences","continuationLinks","notes"];
const $ = (id) => document.getElementById(id);
function html(value) {
return String(value || "").replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
}
function slugify(value) {
return String(value || "untitled").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "untitled";
}
async function api(path, options = {}) {
const res = await fetch(path, { headers: { "Content-Type": "application/json" }, ...options });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Request failed");
return data;
}
function setStatus(message) {
$("status").textContent = message;
$("autosave").textContent = message;
}
function snapshot() {
state.undo.push(JSON.stringify(state.entries));
state.undo = state.undo.slice(-60);
state.redo = [];
}
function restore(serialized) {
state.entries = JSON.parse(serialized);
rememberDraft();
renderAll();
selectEntry(state.selectedId || state.entries[0]?.id);
}
function rememberDraft() {
localStorage.setItem(draftKey, JSON.stringify({ entries: state.entries, selectedId: state.selectedId, savedAt: Date.now() }));
$("autosave").textContent = "Aphy tucked a local draft under the keyboard.";
}
function entryFromForm() {
const current = currentEntry() || {};
return {
...current,
id: current.id || slugify($("title").value),
title: $("title").value.trim(),
type: $("type").value,
content: $("content").value,
characters: splitList($("characters").value),
emotionalTone: $("tone").value,
rarity: $("rarity").value,
triggerConditions: $("triggerConditions").value,
tags: splitList($("tags").value),
category: $("category").value,
pageLocation: $("pageLocation").value,
familyLayer: $("familyLayer").value,
enabled: $("enabled").value === "true",
notes: $("notes").value,
audioSettings: $("audioSettings").value,
animationTrigger: $("animationTrigger").value,
cssClassHooks: $("cssClassHooks").value,
chainReferences: splitList($("chainReferences").value),
continuationLinks: splitList($("continuationLinks").value),
};
}
function splitList(value) {
return String(value || "").split(",").map((item) => item.trim()).filter(Boolean);
}
function currentEntry() {
return state.entries.find((entry) => entry.id === state.selectedId);
}
function applyFormToState() {
if (!state.selectedId) return;
const index = state.entries.findIndex((entry) => entry.id === state.selectedId);
if (index === -1) return;
state.entries[index] = entryFromForm();
state.selectedId = state.entries[index].id;
rememberDraft();
renderAll();
}
function fillForm(entry) {
if (!entry) return;
$("deskTitle").textContent = entry.title || "Untitled hidden entry";
$("deskSub").textContent = `${entry.type} / ${entry.category || "uncategorized"}`;
$("title").value = entry.title || "";
$("type").value = entry.type || "quote";
$("characters").value = (entry.characters || []).join(", ");
$("tone").value = entry.emotionalTone || "warm";
$("rarity").value = entry.rarity || "common";
$("category").value = entry.category || "";
$("familyLayer").value = entry.familyLayer || "";
$("pageLocation").value = entry.pageLocation || "";
$("triggerConditions").value = entry.triggerConditions || "";
$("content").value = entry.content || "";
$("tags").value = (entry.tags || []).join(", ");
$("enabled").value = entry.enabled === false ? "false" : "true";
$("notes").value = entry.notes || "";
$("audioSettings").value = entry.audioSettings || "";
$("animationTrigger").value = entry.animationTrigger || "";
$("cssClassHooks").value = entry.cssClassHooks || "";
$("chainReferences").value = (entry.chainReferences || []).join(", ");
$("continuationLinks").value = (entry.continuationLinks || []).join(", ");
renderPreview(entry);
}
function selectEntry(id) {
const entry = state.entries.find((item) => item.id === id) || state.entries[0];
if (!entry) return;
state.selectedId = entry.id;
fillForm(entry);
renderList();
renderGraph();
renderTimeline();
}
function newEntry(type = "quote") {
snapshot();
const now = new Date().toISOString().slice(0, 10);
const entry = {
id: `hidden-${Date.now()}`,
title: "Untitled hidden entry",
type,
content: "",
characters: [],
emotionalTone: "warm",
rarity: "common",
triggerConditions: "",
tags: [],
category: type,
pageLocation: "",
familyLayer: "",
enabled: true,
createdDate: now,
modifiedDate: now,
notes: "",
chainReferences: [],
continuationLinks: [],
};
state.entries.unshift(entry);
state.selectedId = entry.id;
rememberDraft();
renderAll();
fillForm(entry);
$("title").focus();
}
function duplicateEntry() {
const entry = currentEntry();
if (!entry) return;
snapshot();
const copy = { ...entry, id: `${entry.id}-copy-${Date.now()}`, title: `${entry.title} copy`, createdDate: new Date().toISOString().slice(0, 10), modifiedDate: new Date().toISOString().slice(0, 10) };
state.entries.splice(Math.max(0, state.entries.findIndex((item) => item.id === entry.id)), 0, copy);
state.selectedId = copy.id;
rememberDraft();
renderAll();
fillForm(copy);
}
function deleteEntry() {
const entry = currentEntry();
if (!entry || !confirm(`Delete "${entry.title}"?`)) return;
snapshot();
state.entries = state.entries.filter((item) => item.id !== entry.id);
state.selectedId = state.entries[0]?.id || "";
rememberDraft();
renderAll();
fillForm(currentEntry());
}
function renderPreview(entry) {
const body = html(entry.content || "");
const withMarkdown = body
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
.replace(/^&gt;\s?(.+)$/gm, "<blockquote>$1</blockquote>");
$("preview").innerHTML = `${withMarkdown || "<span class='subtle'>This hidden piece is still waiting for words.</span>"}<div class="pills" style="margin-top:12px">${(entry.characters || []).map((item) => `<span class="pill">${html(item)}</span>`).join("")}<span class="pill">${html(entry.emotionalTone)}</span><span class="pill">${html(entry.rarity)}</span></div>`;
}
function passesFilters(entry) {
const query = $("search").value.toLowerCase();
const haystack = [entry.title, entry.content, entry.type, entry.category, entry.triggerConditions, entry.pageLocation, entry.notes, ...(entry.tags || []), ...(entry.characters || [])].join(" ").toLowerCase();
return (!query || haystack.includes(query))
&& (!$("typeFilter").value || entry.type === $("typeFilter").value)
&& (!$("characterFilter").value || (entry.characters || []).includes($("characterFilter").value))
&& (!$("toneFilter").value || entry.emotionalTone === $("toneFilter").value)
&& (!$("rarityFilter").value || entry.rarity === $("rarityFilter").value);
}
function renderList() {
const box = $("entryList");
state.filtered = state.entries.filter(passesFilters);
box.innerHTML = "";
if (!state.filtered.length) {
box.innerHTML = "<div class='status'>No hidden pieces matched. Try a softer filter.</div>";
return;
}
state.filtered.forEach((entry) => {
const btn = document.createElement("button");
btn.type = "button";
btn.draggable = true;
btn.className = `entry-card${entry.id === state.selectedId ? " active" : ""}${entry.enabled === false ? " disabled" : ""}`;
btn.dataset.id = entry.id;
btn.innerHTML = `<strong>${html(entry.title)}</strong><span class="subtle">${html(entry.type)} / ${html(entry.category || "uncategorized")}</span><span class="entry-meta"><span class="dot ${html(entry.rarity)}"></span><span class="pill">${html(entry.emotionalTone || "warm")}</span>${(entry.characters || []).slice(0, 3).map((item) => `<span class="pill">${html(item)}</span>`).join("")}</span>`;
btn.onclick = () => selectEntry(entry.id);
btn.ondragstart = () => { state.draggingId = entry.id; };
btn.ondragover = (event) => event.preventDefault();
btn.ondrop = () => reorderEntry(state.draggingId, entry.id);
box.appendChild(btn);
});
}
function reorderEntry(fromId, toId) {
if (!fromId || fromId === toId) return;
snapshot();
const from = state.entries.findIndex((entry) => entry.id === fromId);
const to = state.entries.findIndex((entry) => entry.id === toId);
const [item] = state.entries.splice(from, 1);
state.entries.splice(to, 0, item);
rememberDraft();
renderList();
}
function renderGraph() {
const box = $("graph");
const entry = currentEntry();
if (!entry) return;
const links = [...(entry.chainReferences || []), ...(entry.continuationLinks || [])].filter(Boolean);
const nodes = [entry.id, ...links].slice(0, 8);
const centerX = 50, centerY = 50, radius = 34;
const labels = nodes.map((id) => state.entries.find((item) => item.id === id)?.title || id);
box.innerHTML = `<svg viewBox="0 0 100 100">${nodes.slice(1).map((_, i) => {
const angle = (Math.PI * 2 * i) / Math.max(1, nodes.length - 1);
const x = centerX + Math.cos(angle) * radius;
const y = centerY + Math.sin(angle) * radius;
return `<line x1="${centerX}" y1="${centerY}" x2="${x}" y2="${y}" stroke="rgba(77,113,90,.35)" stroke-width="1" />`;
}).join("")}</svg>${nodes.map((id, i) => {
const angle = (Math.PI * 2 * (i - 1)) / Math.max(1, nodes.length - 1);
const x = i === 0 ? centerX : centerX + Math.cos(angle) * radius;
const y = i === 0 ? centerY : centerY + Math.sin(angle) * radius;
return `<span class="node" style="left:${x}%;top:${y}%">${html(labels[i])}</span>`;
}).join("")}`;
}
function renderTimeline() {
const rows = state.entries.slice().sort((a, b) => String(b.modifiedDate || "").localeCompare(String(a.modifiedDate || ""))).slice(0, 12);
$("timeline").innerHTML = rows.map((entry) => `<div class="time-row"><strong>${html(entry.modifiedDate || entry.createdDate || "")}</strong><br>${html(entry.title)}<br><span class="subtle">${html(entry.type)} / ${html(entry.rarity)}</span></div>`).join("");
}
function renderAll() {
renderList();
renderGraph();
renderTimeline();
if (currentEntry()) renderPreview(currentEntry());
}
function populateSelects() {
const opt = (value) => `<option value="${html(value)}">${html(value || "All")}</option>`;
$("type").innerHTML = state.meta.types.map(opt).join("");
$("typeFilter").innerHTML = opt("") + state.meta.types.map(opt).join("");
$("characterFilter").innerHTML = opt("") + state.meta.characters.map(opt).join("");
$("tone").innerHTML = state.meta.tones.map(opt).join("");
$("toneFilter").innerHTML = opt("") + state.meta.tones.map(opt).join("");
$("rarity").innerHTML = state.meta.rarities.map(opt).join("");
$("rarityFilter").innerHTML = opt("") + state.meta.rarities.map(opt).join("");
}
async function load() {
try {
const data = await api("/api/hidden");
state.meta = data;
state.entries = data.entries || [];
const draft = JSON.parse(localStorage.getItem(draftKey) || "null");
if (draft?.entries?.length && confirm("A local hidden-desk draft exists. Restore it?")) {
state.entries = draft.entries;
state.selectedId = draft.selectedId || "";
}
populateSelects();
setStatus(data.migratedFromJs ? "Existing JS constants were read and shaped into friendly entries." : "hidden drawer open.");
selectEntry(state.selectedId || state.entries[0]?.id);
renderAll();
} catch (err) {
setStatus(err.message);
}
}
async function save() {
applyFormToState();
setStatus("Saving the hidden layer.");
try {
const data = await api("/api/hidden", { method: "POST", body: JSON.stringify({ entries: state.entries }) });
state.entries = data.entries;
localStorage.removeItem(draftKey);
setStatus(data.message || "stored safely.");
renderAll();
} catch (err) {
setStatus(err.message);
}
}
fields.forEach((id) => {
$(id).addEventListener("input", () => { applyFormToState(); });
$(id).addEventListener("change", () => { snapshot(); applyFormToState(); });
});
["search","typeFilter","characterFilter","toneFilter","rarityFilter"].forEach((id) => $(id).addEventListener("input", renderList));
$("saveBtn").onclick = save;
$("newBtn").onclick = () => newEntry();
$("duplicateBtn").onclick = duplicateEntry;
$("deleteBtn").onclick = deleteEntry;
$("undoBtn").onclick = () => { if (!state.undo.length) return; state.redo.push(JSON.stringify(state.entries)); restore(state.undo.pop()); };
$("redoBtn").onclick = () => { if (!state.redo.length) return; state.undo.push(JSON.stringify(state.entries)); restore(state.redo.pop()); };
document.querySelectorAll("[data-template]").forEach((button) => button.addEventListener("click", () => newEntry(button.dataset.template)));
load();
</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 == "/hidden":
body = HIDDEN_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":
try:
self.send_json(list_pages())
except Exception as exc:
self.send_json({"error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
return
if parsed.path == "/api/diagnostics":
try:
self.send_json(server_diagnostics())
except Exception as exc:
self.send_json({"error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
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_QUEUE.snapshot())
return
if parsed.path == "/api/hidden":
try:
self.send_json(load_hidden_store())
except Exception as exc:
self.send_json({"error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
return
self.send_error(HTTPStatus.NOT_FOUND)
def do_POST(self) -> None:
if self.path == "/api/upload":
try:
length = int(self.headers.get("Content-Length", "0"))
filename, payload, page_path = parse_upload_form(
self.headers.get("Content-Type", ""),
self.rfile.read(length),
)
self.send_json(save_upload(filename, payload, page_path))
except Exception as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if self.path == "/api/hidden":
try:
length = int(self.headers.get("Content-Length", "0"))
data = json.loads(self.rfile.read(length).decode("utf-8"))
self.send_json(save_hidden_store(data))
except Exception as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if self.path != "/api/page":
self.send_error(HTTPStatus.NOT_FOUND)
return
try:
length = int(self.headers.get("Content-Length", "0"))
data = json.loads(self.rfile.read(length).decode("utf-8"))
saved = save_page(data)
saved["queuedBuild"] = queue_build(saved)
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)
page_count = len(list_pages())
print(f"Authoring UI running at http://127.0.0.1:{port}")
print(f"Content root: {ROOT}")
print(f"Editable pages: {page_count}")
if page_count == 0:
print("WARNING: no editable pages were found. Check AUTHOR_ROOT and the launch directory.")
print("Press Ctrl-C to stop.")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()