3111 lines
130 KiB
Python
Executable File
3111 lines
130 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"]
|
|
HIDDEN_LAYER_DEPTHS = [
|
|
{
|
|
"id": "0",
|
|
"name": "Surface Reality",
|
|
"meaning": "Normal visible website content, visible warmth, and ordinary interactions.",
|
|
},
|
|
{
|
|
"id": "1",
|
|
"name": "Hidden Personality",
|
|
"meaning": "Small hidden jokes, hover text, tiny discoveries, and recurring symbols.",
|
|
},
|
|
{
|
|
"id": "2",
|
|
"name": "Memory Layer",
|
|
"meaning": "Young Z memories, Lima notes, nostalgia, and emotional fragments.",
|
|
},
|
|
{
|
|
"id": "3",
|
|
"name": "Reflection Layer",
|
|
"meaning": "Sensei Chi philosophy, Aphy conversations, and introspection.",
|
|
},
|
|
{
|
|
"id": "4",
|
|
"name": "Time Layer",
|
|
"meaning": "Future Z logs, time anomalies, long-term revisits, and future/past echoes.",
|
|
},
|
|
{
|
|
"id": "5",
|
|
"name": "Core Layer",
|
|
"meaning": "Rare deeply emotional truths found by patient exploration.",
|
|
},
|
|
]
|
|
|
|
|
|
def slugify(value: str) -> str:
|
|
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
|
return slug or "untitled"
|
|
|
|
|
|
def normalise_tags(value: Any) -> list[str]:
|
|
if isinstance(value, str):
|
|
raw = re.split(r"[,:\s]+", value)
|
|
elif isinstance(value, list):
|
|
raw = [str(item) for item in value]
|
|
else:
|
|
raw = []
|
|
tags = []
|
|
for tag in raw:
|
|
if not tag.strip():
|
|
continue
|
|
clean = slugify(tag)
|
|
if clean and clean not in tags:
|
|
tags.append(clean)
|
|
return tags
|
|
|
|
|
|
def org_date(dt: datetime) -> str:
|
|
return dt.strftime("<%Y-%m-%d %a %H:%M>")
|
|
|
|
|
|
def parse_org_datetime(value: str | None) -> datetime | None:
|
|
if not value:
|
|
return None
|
|
match = re.search(r"<(\d{4})-(\d{2})-(\d{2})(?:\s+\w+)?(?:\s+(\d{2}):(\d{2}))?>", value)
|
|
if not match:
|
|
return None
|
|
year, month, day, hour, minute = match.groups()
|
|
return datetime(
|
|
int(year),
|
|
int(month),
|
|
int(day),
|
|
int(hour or 12),
|
|
int(minute or 0),
|
|
)
|
|
|
|
|
|
def html_escape(value: str) -> str:
|
|
return (
|
|
value.replace("&", "&")
|
|
.replace("<", "<")
|
|
.replace(">", ">")
|
|
.replace('"', """)
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class OrgPage:
|
|
path: str
|
|
page_type: str
|
|
title: str
|
|
slug: str
|
|
tags: list[str]
|
|
content: str
|
|
date: str
|
|
comments: bool
|
|
options: str
|
|
wip: str | None = None
|
|
|
|
|
|
@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"})",
|
|
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"" 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", []),
|
|
"emotionalRole": extra.pop("emotionalRole", ""),
|
|
"discoveryDifficulty": extra.pop("discoveryDifficulty", "gentle"),
|
|
"mysteryLevel": extra.pop("mysteryLevel", "quiet"),
|
|
"resonanceScore": extra.pop("resonanceScore", 3),
|
|
"symbols": extra.pop("symbols", []),
|
|
"narrativeArcs": extra.pop("narrativeArcs", []),
|
|
"parentLinks": extra.pop("parentLinks", []),
|
|
"childLinks": extra.pop("childLinks", []),
|
|
"echoes": extra.pop("echoes", []),
|
|
"mirroredEntries": extra.pop("mirroredEntries", []),
|
|
"thematicLinks": extra.pop("thematicLinks", []),
|
|
"symbolicLinks": extra.pop("symbolicLinks", []),
|
|
"triggerLinks": extra.pop("triggerLinks", []),
|
|
}
|
|
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()]
|
|
entry["emotionalRole"] = str(entry.get("emotionalRole") or "")
|
|
entry["discoveryDifficulty"] = str(entry.get("discoveryDifficulty") or "gentle")
|
|
entry["mysteryLevel"] = str(entry.get("mysteryLevel") or "quiet")
|
|
try:
|
|
entry["resonanceScore"] = max(1, min(10, int(entry.get("resonanceScore") or 3)))
|
|
except (TypeError, ValueError):
|
|
entry["resonanceScore"] = 3
|
|
for key in [
|
|
"symbols",
|
|
"narrativeArcs",
|
|
"parentLinks",
|
|
"childLinks",
|
|
"echoes",
|
|
"mirroredEntries",
|
|
"thematicLinks",
|
|
"symbolicLinks",
|
|
"triggerLinks",
|
|
]:
|
|
entry[key] = [str(item).strip() for item in entry.get(key, []) 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,
|
|
"layers": HIDDEN_LAYER_DEPTHS,
|
|
"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 for narrative nodes, then 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, resolve conflicts by narrative node id and relationship fields, not by whole-file ownership.",
|
|
"scalability": "The graph model can grow into arc folders, richer discovery flows, symbolic indexes, and per-character constellation views without changing the live hidden-details runtime.",
|
|
}
|
|
|
|
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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(/<u>(.+?)<\/u>/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 Memory Observatory</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: dark;
|
|
--bg: #12100d;
|
|
--ink: #f8ead1;
|
|
--muted: #b9a98e;
|
|
--paper: #fbf1dc;
|
|
--paper-ink: #32251a;
|
|
--line: rgba(232, 202, 139, 0.24);
|
|
--line-strong: rgba(232, 202, 139, 0.52);
|
|
--rose: #d06b78;
|
|
--moss: #7fb089;
|
|
--gold: #d3a64d;
|
|
--blue: #75a9bd;
|
|
--violet: #a58ac9;
|
|
--shadow: 0 28px 80px rgba(0, 0, 0, 0.34);
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
overflow: hidden;
|
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
color: var(--ink);
|
|
background:
|
|
radial-gradient(circle at 50% 50%, rgba(211, 166, 77, 0.13), transparent 12%),
|
|
radial-gradient(circle at 52% 48%, rgba(208, 107, 120, 0.14), transparent 28%),
|
|
radial-gradient(circle at 16% 18%, rgba(117, 169, 189, 0.16), transparent 24%),
|
|
linear-gradient(145deg, #0f0d0b 0%, #1b1611 48%, #241b17 100%);
|
|
}
|
|
body::before {
|
|
content: "";
|
|
position: fixed;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
opacity: 0.18;
|
|
background-image:
|
|
linear-gradient(rgba(255,255,255,0.055) 1px, transparent 1px),
|
|
linear-gradient(90deg, rgba(255,255,255,0.035) 1px, transparent 1px);
|
|
background-size: 34px 34px;
|
|
}
|
|
button, input, textarea, select { font: inherit; }
|
|
button {
|
|
min-height: 36px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 7px;
|
|
background: rgba(251, 241, 220, 0.09);
|
|
color: var(--ink);
|
|
padding: 0 11px;
|
|
cursor: pointer;
|
|
}
|
|
button:hover { border-color: var(--line-strong); background: rgba(251, 241, 220, 0.15); }
|
|
button.primary { background: #d3a64d; border-color: #edc778; color: #1a120b; font-weight: 850; }
|
|
button.danger { color: #ffd8dd; border-color: rgba(208, 107, 120, 0.6); }
|
|
input, textarea, select {
|
|
width: 100%;
|
|
border: 1px solid rgba(92, 67, 34, 0.34);
|
|
border-radius: 7px;
|
|
background: var(--paper);
|
|
color: var(--paper-ink);
|
|
padding: 9px 10px;
|
|
}
|
|
textarea { min-height: 122px; resize: vertical; line-height: 1.55; }
|
|
label { display: grid; gap: 5px; color: #d9c28d; font-size: 12px; font-weight: 800; }
|
|
h1, h2, h3 { font-family: Georgia, "Times New Roman", serif; letter-spacing: 0; }
|
|
h1 { margin: 0; font-size: clamp(24px, 3vw, 42px); }
|
|
h2 { margin: 0 0 10px; font-size: 18px; }
|
|
h3 { margin: 0; font-size: 16px; }
|
|
.observatory { display: grid; grid-template-columns: 280px minmax(0, 1fr) 390px; height: 100vh; }
|
|
.left, .drawer {
|
|
z-index: 3;
|
|
overflow: auto;
|
|
border-color: var(--line);
|
|
background: rgba(19, 16, 13, 0.84);
|
|
backdrop-filter: blur(18px);
|
|
}
|
|
.left { border-right: 1px solid var(--line); padding: 18px; }
|
|
.drawer { border-left: 1px solid var(--line); padding: 18px; }
|
|
.map { position: relative; min-width: 0; overflow: hidden; }
|
|
.map-head {
|
|
position: absolute;
|
|
z-index: 2;
|
|
top: 18px;
|
|
left: 20px;
|
|
right: 20px;
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
pointer-events: none;
|
|
}
|
|
.map-head > * { pointer-events: auto; }
|
|
.subtle { color: var(--muted); font-size: 13px; line-height: 1.45; }
|
|
.status {
|
|
margin: 14px 0;
|
|
border: 1px solid var(--line);
|
|
border-left: 4px solid var(--gold);
|
|
border-radius: 7px;
|
|
padding: 10px 11px;
|
|
color: #e9d7b8;
|
|
background: rgba(251, 241, 220, 0.08);
|
|
font-size: 13px;
|
|
}
|
|
.stack { display: grid; gap: 10px; }
|
|
.filters { display: grid; gap: 9px; margin: 14px 0; }
|
|
.modebar, .actions, .pills { display: flex; flex-wrap: wrap; gap: 7px; align-items: center; }
|
|
.modebar button.active { background: rgba(211, 166, 77, 0.26); border-color: var(--gold); }
|
|
.pill {
|
|
border: 1px solid var(--line);
|
|
border-radius: 999px;
|
|
padding: 3px 8px;
|
|
color: #efd9a6;
|
|
background: rgba(251, 241, 220, 0.08);
|
|
font-size: 12px;
|
|
}
|
|
.layer-key { display: grid; gap: 8px; margin-top: 14px; }
|
|
.layer-row {
|
|
display: grid;
|
|
grid-template-columns: 28px 1fr;
|
|
gap: 9px;
|
|
align-items: start;
|
|
padding: 9px;
|
|
border: 1px solid rgba(232, 202, 139, 0.16);
|
|
border-radius: 8px;
|
|
background: rgba(251, 241, 220, 0.055);
|
|
}
|
|
.layer-row strong { display: block; color: #ffe2a6; font-size: 13px; }
|
|
.depth-mark {
|
|
width: 24px;
|
|
height: 24px;
|
|
border-radius: 50%;
|
|
display: grid;
|
|
place-items: center;
|
|
background: rgba(211, 166, 77, 0.16);
|
|
border: 1px solid rgba(211, 166, 77, 0.48);
|
|
color: #ffe2a6;
|
|
font-size: 12px;
|
|
font-weight: 850;
|
|
}
|
|
#graph {
|
|
position: absolute;
|
|
inset: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
.ring { fill: none; stroke: rgba(232, 202, 139, 0.14); stroke-width: 1; }
|
|
.ring-label { fill: rgba(248, 234, 209, 0.45); font-size: 10px; letter-spacing: 0; }
|
|
.edge { stroke: rgba(248, 234, 209, 0.18); stroke-width: 1; }
|
|
.edge.continuation { stroke: rgba(211, 166, 77, 0.5); stroke-width: 1.6; }
|
|
.edge.echo { stroke: rgba(117, 169, 189, 0.45); stroke-dasharray: 4 5; }
|
|
.edge.theme, .edge.symbol { stroke: rgba(127, 176, 137, 0.38); }
|
|
.memory-node { cursor: pointer; transition: opacity 160ms ease; }
|
|
.memory-node text { fill: #f7ead3; font-size: 11px; paint-order: stroke; stroke: rgba(14, 11, 8, 0.82); stroke-width: 3px; stroke-linejoin: round; }
|
|
.memory-node circle { stroke: rgba(255, 246, 223, 0.82); stroke-width: 1.4; filter: drop-shadow(0 0 7px rgba(255, 232, 177, 0.18)); }
|
|
.memory-node.selected circle { stroke: #fff3c7; stroke-width: 3; }
|
|
.memory-node.dragging circle { stroke: #f3cd7a; stroke-width: 3; }
|
|
.map-note {
|
|
position: absolute;
|
|
left: 20px;
|
|
bottom: 18px;
|
|
z-index: 2;
|
|
max-width: 520px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
padding: 10px 12px;
|
|
background: rgba(18, 16, 13, 0.72);
|
|
color: #e7d5b8;
|
|
font-size: 13px;
|
|
}
|
|
.panel {
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
padding: 12px;
|
|
background: rgba(251, 241, 220, 0.07);
|
|
box-shadow: var(--shadow);
|
|
}
|
|
.editor-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
|
|
.full { grid-column: 1 / -1; }
|
|
.preview {
|
|
white-space: pre-wrap;
|
|
min-height: 118px;
|
|
line-height: 1.6;
|
|
border: 1px solid rgba(92, 67, 34, 0.3);
|
|
border-radius: 8px;
|
|
background: var(--paper);
|
|
color: var(--paper-ink);
|
|
padding: 12px;
|
|
font-family: Georgia, "Times New Roman", serif;
|
|
}
|
|
.connection-list, .timeline-list { display: grid; gap: 8px; }
|
|
.connection {
|
|
text-align: left;
|
|
display: block;
|
|
width: 100%;
|
|
height: auto;
|
|
min-height: 0;
|
|
padding: 8px 9px;
|
|
}
|
|
.flow-row { display: grid; gap: 3px; border-left: 3px solid rgba(211, 166, 77, 0.45); padding-left: 9px; font-size: 13px; }
|
|
.hidden { display: none !important; }
|
|
@keyframes breathe {
|
|
0%, 100% { opacity: 0.72; }
|
|
50% { opacity: 1; }
|
|
}
|
|
.core-glow { animation: breathe 7s ease-in-out infinite; }
|
|
@media (max-width: 1120px) {
|
|
body { overflow: auto; }
|
|
.observatory { grid-template-columns: 1fr; height: auto; min-height: 100vh; }
|
|
.left, .drawer { max-height: none; border: 0; border-bottom: 1px solid var(--line); }
|
|
.map { height: 72vh; min-height: 560px; }
|
|
}
|
|
@media (max-width: 720px) {
|
|
.map-head { position: static; padding: 16px; display: grid; }
|
|
#graph { top: 128px; height: calc(100% - 128px); }
|
|
.editor-grid { grid-template-columns: 1fr; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="observatory">
|
|
<aside class="left">
|
|
<h1>Memory Observatory</h1>
|
|
<div class="subtle">A constellation map for the hidden soul of the website.</div>
|
|
<div id="status" class="status">Aphy is dimming the room lights.</div>
|
|
<div class="modebar" id="modebar">
|
|
<button data-mode="graph" class="active">Emotional graph</button>
|
|
<button data-mode="timeline">Timeline</button>
|
|
<button data-mode="character">Character</button>
|
|
<button data-mode="layer">Layer dive</button>
|
|
<button data-mode="flow">Discovery flow</button>
|
|
</div>
|
|
<div class="filters">
|
|
<input id="search" type="search" placeholder="Search memories, symbols, triggers" />
|
|
<select id="layerFilter"></select>
|
|
<select id="characterFilter"></select>
|
|
<select id="toneFilter"></select>
|
|
<select id="rarityFilter"></select>
|
|
</div>
|
|
<div class="actions">
|
|
<button id="newBtn" type="button">New memory</button>
|
|
<button id="saveBtn" class="primary" type="button">Save constellation</button>
|
|
</div>
|
|
<div class="layer-key" id="layerKey"></div>
|
|
</aside>
|
|
|
|
<main class="map">
|
|
<div class="map-head">
|
|
<div>
|
|
<h1 id="mapTitle">Emotional Graph</h1>
|
|
<div id="mapSub" class="subtle">Layer 0 lives near the edge. Layer 5 rests at the quiet core.</div>
|
|
</div>
|
|
<div class="actions">
|
|
<button id="undoBtn" type="button">Undo</button>
|
|
<button id="redoBtn" type="button">Redo</button>
|
|
<button id="duplicateBtn" type="button">Duplicate</button>
|
|
<button id="deleteBtn" class="danger" type="button">Delete</button>
|
|
</div>
|
|
</div>
|
|
<svg id="graph" viewBox="0 0 1000 760" role="img" aria-label="Hidden narrative constellation"></svg>
|
|
<div class="map-note" id="mapNote">Click a memory to open it. Drag one memory onto another to weave a continuation thread.</div>
|
|
</main>
|
|
|
|
<aside class="drawer">
|
|
<section class="panel">
|
|
<h2 id="drawerTitle">Choose a memory</h2>
|
|
<div id="drawerSub" class="subtle">The side panel opens from the constellation.</div>
|
|
<div id="preview" class="preview" style="margin-top:10px"></div>
|
|
</section>
|
|
<section class="panel" style="margin-top:12px">
|
|
<h2>Memory editor</h2>
|
|
<div class="editor-grid">
|
|
<label class="full">Name<input id="title" /></label>
|
|
<label>Kind<select id="type"></select></label>
|
|
<label>Depth<select id="familyLayer"></select></label>
|
|
<label>Characters<input id="characters" placeholder="Lima, Aphy" /></label>
|
|
<label>Tone<select id="tone"></select></label>
|
|
<label>Rarity<select id="rarity"></select></label>
|
|
<label>Discovery<select id="discoveryDifficulty" placeholder="gentle, patient, hidden" /></label>
|
|
<label>Mystery<input id="mysteryLevel" placeholder="quiet, strange, deep" /></label>
|
|
<label>Resonance<input id="resonanceScore" type="number" min="1" max="10" /></label>
|
|
<label>Where it appears<input id="pageLocation" placeholder="/play/lima-note.html" /></label>
|
|
<label class="full">Trigger path<input id="triggerConditions" placeholder="search: lima, hover, idle 90 seconds" /></label>
|
|
<label class="full">Memory text<textarea id="content" spellcheck="true"></textarea></label>
|
|
<label>Symbols<input id="symbols" placeholder="ring, tea, crayon sun" /></label>
|
|
<label>Arcs<input id="narrativeArcs" placeholder="lima arc, time echoes" /></label>
|
|
<label>Tags<input id="tags" placeholder="warm, october" /></label>
|
|
<label>Role<input id="emotionalRole" placeholder="reassurance, invitation, warning" /></label>
|
|
<label class="full">Continuations<input id="continuationLinks" placeholder="memory-id, memory-id" /></label>
|
|
<label class="full">Echoes<input id="echoes" placeholder="memory-id, memory-id" /></label>
|
|
<label class="full">Themes<input id="thematicLinks" placeholder="memory-id, memory-id" /></label>
|
|
<label class="full">Symbol links<input id="symbolicLinks" placeholder="memory-id, memory-id" /></label>
|
|
<label class="full">Trigger links<input id="triggerLinks" placeholder="memory-id, memory-id" /></label>
|
|
<label class="full">Parent memories<input id="parentLinks" placeholder="memory-id" /></label>
|
|
<label class="full">Child memories<input id="childLinks" placeholder="memory-id" /></label>
|
|
<label class="full">Mirrors<input id="mirroredEntries" placeholder="memory-id" /></label>
|
|
<label>Enabled<select id="enabled"><option value="true">Awake</option><option value="false">Resting</option></select></label>
|
|
<label>CSS hooks<input id="cssClassHooks" /></label>
|
|
<label>Audio<input id="audioSettings" /></label>
|
|
<label>Animation<input id="animationTrigger" /></label>
|
|
<label class="full">Private notes<textarea id="notes"></textarea></label>
|
|
</div>
|
|
<div id="autosave" class="subtle" style="margin-top:10px"></div>
|
|
</section>
|
|
<section class="panel" style="margin-top:12px">
|
|
<h2>Nearby memories</h2>
|
|
<div id="connections" class="connection-list"></div>
|
|
</section>
|
|
<section class="panel" style="margin-top:12px">
|
|
<h2>Architecture</h2>
|
|
<div class="subtle">
|
|
<p><strong>Updated model:</strong> each hidden piece is a narrative node. The old Family Layer Index is now a depth zone, not a type.</p>
|
|
<p><strong>Relationship engine:</strong> links come from continuations, echoes, themes, symbols, triggers, page locations, characters, and arcs.</p>
|
|
<p><strong>Storage:</strong> the friendly graph lives in <code>assets/content/hidden-details.json</code>; the live site still receives generated constants in <code>assets/scripts/hidden-details.js</code>.</p>
|
|
</div>
|
|
</section>
|
|
</aside>
|
|
</div>
|
|
|
|
<script>
|
|
const draftKey = "hiddenNarrativeObservatoryDraft:v1";
|
|
const state = { entries: [], meta: {}, mode: "graph", selectedId: "", undo: [], redo: [], draggingId: "", positions: new Map() };
|
|
const fields = ["title","type","familyLayer","characters","tone","rarity","discoveryDifficulty","mysteryLevel","resonanceScore","pageLocation","triggerConditions","content","symbols","narrativeArcs","tags","emotionalRole","continuationLinks","echoes","thematicLinks","symbolicLinks","triggerLinks","parentLinks","childLinks","mirroredEntries","enabled","cssClassHooks","audioSettings","animationTrigger","notes"];
|
|
const $ = (id) => document.getElementById(id);
|
|
const toneColors = { warm: "#d3a64d", funny: "#7fb089", nostalgic: "#d06b78", wise: "#75a9bd", strange: "#a58ac9", soft: "#e6bd8c", hopeful: "#9fcf9f", protective: "#d98f71", melancholy: "#8fa7c6" };
|
|
const characterSymbols = { Lima: "L", Aphy: "A", "Sensei Chi": "S", "Young Z": "Y", "Future Z": "F", Z: "Z" };
|
|
|
|
function html(value) {
|
|
return String(value || "").replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char]));
|
|
}
|
|
function splitList(value) {
|
|
return String(value || "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
}
|
|
function slugify(value) {
|
|
return String(value || "memory").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "memory";
|
|
}
|
|
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();
|
|
render();
|
|
selectNode(state.selectedId || state.entries[0]?.id);
|
|
}
|
|
function rememberDraft() {
|
|
localStorage.setItem(draftKey, JSON.stringify({ entries: state.entries, selectedId: state.selectedId, savedAt: Date.now() }));
|
|
$("autosave").textContent = "local draft kept warm.";
|
|
}
|
|
function depth(entry) {
|
|
const value = String(entry.familyLayer || "").match(/[0-5]/);
|
|
if (value) return Number(value[0]);
|
|
if ((entry.characters || []).includes("Future Z")) return 4;
|
|
if ((entry.characters || []).includes("Sensei Chi")) return 3;
|
|
if ((entry.characters || []).includes("Young Z") || (entry.characters || []).includes("Lima")) return 2;
|
|
return entry.rarity === "rare" || entry.rarity === "very rare" ? 5 : 1;
|
|
}
|
|
function currentEntry() {
|
|
return state.entries.find((entry) => entry.id === state.selectedId);
|
|
}
|
|
function entryFromForm() {
|
|
const current = currentEntry() || {};
|
|
return {
|
|
...current,
|
|
id: current.id || slugify($("title").value),
|
|
title: $("title").value.trim() || "Untitled memory",
|
|
type: $("type").value,
|
|
familyLayer: $("familyLayer").value,
|
|
characters: splitList($("characters").value),
|
|
emotionalTone: $("tone").value,
|
|
rarity: $("rarity").value,
|
|
discoveryDifficulty: $("discoveryDifficulty").value,
|
|
mysteryLevel: $("mysteryLevel").value,
|
|
resonanceScore: Number($("resonanceScore").value || 3),
|
|
pageLocation: $("pageLocation").value,
|
|
triggerConditions: $("triggerConditions").value,
|
|
content: $("content").value,
|
|
symbols: splitList($("symbols").value),
|
|
narrativeArcs: splitList($("narrativeArcs").value),
|
|
tags: splitList($("tags").value),
|
|
emotionalRole: $("emotionalRole").value,
|
|
continuationLinks: splitList($("continuationLinks").value),
|
|
echoes: splitList($("echoes").value),
|
|
thematicLinks: splitList($("thematicLinks").value),
|
|
symbolicLinks: splitList($("symbolicLinks").value),
|
|
triggerLinks: splitList($("triggerLinks").value),
|
|
parentLinks: splitList($("parentLinks").value),
|
|
childLinks: splitList($("childLinks").value),
|
|
mirroredEntries: splitList($("mirroredEntries").value),
|
|
enabled: $("enabled").value === "true",
|
|
cssClassHooks: $("cssClassHooks").value,
|
|
audioSettings: $("audioSettings").value,
|
|
animationTrigger: $("animationTrigger").value,
|
|
notes: $("notes").value,
|
|
};
|
|
}
|
|
function applyFormToState() {
|
|
if (!state.selectedId) return;
|
|
const index = state.entries.findIndex((entry) => entry.id === state.selectedId);
|
|
if (index < 0) return;
|
|
state.entries[index] = entryFromForm();
|
|
state.selectedId = state.entries[index].id;
|
|
rememberDraft();
|
|
render();
|
|
}
|
|
function fillForm(entry) {
|
|
if (!entry) return;
|
|
$("drawerTitle").textContent = entry.title || "Untitled memory";
|
|
$("drawerSub").textContent = `${layerName(depth(entry))} / ${entry.type || "memory"}`;
|
|
$("title").value = entry.title || "";
|
|
$("type").value = entry.type || "quote";
|
|
$("familyLayer").value = String(depth(entry));
|
|
$("characters").value = (entry.characters || []).join(", ");
|
|
$("tone").value = entry.emotionalTone || "warm";
|
|
$("rarity").value = entry.rarity || "common";
|
|
$("discoveryDifficulty").value = entry.discoveryDifficulty || "gentle";
|
|
$("mysteryLevel").value = entry.mysteryLevel || "quiet";
|
|
$("resonanceScore").value = entry.resonanceScore || 3;
|
|
$("pageLocation").value = entry.pageLocation || "";
|
|
$("triggerConditions").value = entry.triggerConditions || "";
|
|
$("content").value = entry.content || "";
|
|
$("symbols").value = (entry.symbols || []).join(", ");
|
|
$("narrativeArcs").value = (entry.narrativeArcs || []).join(", ");
|
|
$("tags").value = (entry.tags || []).join(", ");
|
|
$("emotionalRole").value = entry.emotionalRole || "";
|
|
$("continuationLinks").value = (entry.continuationLinks || []).join(", ");
|
|
$("echoes").value = (entry.echoes || []).join(", ");
|
|
$("thematicLinks").value = (entry.thematicLinks || []).join(", ");
|
|
$("symbolicLinks").value = (entry.symbolicLinks || []).join(", ");
|
|
$("triggerLinks").value = (entry.triggerLinks || []).join(", ");
|
|
$("parentLinks").value = (entry.parentLinks || []).join(", ");
|
|
$("childLinks").value = (entry.childLinks || []).join(", ");
|
|
$("mirroredEntries").value = (entry.mirroredEntries || []).join(", ");
|
|
$("enabled").value = entry.enabled === false ? "false" : "true";
|
|
$("cssClassHooks").value = entry.cssClassHooks || "";
|
|
$("audioSettings").value = entry.audioSettings || "";
|
|
$("animationTrigger").value = entry.animationTrigger || "";
|
|
$("notes").value = entry.notes || "";
|
|
renderPreview(entry);
|
|
renderConnections(entry);
|
|
}
|
|
function layerName(layer) {
|
|
return (state.meta.layers || []).find((item) => item.id === String(layer))?.name || `Layer ${layer}`;
|
|
}
|
|
function filteredEntries() {
|
|
const query = $("search").value.toLowerCase();
|
|
return state.entries.filter((entry) => {
|
|
const haystack = [entry.title, entry.content, entry.type, entry.triggerConditions, entry.pageLocation, entry.emotionalRole, entry.mysteryLevel, ...(entry.characters || []), ...(entry.tags || []), ...(entry.symbols || []), ...(entry.narrativeArcs || [])].join(" ").toLowerCase();
|
|
return (!query || haystack.includes(query))
|
|
&& (!$("layerFilter").value || String(depth(entry)) === $("layerFilter").value)
|
|
&& (!$("characterFilter").value || (entry.characters || []).includes($("characterFilter").value))
|
|
&& (!$("toneFilter").value || entry.emotionalTone === $("toneFilter").value)
|
|
&& (!$("rarityFilter").value || entry.rarity === $("rarityFilter").value);
|
|
});
|
|
}
|
|
function relationshipPairs(entries) {
|
|
const byId = new Map(state.entries.map((entry) => [entry.id, entry]));
|
|
const visible = new Set(entries.map((entry) => entry.id));
|
|
const pairs = [];
|
|
const add = (source, target, kind) => {
|
|
if (!target || !byId.has(target) || !visible.has(source.id) || !visible.has(target)) return;
|
|
pairs.push({ source: source.id, target, kind });
|
|
};
|
|
entries.forEach((entry) => {
|
|
(entry.continuationLinks || []).forEach((id) => add(entry, id, "continuation"));
|
|
(entry.chainReferences || []).forEach((id) => add(entry, id, "continuation"));
|
|
(entry.echoes || []).forEach((id) => add(entry, id, "echo"));
|
|
(entry.thematicLinks || []).forEach((id) => add(entry, id, "theme"));
|
|
(entry.symbolicLinks || []).forEach((id) => add(entry, id, "symbol"));
|
|
(entry.triggerLinks || []).forEach((id) => add(entry, id, "trigger"));
|
|
(entry.parentLinks || []).forEach((id) => add(entry, id, "parent"));
|
|
(entry.childLinks || []).forEach((id) => add(entry, id, "child"));
|
|
(entry.mirroredEntries || []).forEach((id) => add(entry, id, "echo"));
|
|
});
|
|
if (state.mode === "character" || state.mode === "graph") {
|
|
entries.forEach((entry, index) => {
|
|
entries.slice(index + 1).forEach((other) => {
|
|
if ((entry.characters || []).some((name) => (other.characters || []).includes(name))) pairs.push({ source: entry.id, target: other.id, kind: "character" });
|
|
if ((entry.symbols || []).some((name) => (other.symbols || []).includes(name))) pairs.push({ source: entry.id, target: other.id, kind: "symbol" });
|
|
});
|
|
});
|
|
}
|
|
return pairs.slice(0, 520);
|
|
}
|
|
function layoutEntries(entries) {
|
|
const cx = 500;
|
|
const cy = 400;
|
|
const byLayer = new Map();
|
|
entries.forEach((entry) => {
|
|
const layer = state.mode === "layer" ? depth(entry) : state.mode === "character" ? characterBucket(entry) : depth(entry);
|
|
if (!byLayer.has(layer)) byLayer.set(layer, []);
|
|
byLayer.get(layer).push(entry);
|
|
});
|
|
state.positions = new Map();
|
|
[...byLayer.entries()].forEach(([layer, items]) => {
|
|
const layerNum = Number(layer);
|
|
const radius = state.mode === "layer" ? 72 + (5 - layerNum) * 54 : 80 + (5 - depth({ familyLayer: String(layerNum) })) * 55;
|
|
items.forEach((entry, index) => {
|
|
const salt = hash(entry.id) / 9999;
|
|
const angle = ((Math.PI * 2) / Math.max(1, items.length)) * index + salt;
|
|
let x = cx + Math.cos(angle) * radius;
|
|
let y = cy + Math.sin(angle) * radius * 0.72;
|
|
if (state.mode === "timeline") {
|
|
x = 120 + (index % 7) * 125;
|
|
y = 140 + Math.floor(index / 7) * 72;
|
|
}
|
|
if (state.mode === "flow") {
|
|
x = 120 + depth(entry) * 145;
|
|
y = 120 + (index % 9) * 62;
|
|
}
|
|
state.positions.set(entry.id, { x, y });
|
|
});
|
|
});
|
|
}
|
|
function characterBucket(entry) {
|
|
const first = (entry.characters || [])[0] || "Other";
|
|
const index = Math.max(0, state.meta.characters.indexOf(first));
|
|
return index % 6;
|
|
}
|
|
function hash(value) {
|
|
let h = 0;
|
|
for (let i = 0; i < value.length; i += 1) h = (h * 31 + value.charCodeAt(i)) % 9999;
|
|
return h;
|
|
}
|
|
function render() {
|
|
const entries = filteredEntries();
|
|
layoutEntries(entries);
|
|
const graph = $("graph");
|
|
const pairs = relationshipPairs(entries);
|
|
const rings = state.mode === "timeline" || state.mode === "flow" ? "" : [0,1,2,3,4,5].map((layer) => {
|
|
const radius = 80 + (5 - layer) * 55;
|
|
return `<ellipse class="ring ${layer === 5 ? "core-glow" : ""}" cx="500" cy="400" rx="${radius}" ry="${radius * 0.72}"></ellipse><text class="ring-label" x="${505 + radius}" y="${400}">Layer ${layer} - ${html(layerName(layer))}</text>`;
|
|
}).join("");
|
|
const edges = pairs.map((pair) => {
|
|
const a = state.positions.get(pair.source);
|
|
const b = state.positions.get(pair.target);
|
|
if (!a || !b) return "";
|
|
return `<line class="edge ${html(pair.kind)}" x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}"></line>`;
|
|
}).join("");
|
|
const nodes = entries.map((entry) => {
|
|
const pos = state.positions.get(entry.id);
|
|
const color = toneColors[entry.emotionalTone] || "#d3a64d";
|
|
const size = 5 + Math.min(10, Number(entry.resonanceScore || 3)) + (entry.rarity === "rare" || entry.rarity === "very rare" ? 3 : 0);
|
|
const char = characterSymbols[(entry.characters || [])[0]] || "*";
|
|
return `<g class="memory-node${entry.id === state.selectedId ? " selected" : ""}${entry.enabled === false ? " resting" : ""}" data-id="${html(entry.id)}" tabindex="0" transform="translate(${pos.x} ${pos.y})">
|
|
<circle r="${size}" fill="${color}" opacity="${entry.enabled === false ? 0.42 : 0.92}"></circle>
|
|
<text x="0" y="4" text-anchor="middle">${html(char)}</text>
|
|
<text x="${size + 5}" y="-7">${html(entry.title).slice(0, 34)}</text>
|
|
</g>`;
|
|
}).join("");
|
|
graph.innerHTML = `${rings}${edges}${nodes}`;
|
|
graph.querySelectorAll(".memory-node").forEach((node) => {
|
|
node.addEventListener("click", () => selectNode(node.dataset.id));
|
|
node.addEventListener("dragstart", () => { state.draggingId = node.dataset.id; node.classList.add("dragging"); });
|
|
node.addEventListener("dragend", () => node.classList.remove("dragging"));
|
|
node.addEventListener("dragover", (event) => event.preventDefault());
|
|
node.addEventListener("drop", (event) => { event.preventDefault(); connectNodes(state.draggingId, node.dataset.id); });
|
|
node.setAttribute("draggable", "true");
|
|
});
|
|
renderModeSupport(entries);
|
|
}
|
|
function renderModeSupport(entries) {
|
|
const titles = {
|
|
graph: ["Emotional Graph", "Relationship web: continuations, echoes, symbols, characters, triggers."],
|
|
timeline: ["Timeline View", "Memories arranged by modified date so the constellation becomes a diary."],
|
|
character: ["Character View", "Nodes gather around Lima, Aphy, Sensei Chi, Young Z, Future Z, and Z."],
|
|
layer: ["Layer Dive", "Depth zones show the emotional pressure from surface reality to the core."],
|
|
flow: ["Discovery Flow", "A possible path from ordinary encounter to patient secret."],
|
|
};
|
|
$("mapTitle").textContent = titles[state.mode][0];
|
|
$("mapSub").textContent = titles[state.mode][1];
|
|
$("mapNote").textContent = `${entries.length} memories visible. Drag one memory onto another to create a continuation thread.`;
|
|
}
|
|
function selectNode(id) {
|
|
const entry = state.entries.find((item) => item.id === id) || state.entries[0];
|
|
if (!entry) return;
|
|
state.selectedId = entry.id;
|
|
fillForm(entry);
|
|
render();
|
|
}
|
|
function connectNodes(sourceId, targetId) {
|
|
if (!sourceId || !targetId || sourceId === targetId) return;
|
|
snapshot();
|
|
const entry = state.entries.find((item) => item.id === sourceId);
|
|
if (!entry) return;
|
|
entry.continuationLinks = Array.from(new Set([...(entry.continuationLinks || []), targetId]));
|
|
state.selectedId = sourceId;
|
|
rememberDraft();
|
|
fillForm(entry);
|
|
render();
|
|
setStatus("thread woven. A continuation now runs between those memories.");
|
|
}
|
|
function renderPreview(entry) {
|
|
const body = html(entry.content || "").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
|
$("preview").innerHTML = `${body || "<span class='subtle'>This memory is waiting for words.</span>"}<div class="pills" style="margin-top:10px"><span class="pill">${html(layerName(depth(entry)))}</span><span class="pill">${html(entry.emotionalTone)}</span><span class="pill">${html(entry.rarity)}</span>${(entry.symbols || []).map((item) => `<span class="pill">${html(item)}</span>`).join("")}</div>`;
|
|
}
|
|
function renderConnections(entry) {
|
|
const ids = new Set([...(entry.continuationLinks || []), ...(entry.echoes || []), ...(entry.thematicLinks || []), ...(entry.symbolicLinks || []), ...(entry.triggerLinks || []), ...(entry.parentLinks || []), ...(entry.childLinks || []), ...(entry.mirroredEntries || [])]);
|
|
const sameCharacter = state.entries.filter((other) => other.id !== entry.id && (entry.characters || []).some((name) => (other.characters || []).includes(name))).slice(0, 4);
|
|
const linked = [...ids].map((id) => state.entries.find((item) => item.id === id)).filter(Boolean);
|
|
const items = [...linked, ...sameCharacter].slice(0, 10);
|
|
$("connections").innerHTML = items.length ? items.map((item) => `<button class="connection" type="button" data-id="${html(item.id)}"><strong>${html(item.title)}</strong><br><span class="subtle">${html(layerName(depth(item)))} / ${html(item.type)}</span></button>`).join("") : "<div class='subtle'>No nearby memories yet. Drag from this node to another to begin a thread.</div>";
|
|
$("connections").querySelectorAll("button").forEach((button) => button.addEventListener("click", () => selectNode(button.dataset.id)));
|
|
}
|
|
function newEntry(type = "quote") {
|
|
snapshot();
|
|
const now = new Date().toISOString().slice(0, 10);
|
|
const entry = {
|
|
id: `memory-${Date.now()}`,
|
|
title: "Untitled memory",
|
|
type,
|
|
content: "",
|
|
characters: [],
|
|
emotionalTone: "warm",
|
|
rarity: "common",
|
|
triggerConditions: "",
|
|
tags: [],
|
|
category: type,
|
|
pageLocation: "",
|
|
familyLayer: "1",
|
|
enabled: true,
|
|
createdDate: now,
|
|
modifiedDate: now,
|
|
notes: "",
|
|
emotionalRole: "",
|
|
discoveryDifficulty: "gentle",
|
|
mysteryLevel: "quiet",
|
|
resonanceScore: 3,
|
|
symbols: [],
|
|
narrativeArcs: [],
|
|
continuationLinks: [],
|
|
echoes: [],
|
|
thematicLinks: [],
|
|
symbolicLinks: [],
|
|
triggerLinks: [],
|
|
parentLinks: [],
|
|
childLinks: [],
|
|
mirroredEntries: [],
|
|
};
|
|
state.entries.unshift(entry);
|
|
selectNode(entry.id);
|
|
rememberDraft();
|
|
}
|
|
function duplicateEntry() {
|
|
const entry = currentEntry();
|
|
if (!entry) return;
|
|
snapshot();
|
|
const copy = { ...entry, id: `${entry.id}-echo-${Date.now()}`, title: `${entry.title} echo`, createdDate: new Date().toISOString().slice(0, 10), modifiedDate: new Date().toISOString().slice(0, 10) };
|
|
state.entries.unshift(copy);
|
|
selectNode(copy.id);
|
|
rememberDraft();
|
|
}
|
|
function deleteEntry() {
|
|
const entry = currentEntry();
|
|
if (!entry || !confirm(`Let "${entry.title}" rest outside the constellation?`)) return;
|
|
snapshot();
|
|
state.entries = state.entries.filter((item) => item.id !== entry.id);
|
|
state.entries.forEach((item) => {
|
|
["continuationLinks","echoes","thematicLinks","symbolicLinks","triggerLinks","parentLinks","childLinks","mirroredEntries"].forEach((key) => {
|
|
item[key] = (item[key] || []).filter((id) => id !== entry.id);
|
|
});
|
|
});
|
|
selectNode(state.entries[0]?.id);
|
|
rememberDraft();
|
|
}
|
|
function populateControls() {
|
|
const opt = (value, label = value) => `<option value="${html(value)}">${html(label || "All")}</option>`;
|
|
$("type").innerHTML = state.meta.types.map((value) => opt(value)).join("");
|
|
$("tone").innerHTML = state.meta.tones.map((value) => opt(value)).join("");
|
|
$("rarity").innerHTML = state.meta.rarities.map((value) => opt(value)).join("");
|
|
$("layerFilter").innerHTML = opt("", "All depths") + (state.meta.layers || []).map((layer) => opt(layer.id, `Layer ${layer.id} - ${layer.name}`)).join("");
|
|
$("familyLayer").innerHTML = (state.meta.layers || []).map((layer) => opt(layer.id, `Layer ${layer.id} - ${layer.name}`)).join("");
|
|
$("characterFilter").innerHTML = opt("", "All characters") + state.meta.characters.map((value) => opt(value)).join("");
|
|
$("toneFilter").innerHTML = opt("", "All tones") + state.meta.tones.map((value) => opt(value)).join("");
|
|
$("rarityFilter").innerHTML = opt("", "All rarities") + state.meta.rarities.map((value) => opt(value)).join("");
|
|
$("layerKey").innerHTML = (state.meta.layers || []).map((layer) => `<div class="layer-row"><span class="depth-mark">${html(layer.id)}</span><span><strong>${html(layer.name)}</strong><span class="subtle">${html(layer.meaning)}</span></span></div>`).join("");
|
|
}
|
|
async function save() {
|
|
applyFormToState();
|
|
setStatus("Saving the constellation.");
|
|
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 || "constellation stored safely.");
|
|
render();
|
|
} catch (err) {
|
|
setStatus(err.message);
|
|
}
|
|
}
|
|
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 constellation draft exists. Restore it?")) {
|
|
state.entries = draft.entries;
|
|
state.selectedId = draft.selectedId || "";
|
|
}
|
|
populateControls();
|
|
setStatus(data.migratedFromJs ? "Existing hidden details became a memory constellation." : "observatory open.");
|
|
selectNode(state.selectedId || state.entries[0]?.id);
|
|
} catch (err) {
|
|
setStatus(err.message);
|
|
}
|
|
}
|
|
fields.forEach((id) => {
|
|
$(id).addEventListener("input", applyFormToState);
|
|
$(id).addEventListener("change", () => { snapshot(); applyFormToState(); });
|
|
});
|
|
["search","layerFilter","characterFilter","toneFilter","rarityFilter"].forEach((id) => $(id).addEventListener("input", render));
|
|
$("newBtn").onclick = () => newEntry();
|
|
$("saveBtn").onclick = save;
|
|
$("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()); };
|
|
$("modebar").querySelectorAll("button").forEach((button) => button.addEventListener("click", () => {
|
|
state.mode = button.dataset.mode;
|
|
$("modebar").querySelectorAll("button").forEach((item) => item.classList.toggle("active", item === button));
|
|
render();
|
|
}));
|
|
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()
|