Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
75 lines
2.6 KiB
Python
75 lines
2.6 KiB
Python
"""The Story view: every scene's text as one box, cut by typed marker words.
|
|
|
|
`Scene.text` stays the truth (core/projects.py). A story is only a second way
|
|
to look at and edit the same scenes: `join` lays them out with a marker between
|
|
each, `split` reads that text back into scenes. A marker is a word the person
|
|
types (`qcut` by default); it is consumed on split, so a cut is never part of
|
|
any scene's text, and only typed words cut -- shortcut expansion happens later
|
|
(core/prompt_build.py) and never moves a boundary.
|
|
|
|
Lossless: split(join(scenes)) == scenes for any scenes that do not themselves
|
|
contain a marker word (an empty scene included -- "a qcut qcut b" is three
|
|
scenes, the middle one empty). The anchor is not part of the story; it has its
|
|
own field.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
|
|
from . import config
|
|
|
|
DEFAULT_MARKERS = ["qcut"]
|
|
MAX_MARKER = 60
|
|
|
|
|
|
def markers() -> list[str]:
|
|
try:
|
|
data = json.loads(config.MARKERS_FILE.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
|
return list(DEFAULT_MARKERS)
|
|
return _clean(data) or list(DEFAULT_MARKERS)
|
|
|
|
|
|
def _clean(raw) -> list[str]:
|
|
out = []
|
|
for m in raw if isinstance(raw, list) else []:
|
|
if not isinstance(m, str):
|
|
continue
|
|
m = " ".join(str(m).split())[:MAX_MARKER]
|
|
if m and m.lower() not in (o.lower() for o in out):
|
|
out.append(m)
|
|
return out
|
|
|
|
|
|
def save_markers(raw) -> list[str]:
|
|
"""A story with no marker could never be cut, so an empty list is refused
|
|
rather than quietly replaced by the default."""
|
|
clean = _clean(raw)
|
|
if not clean:
|
|
raise ValueError("at least one marker word is needed to cut scenes")
|
|
config.ROOT.mkdir(parents=True, exist_ok=True)
|
|
tmp = config.MARKERS_FILE.with_suffix(".tmp")
|
|
tmp.write_text(json.dumps(clean, indent=2), encoding="utf-8")
|
|
tmp.replace(config.MARKERS_FILE)
|
|
return clean
|
|
|
|
|
|
def _pattern(words: list[str]):
|
|
# Longest first so "scene cut" wins over "cut"; whole words only, so
|
|
# "qcutting" is not a cut. Spaces in a marker match any run of whitespace.
|
|
alts = [r"\s+".join(map(re.escape, w.split()))
|
|
for w in sorted(words, key=len, reverse=True)]
|
|
return re.compile(r"(?<!\w)(?:" + "|".join(alts) + r")(?!\w)", re.IGNORECASE)
|
|
|
|
|
|
def split(text: str, words: list[str] | None = None) -> list[str]:
|
|
parts = _pattern(words or markers()).split(str(text or ""))
|
|
return [p.strip() for p in parts]
|
|
|
|
|
|
def join(scenes, words: list[str] | None = None) -> str:
|
|
marker = (words or markers())[0]
|
|
return f"\n{marker}\n".join(str(s or "").strip() for s in scenes)
|