Initial authoring system prototype
This commit is contained in:
19
prototype/README.md
Normal file
19
prototype/README.md
Normal 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
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()
|
||||
72
prototype/static/app.js
vendored
Normal file
72
prototype/static/app.js
vendored
Normal 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) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[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'; });
|
||||
64
prototype/static/index.html
Normal file
64
prototype/static/index.html
Normal 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) …"></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>
|
||||
14
prototype/static/styles.css
Normal file
14
prototype/static/styles.css
Normal 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; } }
|
||||
Reference in New Issue
Block a user