Files
felhom.eu/documentation/audits/app-guides-2026-10-15/tools/apply_guide.py
T

166 lines
5.6 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""apply_guide.py — write one draft guide into templates/<app>/.felhom.yml, by TEXT, and prove the parse.
python3 apply_guide.py <catalog-root> <draft.json> [--hidden]
The draft is {"app", "checked_version", "hu": {"sections", "faq"}, "en": {"sections", "faq"}}. The Hungarian guide
goes at the END of the top-level `app_info:` block (two spaces), the English one at the end of `i18n.en.app_info`
(six spaces). An existing guide block is replaced, so a morning format change is one more run. Every string is
written JSON-quoted (valid YAML double-quoted: Hungarian quotes, apostrophes and colons cannot break it — the
„Hungarian-quote YAML kill" trap).
`--hidden` writes `checked_version: ""` (a label was not found: the box does not show the guide).
PROOF, after writing: the file parses; `app_info.guide` and `i18n.en.app_info.guide` equal the draft; and the
WHOLE document minus those two keys equals the document before. Any difference restores the old file and exits 1.
"""
import copy
import io
import json
import sys
import yaml
def q(s):
return json.dumps(s, ensure_ascii=False)
def block(g, ind, cv=None):
p = " " * ind
out = [p + "guide:"]
if cv is not None:
out.append(p + " checked_version: " + q(cv))
out.append(p + " sections:")
for se in g["sections"]:
out.append(p + " - title: " + q(se["title"]))
if se.get("intro"):
out.append(p + " intro: " + q(se["intro"]))
out.append(p + " steps:")
for st in se["steps"]:
out.append(p + " - " + q(st))
if g.get("faq"):
out.append(p + " faq:")
for qa in g["faq"]:
out.append(p + " - q: " + q(qa["q"]))
out.append(p + " a: " + q(qa["a"]))
return out
def indent(line):
return len(line) - len(line.lstrip(" "))
def is_content(line):
s = line.strip()
return s != "" and not s.startswith("#")
def span(lines, start, ind):
"""Index just past the last CONTENT line belonging to the block whose header is lines[start] (children are
indented deeper than `ind`). Trailing blank and comment lines stay outside, with whatever follows."""
end = start + 1
last = start
i = start + 1
while i < len(lines):
if is_content(lines[i]):
if indent(lines[i]) <= ind:
break
last = i
i += 1
return last + 1
def remove_guide(lines, parent, ind):
"""Remove a `guide:` child (at indent `ind`) of the block headed at lines[parent]."""
end = span(lines, parent, ind - 2)
for i in range(parent + 1, end):
if lines[i] == " " * ind + "guide:":
j = span(lines, i, ind)
del lines[i:j]
return True
return False
def find_line(lines, text, after=0, before=None):
for i in range(after, before if before is not None else len(lines)):
if lines[i] == text:
return i
return -1
def strip_guides(doc):
d = copy.deepcopy(doc)
(d.get("app_info") or {}).pop("guide", None)
en = ((d.get("i18n") or {}).get("en") or {})
(en.get("app_info") or {}).pop("guide", None)
return d
def main(argv):
root, draft_path = argv[1], argv[2]
hidden = "--hidden" in argv
d = json.load(io.open(draft_path, encoding="utf-8"))
app = d["app"]
path = "%s/templates/%s/.felhom.yml" % (root, app)
old_text = io.open(path, encoding="utf-8").read()
old_doc = yaml.safe_load(old_text)
lines = old_text.split("\n")
# Hungarian: top-level app_info
ai = find_line(lines, "app_info:")
if ai < 0:
print("FAIL %s: no top-level app_info:" % app)
return 1
remove_guide(lines, ai, 2)
end = span(lines, ai, 0)
cv = "" if hidden else d["checked_version"]
lines[end:end] = block(d["hu"], 2, cv)
# English: i18n -> en -> app_info
i18 = find_line(lines, "i18n:")
en = find_line(lines, " en:", i18 + 1) if i18 >= 0 else -1
if en < 0:
print("FAIL %s: no i18n.en block — every app is translated; refusing to invent one" % app)
return 1
en_end = span(lines, en, 2)
eai = find_line(lines, " app_info:", en + 1, en_end)
if eai < 0:
lines[en_end:en_end] = [" app_info:"]
eai = en_end
remove_guide(lines, eai, 6)
e_end = span(lines, eai, 4)
lines[e_end:e_end] = block(d["en"], 6)
new_text = "\n".join(lines)
new_doc = yaml.safe_load(new_text)
want_hu = {"checked_version": cv, "sections": d["hu"]["sections"], "faq": d["hu"]["faq"]}
want_en = {"sections": d["en"]["sections"], "faq": d["en"]["faq"]}
def norm(g): # the writer omits an empty intro; compare like for like
g = copy.deepcopy(g)
for se in g.get("sections") or []:
if not se.get("intro"):
se.pop("intro", None)
return g
problems = []
if norm(new_doc["app_info"].get("guide")) != norm(want_hu):
problems.append("Hungarian guide does not read back as the draft")
if norm(new_doc["i18n"]["en"]["app_info"].get("guide")) != norm(want_en):
problems.append("English guide does not read back as the draft")
if strip_guides(new_doc) != strip_guides(old_doc):
problems.append("the rest of the document CHANGED")
if problems:
print("FAIL %s: %s — file left as it was" % (app, "; ".join(problems)))
return 1
with io.open(path, "w", encoding="utf-8") as fh:
fh.write(new_text)
print("OK %s: guide written (%d sections, %d faq; checked_version %r)"
% (app, len(d["hu"]["sections"]), len(d["hu"]["faq"]), cv))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))