aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-05 09:39:57 +0200
committerDanilo M. <danix@danix.xyz>2026-10-05 09:39:57 +0200
commita9cb9970bb30b3b23f308728a38634518c5b0747 (patch)
tree50efe6a7bdeda0f60037e79cacac40a8cf235614
parent04cae51db97e20e24a53e1183dc18d5ea993f873 (diff)
downloadfanfictioner-a9cb9970bb30b3b23f308728a38634518c5b0747.tar.gz
fanfictioner-a9cb9970bb30b3b23f308728a38634518c5b0747.zip
Harden plan.md parsing and validation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rwxr-xr-xfanfictioner51
1 files changed, 42 insertions, 9 deletions
diff --git a/fanfictioner b/fanfictioner
index 8f5764b..fc5b761 100755
--- a/fanfictioner
+++ b/fanfictioner
@@ -95,6 +95,8 @@ def parse_plan(text):
section = cur = None
for line in text.splitlines():
s = line.strip()
+ if s.startswith("```") or s == "---":
+ continue
if s.startswith("# ") and not plan["title"]:
plan["title"] = s[2:].strip()
elif s.startswith("## "):
@@ -106,12 +108,12 @@ def parse_plan(text):
elif section == "pages":
m = re.match(r"(\d+)\.\s*(.*)", head)
cur = {"n": int(m[1]) if m else 0, "title": m[2] if m else head, "text": [],
- "characters": [], "pose": "", "framing": "", "model": ""}
+ "heading": "" if m else head, "characters": None, "pose": "", "framing": "", "model": ""}
plan["pages"].append(cur)
- elif section is None and (m := re.match(r"(Series|Book|Style):\s*(.*)", s)):
+ elif section is None and (m := re.match(r"[-*\s]*\**(Series|Book|Style)\**:\**\s*(.*)", s)):
plan[m[1].lower()] = m[2].strip()
elif section == "pages" and cur is not None and \
- (m := re.match(r"(Characters|Pose|Framing|Model):\s*(.*)", s)):
+ (m := re.match(r"[-*\s]*\**(Characters|Pose|Framing|Model)\**:\**\s*(.*)", s)):
key, val = m[1].lower(), m[2].strip()
if key == "characters":
val = [] if val.lower() == "none" else [c.strip() for c in val.split(",") if c.strip()]
@@ -132,13 +134,23 @@ def validate_plan(plan):
errs.append("no characters")
if not plan["pages"]:
errs.append("no pages")
+ errs += [f'bad {k} name "{plan[k]}"' for k in ("series", "book")
+ if plan[k] and (("/" in plan[k]) or plan[k] in (".", ".."))]
+ errs += [f'bad character name "{c}"' for c in plan["characters"] if "/" in c or c in (".", "..")]
ns = [p["n"] for p in plan["pages"]]
- if len(set(ns)) != len(ns) or 0 in ns:
+ if sorted(ns) != list(range(1, len(ns) + 1)):
errs.append("page numbers must be unique and start at 1")
for p in plan["pages"]:
- errs += [f"page {p['n']}: unknown character {c}" for c in p["characters"]
+ if p["heading"]:
+ errs.append(f'page heading "{p["heading"]}" must look like "### N. title"')
+ if p["characters"] is None:
+ errs.append(f"page {p['n']}: Characters missing")
+ chars = p["characters"] or []
+ errs += [f"page {p['n']}: unknown character {c}" for c in chars
if c not in plan["characters"]]
- if p["characters"] and not p["pose"]:
+ if len(set(chars)) != len(chars):
+ errs.append(f"page {p['n']}: duplicate character")
+ if chars and not p["pose"]:
errs.append(f"page {p['n']}: Pose missing")
if p["model"] and p["model"] not in ("zimage", "krea2"):
errs.append(f"page {p['n']}: unknown model {p['model']}")
@@ -185,7 +197,7 @@ def edit_groups(chars):
def edit_hint(plan):
return "\n".join(f"Page {p['n']}: " + (" then ".join("[" + ", ".join(g) + "]"
- for g in edit_groups(p["characters"]))
+ for g in edit_groups(p["characters"] or []))
or "no edits")
for p in plan["pages"])
@@ -194,7 +206,7 @@ def check_json(pj, plan):
errs = []
if [c["name"] for c in pj["characters"]] != list(plan["characters"]):
errs.append("characters differ from plan.md")
- want = {p["n"]: p["characters"] for p in plan["pages"]}
+ want = {p["n"]: p["characters"] or [] for p in plan["pages"]}
if sorted(p["n"] for p in pj["pages"]) != sorted(want):
errs.append("pages differ from plan.md")
for p in pj["pages"]:
@@ -243,6 +255,26 @@ def selftest():
assert validate_plan(parse_plan("")) == ["missing title", "missing series", "missing book",
"no characters", "no pages"]
+ md = parse_plan(SAMPLE_PLAN.replace("Characters: Mara, Jon, Kit", "**Characters:** Mara, Jon, Kit")
+ .replace("Pose:", "- **Pose**:").replace("Framing:", "* Framing:")
+ .replace("Series:", "**Series:**").replace("Style:", "- Style:"))
+ assert md["series"] == "Test Series" and md["style"] == "soft watercolor, muted colors", md
+ assert md["pages"][0]["characters"] == ["Mara", "Jon", "Kit"], md
+ assert md["pages"][0]["pose"].startswith("Mara left") and md["pages"][0]["framing"].startswith("wide"), md
+ assert validate_plan(md) == []
+ assert "page 2: Characters missing" in validate_plan(parse_plan(SAMPLE_PLAN.replace("Characters: none\n", "")))
+ assert edit_hint(parse_plan(SAMPLE_PLAN.replace("Characters: none\n", ""))).endswith("Page 2: no edits")
+ assert 'bad series name "../x"' in validate_plan(parse_plan(SAMPLE_PLAN.replace("Test Series", "../x")))
+ assert 'bad book name ".."' in validate_plan(parse_plan(SAMPLE_PLAN.replace("Book One", "..")))
+ assert 'bad character name "Jon/Kit"' in validate_plan(parse_plan(SAMPLE_PLAN.replace("### Jon", "### Jon/Kit")))
+ assert "page numbers must be unique and start at 1" in validate_plan(
+ parse_plan(SAMPLE_PLAN.replace("### 1.", "### 2.").replace("### 2. Empty", "### 3. Empty")))
+ assert 'page heading "Page 2: Empty roof" must look like "### N. title"' in validate_plan(
+ parse_plan(SAMPLE_PLAN.replace("### 2. Empty", "### Page 2: Empty")))
+ assert "page 1: duplicate character" in validate_plan(
+ parse_plan(SAMPLE_PLAN.replace("Characters: Mara, Jon, Kit", "Characters: Mara, Mara")))
+ fenced = parse_plan(SAMPLE_PLAN.replace("They reach the roof at dusk.", "```\nThey reach the roof at dusk.\n---\n```"))
+ assert fenced["pages"][0]["text"] == "They reach the roof at dusk.", fenced
assert edit_groups([]) == []
assert edit_groups(["Mara", "Jon", "Kit"]) == [["Mara", "Jon"], ["Kit"]]
assert edit_groups(list("abcd")) == [["a", "b"], ["c", "d"]]
@@ -299,7 +331,8 @@ def selftest():
assert [p["n"] for p in pages] == [2], pages
keep(t / "refs" / "Mara.png", t / "deep" / "x.png")
- assert (t / "deep" / "x.png").exists()
+ assert (t / "deep" / "x.png").read_bytes() == (t / "refs" / "Mara.png").read_bytes()
+ assert not list((t / "deep").glob("*.tmp"))
print("selftest ok")