Initial authoring system prototype

This commit is contained in:
Joey Grasty
2026-08-23 14:27:56 -05:00
commit 23e4ef595e
101 changed files with 5473 additions and 0 deletions

285
prototype/app.py Normal file
View File

@@ -0,0 +1,285 @@
#!/usr/bin/env python3
"""Read-only local package-preview prototype for a Scrivener project.
This deliberately has no model call and no Scrivener write path. It is a
small standard-library server so the first workflow test has no dependency
installation step.
"""
from __future__ import annotations
import argparse
import json
import re
import shutil
import subprocess
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
APP_DIR = Path(__file__).resolve().parent
DEFAULT_PROJECT = APP_DIR.parent / "hapagirlsauthoringsystemv1.scriv"
@dataclass
class Item:
uuid: str
title: str
item_type: str
children: list["Item"] = field(default_factory=list)
parent_title: str = ""
def rtf_to_text(path: Path) -> str:
"""Extract readable text; use macOS textutil when available, otherwise fall back."""
if shutil.which("textutil"):
result = subprocess.run(
["textutil", "-convert", "txt", "-stdout", str(path)],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0:
return result.stdout.strip()
raw = path.read_text(encoding="latin-1", errors="replace")
raw = re.sub(r"\\'[0-9a-fA-F]{2}", lambda m: bytes.fromhex(m.group()[2:]).decode("latin-1"), raw)
raw = re.sub(r"\\u-?\d+\??", "", raw)
raw = raw.replace("\\par", "\n").replace("\\line", "\n").replace("\\tab", "\t")
raw = re.sub(r"\\[a-zA-Z]+-?\d* ?", "", raw)
raw = raw.replace("{", "").replace("}", "").replace("\\", "")
return re.sub(r"\n{3,}", "\n\n", raw).strip()
class ScrivenerReader:
def __init__(self, project: Path):
self.project = project
project_files = list(project.glob("*.scrivx"))
if len(project_files) != 1:
raise ValueError("Project must contain exactly one .scrivx file.")
self.project_file = project_files[0]
self.root_items = self._load_binder()
self.by_uuid = {item.uuid: item for item in self.walk()}
def _load_binder(self) -> list[Item]:
root = ET.parse(self.project_file).getroot()
binder = root.find("Binder")
if binder is None:
raise ValueError("Scrivener project has no Binder.")
def parse(element: ET.Element, parent_title: str = "") -> Item:
item = Item(
uuid=element.attrib["UUID"],
title=(element.findtext("Title") or "").strip(),
item_type=element.attrib.get("Type", ""),
parent_title=parent_title,
)
children = element.find("Children")
if children is not None:
item.children = [parse(child, item.title) for child in children.findall("BinderItem")]
return item
return [parse(item) for item in binder.findall("BinderItem")]
def walk(self, items: list[Item] | None = None):
for item in self.root_items if items is None else items:
yield item
yield from self.walk(item.children)
def top_level(self, title: str) -> Item | None:
return next((item for item in self.root_items if item.title == title), None)
@staticmethod
def child(item: Item | None, title: str) -> Item | None:
if item is None:
return None
return next((candidate for candidate in item.children if candidate.title == title), None)
def content(self, item: Item | None) -> str:
if item is None:
return ""
path = self.project / "Files" / "Data" / item.uuid / "content.rtf"
return rtf_to_text(path) if path.exists() else ""
@staticmethod
def records(folder: Item | None, reader: "ScrivenerReader") -> list[dict]:
if folder is None:
return []
return [
{"uuid": item.uuid, "title": item.title, "text": reader.content(item)}
for item in folder.children
if item.item_type == "Text"
]
def manuscript_scenes(self) -> list[dict]:
manuscript = self.top_level("Manuscript")
scenes: list[dict] = []
if manuscript is None:
return scenes
for chapter in manuscript.children:
for item in chapter.children:
if item.item_type == "Text":
scenes.append(
{
"uuid": item.uuid,
"title": item.title,
"chapter": chapter.title,
"text": self.content(item),
"state": "canonical" if self.content(item).strip() else "planned",
}
)
return scenes
def summary(self) -> dict:
authoring = self.top_level("Authoring System")
setup = self.child(authoring, "Book Setup")
continuity = self.child(authoring, "Continuity")
briefs_folder = self.child(authoring, "Scene Briefs")
summaries_folder = self.child(continuity, "Scene Summaries")
style = self.child(setup, "STYLE")
instructions = self.child(setup, "Frontier-Model Instructions")
lexicon = self.child(continuity, "Master Lexicon")
world_notes = self.child(continuity, "World Notes")
manuscript = self.manuscript_scenes()
briefs = self.records(briefs_folder, self)
brief_by_title = {brief["title"]: brief for brief in briefs}
summary_by_title = {record["title"]: record for record in self.records(summaries_folder, self)}
paired = []
for scene in manuscript:
paired.append(
{
**scene,
"brief": brief_by_title.get(scene["title"], {}).get("text", ""),
"brief_uuid": brief_by_title.get(scene["title"], {}).get("uuid"),
"summary": summary_by_title.get(scene["title"], {}).get("text", ""),
}
)
manuscript_titles = {scene["title"] for scene in manuscript}
return {
"project": str(self.project),
"locked": (self.project / "Files" / "user.lock").exists(),
"style": self.content(style),
"frontier_instructions": self.content(instructions),
"master_lexicon": self.content(lexicon),
"world_notes": self.content(world_notes),
"scenes": paired,
"characters": self.records(self.top_level("Characters"), self),
"places": self.records(self.top_level("Places"), self),
"unmatched_briefs": [brief for brief in briefs if brief["title"] not in manuscript_titles],
"unmatched_scenes": [scene for scene in manuscript if scene["title"] not in brief_by_title],
}
def section(name: str, value: str) -> str:
return f"### {name}\n{value.strip() or '(none)'}"
def build_package(project: ScrivenerReader, payload: dict) -> str:
data = project.summary()
scene = next((scene for scene in data["scenes"] if scene["uuid"] == payload.get("scene_uuid")), None)
if scene is None:
raise ValueError("Choose a paired Manuscript scene.")
if not scene["brief"].strip():
raise ValueError(f"{scene['title']} has no paired BRIEF.")
selected_characters = set(payload.get("character_uuids", []))
character_blocks = []
for character in data["characters"]:
if character["uuid"] in selected_characters:
character_blocks.append(f"{character['title']}\n{character['text'].strip()}")
selected_places = set(payload.get("place_uuids", []))
place_blocks = []
for place in data["places"]:
if place["uuid"] in selected_places:
place_blocks.append(f"{place['title']}{place['text'].strip()}")
notes = "\n".join(block for block in ["\n".join(place_blocks), payload.get("notes", "").strip()] if block)
story = payload.get("story", "").strip()
if not story:
story = "(none - new storyline)" if payload.get("new_thread", True) else "(none)"
return "\n\n".join(
[
section("STYLE", data["style"]),
section("LEXICON", payload.get("lexicon", "")),
section("STORY", story),
section("CHARACTERS", "\n\n".join(character_blocks)),
section("RECALL", payload.get("recall", "")),
section("PREVIOUS", payload.get("previous", "")),
section("NOTES", notes),
section("BRIEF", scene["brief"]),
section("DIRECTIVE", payload.get("directive", "")),
"### SCENE",
]
) + "\n"
class AppHandler(SimpleHTTPRequestHandler):
reader: ScrivenerReader
def __init__(self, *args: object, **kwargs: object) -> None:
super().__init__(*args, directory=str(APP_DIR), **kwargs)
def send_json(self, value: object, status: HTTPStatus = HTTPStatus.OK) -> None:
body = json.dumps(value, ensure_ascii=False).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.end_headers()
self.wfile.write(body)
def do_GET(self) -> None:
path = urlparse(self.path).path
if path == "/api/project":
self.send_json(self.reader.summary())
return
if path == "/api/health":
self.send_json({"status": "ok", "mode": "read-only prototype"})
return
if path == "/":
self.path = "/static/index.html"
elif path.startswith("/static/"):
self.path = path
else:
self.send_error(HTTPStatus.NOT_FOUND)
return
super().do_GET()
def do_POST(self) -> None:
if urlparse(self.path).path != "/api/package-preview":
self.send_error(HTTPStatus.NOT_FOUND)
return
try:
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length).decode("utf-8"))
self.send_json({"package": build_package(self.reader, payload)})
except (ValueError, json.JSONDecodeError) as error:
self.send_json({"error": str(error)}, HTTPStatus.BAD_REQUEST)
def log_message(self, format: str, *args: object) -> None:
print("[authoring prototype] " + format % args)
def main() -> None:
parser = argparse.ArgumentParser(description="Run the read-only authoring-system prototype.")
parser.add_argument("--project", type=Path, default=DEFAULT_PROJECT)
parser.add_argument("--port", type=int, default=8765)
args = parser.parse_args()
project = args.project.expanduser().resolve()
if not project.is_dir():
parser.error(f"Project not found: {project}")
AppHandler.reader = ScrivenerReader(project)
server = ThreadingHTTPServer(("127.0.0.1", args.port), AppHandler)
print(f"Read-only authoring prototype: http://127.0.0.1:{args.port}")
print(f"Project: {project}")
server.serve_forever()
if __name__ == "__main__":
main()