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

19
prototype/README.md Normal file
View File

@@ -0,0 +1,19 @@
# Read-only Package Preview Prototype
This is the first authoring-system vertical slice. It reads a Scrivener project, checks the project structure, and builds an on-screen package preview. It does **not** call a model and it does **not** write to Scrivener.
Run from `Authoring_System`:
```sh
python3 prototype/app.py
```
Then open [http://127.0.0.1:8765](http://127.0.0.1:8765).
To point it at another project:
```sh
python3 prototype/app.py --project /absolute/path/to/Book.scriv
```
The app binds scene/brief pairs by the visible `Scene X` labels for this read-only prototype. The next phase will persist UUID bindings in a sidecar, so later renaming does not break relationships.

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()

72
prototype/static/app.js vendored Normal file
View File

@@ -0,0 +1,72 @@
let project;
const byId = (id) => document.getElementById(id);
const selected = (container) => [...container.querySelectorAll('input:checked')].map((input) => input.value);
function checks(container, records) {
container.innerHTML = records.map((record) => `
<label title="${escapeHtml(record.text)}"><input type="checkbox" value="${record.uuid}"> ${escapeHtml(record.title)}</label>
`).join('');
}
function escapeHtml(value) {
return value.replace(/[&<>'"]/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[character]));
}
function updateSceneState() {
const scene = project.scenes.find((candidate) => candidate.uuid === byId('scene').value);
byId('scene-state').textContent = scene ? `${scene.chapter} · ${scene.state} · ${scene.brief ? 'BRIEF paired' : 'BRIEF missing'}` : '';
byId('brief').textContent = scene && scene.brief ? scene.brief : 'No paired BRIEF exists for this scene.';
}
function render(data) {
project = data;
byId('status').textContent = data.locked ? 'Scrivener lock detected — read-only' : 'Project not locked — app remains read-only';
byId('status').className = `status ${data.locked ? 'warning' : 'ok'}`;
byId('project-check').innerHTML = [
['Scenes', data.scenes.length],
['Briefs without scene', data.unmatched_briefs.length],
['Scenes without brief', data.unmatched_scenes.length],
['Characters', data.characters.length],
['Places', data.places.length],
].map(([label, value]) => `<dt>${label}</dt><dd>${value}</dd>`).join('');
byId('scene').innerHTML = data.scenes.map((scene) => `<option value="${scene.uuid}">${escapeHtml(scene.title)}${escapeHtml(scene.chapter)}</option>`).join('');
checks(byId('characters'), data.characters);
checks(byId('places'), data.places);
updateSceneState();
}
async function load() {
const response = await fetch('/api/project');
if (!response.ok) throw new Error('Could not load the Scrivener project.');
render(await response.json());
}
async function build() {
byId('error').textContent = '';
const payload = {
scene_uuid: byId('scene').value,
character_uuids: selected(byId('characters')),
place_uuids: selected(byId('places')),
lexicon: byId('lexicon').value,
story: byId('story').value,
recall: byId('recall').value,
previous: byId('previous').value,
notes: byId('notes').value,
directive: byId('directive').value,
new_thread: byId('new-thread').checked,
};
const response = await fetch('/api/package-preview', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
});
const data = await response.json();
if (!response.ok) {
byId('error').textContent = data.error || 'Could not build package.';
return;
}
byId('package').textContent = data.package;
}
byId('scene').addEventListener('change', updateSceneState);
byId('build').addEventListener('click', build);
load().catch((error) => { byId('status').textContent = error.message; byId('status').className = 'status warning'; });

View File

@@ -0,0 +1,64 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Authoring System — Package Preview</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<header>
<div>
<p class="eyebrow">READ-ONLY PROTOTYPE</p>
<h1>Package Preview</h1>
</div>
<div id="status" class="status">Loading project…</div>
</header>
<main>
<aside>
<h2>Project check</h2>
<dl id="project-check"></dl>
<h2>Scene</h2>
<label for="scene">Paired Manuscript scene</label>
<select id="scene"></select>
<p id="scene-state" class="muted"></p>
<h2>Characters</h2>
<div id="characters" class="checks"></div>
<h2>Places for NOTES</h2>
<div id="places" class="checks"></div>
</aside>
<section class="workspace">
<section class="brief-panel">
<h2>Author BRIEF <span class="read-only">read-only</span></h2>
<pre id="brief">Select a scene to view its paired BRIEF.</pre>
</section>
<div class="grid">
<label>LEXICON (manual subset)
<textarea id="lexicon" rows="4" placeholder="Term — usage note"></textarea>
</label>
<label>STORY (manual preview)
<textarea id="story" rows="4" placeholder="Leave empty for a new thread in this prototype."></textarea>
</label>
<label>RECALL (manual preview)
<textarea id="recall" rows="4" placeholder="(Book 1 — summary)&#10;…"></textarea>
</label>
<label>PREVIOUS (manual preview)
<textarea id="previous" rows="4" placeholder="Verbatim paragraph-boundary tail."></textarea>
</label>
<label>Additional NOTES
<textarea id="notes" rows="4" placeholder="Scene-specific facts or exact text."></textarea>
</label>
<label>DIRECTIVE
<textarea id="directive" rows="4" placeholder="Expand to a maximum of 1500 words. Bring the scene to a close…"></textarea>
</label>
</div>
<label class="checkbox"><input id="new-thread" type="checkbox" checked> Treat empty STORY/PREVIOUS as a new narrative thread</label>
<button id="build">Build package preview</button>
<p id="error" class="error"></p>
<h2>Exact package</h2>
<pre id="package">Select a scene, choose context, then build a preview.</pre>
</section>
</main>
<script src="/static/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,14 @@
:root { color-scheme: light; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #17212b; background: #f4f1ea; }
* { box-sizing: border-box; }
body { margin: 0; }
header { display: flex; justify-content: space-between; align-items: center; padding: 1.4rem 2.2rem; background: #1d3f44; color: #fff; }
h1 { margin: 0; font-size: 1.8rem; } h2 { margin: 1.5rem 0 .6rem; font-size: 1rem; } .eyebrow { margin: 0 0 .25rem; font-size: .7rem; letter-spacing: .12em; color: #c7ded6; }
main { display: grid; grid-template-columns: 300px minmax(0, 1fr); min-height: calc(100vh - 84px); }
aside { padding: 1.2rem 1.5rem; border-right: 1px solid #d8d2c5; background: #fcfaf5; } .workspace { padding: 1.5rem 2rem; }
label { display: block; font-size: .85rem; font-weight: 650; } select, textarea { width: 100%; margin-top: .35rem; border: 1px solid #bab2a5; border-radius: 4px; background: #fffefb; padding: .55rem; font: .9rem ui-monospace, SFMono-Regular, Menlo, monospace; }
select { font-family: inherit; } .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; } .checks label { padding: .2rem 0; font-weight: 400; } .checks input { margin-right: .35rem; }
button { margin: 1rem 0; border: 0; border-radius: 4px; padding: .65rem 1rem; background: #b54c2d; color: white; font-weight: 700; cursor: pointer; } button:hover { background: #923b22; }
pre { min-height: 28rem; max-height: 65vh; overflow: auto; margin: 0; padding: 1rem; border: 1px solid #d8d2c5; background: #202525; color: #e5f3ed; white-space: pre-wrap; font: .82rem/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
.brief-panel { margin-bottom: 1.25rem; padding: 0 1rem 1rem; border: 1px solid #d8d2c5; background: #fcfaf5; } .brief-panel h2 { margin-top: 1rem; } .brief-panel pre { min-height: 0; max-height: 16rem; background: #fffefb; color: #17212b; font-family: inherit; font-size: .95rem; } .read-only { font-size: .68rem; font-weight: 600; color: #665f54; text-transform: uppercase; letter-spacing: .08em; }
dl { display: grid; grid-template-columns: 1fr auto; margin: 0; font-size: .9rem; } dt, dd { margin: 0; padding: .25rem 0; border-bottom: 1px solid #ece6da; } dd { font-weight: 700; } .status { border-radius: 999px; padding: .45rem .75rem; font-size: .78rem; } .ok { background: #d9eee4; color: #164d38; } .warning { background: #ffe2d6; color: #7f2c19; } .muted { color: #665f54; font-size: .8rem; } .error { color: #aa2717; font-weight: 700; } .checkbox { margin-top: 1rem; font-weight: 400; }
@media (max-width: 800px) { main { grid-template-columns: 1fr; } aside { border-right: 0; border-bottom: 1px solid #d8d2c5; } .grid { grid-template-columns: 1fr; } }