Initial authoring system prototype
This commit is contained in:
285
prototype/app.py
Normal file
285
prototype/app.py
Normal 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()
|
||||
Reference in New Issue
Block a user