← Files NovelistARCHIVED FILE
skills/novel-story-telling/scripts/write_chapter.py
7.5 KB · Oct 5, 2026 · 18:29 UTC
#!/usr/bin/env python3
"""Create one template-conformant chapter file from reviewed JSON input."""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import tempfile
from datetime import date
from pathlib import Path
from typing import Any, NoReturn
SLUG = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
VALID_STATUSES = {"outline", "draft", "revision", "final"}
LIST_FIELDS = ("characters", "materials", "macguffins", "plot_threads", "tags")
STRING_FIELDS = ("pov", "timeline", "setting", "outline", "revision_notes")
ALLOWED_FIELDS = {
"number",
"title",
"slug",
"status",
"word_target",
"created",
"updated",
"published",
"synopsis",
"draft",
*LIST_FIELDS,
*STRING_FIELDS,
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--project-root", type=Path, default=Path.cwd())
parser.add_argument("--input", type=Path, required=True, help="Reviewed UTF-8 JSON chapter payload")
parser.add_argument("--dry-run", action="store_true", help="Validate and print Markdown without writing")
return parser.parse_args()
def fail(message: str) -> NoReturn:
raise ValueError(message)
def require_text(payload: dict[str, Any], key: str) -> str:
value = payload.get(key)
if not isinstance(value, str) or not value.strip():
fail(f"{key} must be a non-empty string")
return value.strip()
def optional_text(payload: dict[str, Any], key: str) -> str:
value = payload.get(key, "")
if not isinstance(value, str):
fail(f"{key} must be a string")
return value.strip()
def string_list(payload: dict[str, Any], key: str) -> list[str]:
value = payload.get(key, [])
if not isinstance(value, list) or any(not isinstance(item, str) or not item.strip() for item in value):
fail(f"{key} must be an array of non-empty strings")
return [item.strip() for item in value]
def yaml_scalar(value: str) -> str:
return json.dumps(value, ensure_ascii=False)
def yaml_list(key: str, values: list[str]) -> list[str]:
if not values:
return [f"{key}: []"]
return [f"{key}:", *(f" - {yaml_scalar(value)}" for value in values)]
def validate_payload(payload: Any) -> dict[str, Any]:
if not isinstance(payload, dict):
fail("input must be a JSON object")
unknown = sorted(set(payload) - ALLOWED_FIELDS)
if unknown:
fail(f"unknown input fields: {', '.join(unknown)}")
number = payload.get("number")
if isinstance(number, bool) or not isinstance(number, int) or not 1 <= number <= 999:
fail("number must be an integer from 1 through 999")
title = require_text(payload, "title")
slug = require_text(payload, "slug")
if not SLUG.fullmatch(slug):
fail("slug must contain lowercase ASCII letters or digits separated by single hyphens")
status = payload.get("status", "draft")
if status not in VALID_STATUSES:
fail(f"status must be one of: {', '.join(sorted(VALID_STATUSES))}")
word_target = payload.get("word_target", 2500)
if isinstance(word_target, bool) or not isinstance(word_target, int) or word_target <= 0:
fail("word_target must be a positive integer")
created = payload.get("created", date.today().isoformat())
updated = payload.get("updated", created)
for key, value in (("created", created), ("updated", updated)):
if not isinstance(value, str) or not DATE.fullmatch(value):
fail(f"{key} must use YYYY-MM-DD")
try:
date.fromisoformat(value)
except ValueError:
fail(f"{key} must be a real calendar date")
published = payload.get("published", False)
if not isinstance(published, bool):
fail("published must be a boolean")
if published:
fail("a newly created chapter must use published: false")
if updated < created:
fail("updated must not be earlier than created")
return {
"number": number,
"title": title,
"slug": slug,
"status": status,
"word_target": word_target,
"created": created,
"updated": updated,
"published": published,
"synopsis": require_text(payload, "synopsis"),
"draft": require_text(payload, "draft"),
**{key: optional_text(payload, key) for key in STRING_FIELDS},
**{key: string_list(payload, key) for key in LIST_FIELDS},
}
def render_chapter(data: dict[str, Any]) -> str:
number = data["number"]
lines = [
"---",
f"id: chapter-{number:03d}",
"type: chapter",
f"number: {number}",
f"title: {yaml_scalar(data['title'])}",
f"slug: {data['slug']}",
f"status: {data['status']}",
f"pov: {yaml_scalar(data['pov'])}",
f"timeline: {yaml_scalar(data['timeline'])}",
f"setting: {yaml_scalar(data['setting'])}",
f"word_target: {data['word_target']}",
]
for field in ("characters", "materials", "macguffins", "plot_threads"):
lines.extend(yaml_list(field, data[field]))
lines.extend(
[
f"outline: {yaml_scalar(data['outline'])}",
f"published: {'true' if data['published'] else 'false'}",
f"created: {data['created']}",
f"updated: {data['updated']}",
]
)
lines.extend(yaml_list("tags", data["tags"]))
lines.extend(
[
"---",
f"# {data['title']}",
"",
"## Synopsis",
"",
data["synopsis"],
"",
"## Draft",
"",
data["draft"],
"",
"## Revision Notes",
"",
]
)
if data["revision_notes"]:
lines.append(data["revision_notes"])
lines.append("")
return "\n".join(lines)
def ensure_available(chapters: Path, number: int, target: Path) -> None:
conflicts = sorted(chapters.glob(f"{number:03d}.*.md"))
if conflicts:
names = ", ".join(path.name for path in conflicts)
fail(f"chapter number {number:03d} already exists: {names}; revise the existing file instead")
if target.exists():
fail(f"refusing to overwrite existing file: {target}")
def atomic_write(target: Path, content: str) -> None:
target.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent)
temporary = Path(temporary_name)
try:
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
handle.write(content)
os.chmod(temporary, 0o644)
os.replace(temporary, target)
except BaseException:
temporary.unlink(missing_ok=True)
raise
def main() -> int:
args = parse_args()
try:
payload = json.loads(args.input.read_text(encoding="utf-8"))
data = validate_payload(payload)
root = args.project_root.resolve()
chapters = root / "chapters"
target = chapters / f"{data['number']:03d}.{data['slug']}.md"
ensure_available(chapters, data["number"], target)
content = render_chapter(data)
if args.dry_run:
sys.stdout.write(content)
else:
atomic_write(target, content)
print(target.relative_to(root).as_posix())
except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as error:
print(f"error: {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
SHA-256: 59c4b0600d4a261cff705a408fa722155bd43c510e086fa64239884de741b6aa