Message Notes -> ProPresenter tool (v1.1)
Turns the pastor's sermon notes into the message presentation in ProPresenter's Messages library, built from the series or generic template. - Notes (RTF/Word) are parsed into title, scriptures (with bold emphasis), points, builds, quotes, picture slides and the Sermon in a Sentence; team instructions are left off and shown in the summary - Slides are cloned from the template with fresh ids and the template's own formatting; a dependency-free protobuf codec reads/writes .pro files - Text is checked to fit on the slide and in the stream and LED wall theme boxes; long scriptures split at verse numbers, font sizes never change - Links the presentation into the Planning Center playlist's Message section (restarting ProPresenter safely, never while anything is live) - Picks the newest notes from the email inbox folder (Google Apps Script), reads the speaker from the email, and writes the YouTube title and description with the Claude API for pasting into Restream - Offline tests rebuild last Sunday's deck from its notes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
"""Offline checks (no ProPresenter, no fonts needed). Run: python3 tests/run_tests.py"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.join(HERE, "..", "src"))
|
||||
import deck # noqa: E402
|
||||
import notes # noqa: E402
|
||||
import pbwire # noqa: E402
|
||||
import plantext # noqa: E402
|
||||
import rtf # noqa: E402
|
||||
|
||||
NOTES = os.path.join(HERE, "notes")
|
||||
PRO = os.path.join(HERE, "pro")
|
||||
SERIES = os.path.join(PRO, "_TEMPLATE - DO NOT DELETE 2608 Lower Thirds.pro")
|
||||
GENERIC = os.path.join(PRO, "_TEMPLATE_GENERIC - DO NOT DELETE 2609.pro")
|
||||
failures = []
|
||||
|
||||
|
||||
def check(name, cond, detail=""):
|
||||
print("%-6s %s%s" % ("ok" if cond else "FAIL", name, (" -- " + detail) if (detail and not cond) else ""))
|
||||
if not cond:
|
||||
failures.append(name)
|
||||
|
||||
|
||||
def slides_of(data):
|
||||
p = deck.C.decode(data, "rv.data.Presentation")
|
||||
cues = {deck.uuid_str(deck.C.get(c, "uuid")): c for c in deck.C.all(p, "cues")}
|
||||
out = []
|
||||
for g in deck.C.all(p, "cue_groups"):
|
||||
for cid in deck.C.all(g, "cue_identifiers"):
|
||||
a = deck.slide_action(cues[deck.uuid_str(cid)])
|
||||
texts = []
|
||||
for el in deck.slide_elements(a):
|
||||
t = deck.element_text(el)
|
||||
d = deck.C.get(t, "rtf_data") if t is not None else None
|
||||
if d:
|
||||
s = " / ".join(p_.text for p_ in rtf.read(d).paras).strip()
|
||||
if s:
|
||||
texts.append((deck.element_name(el), s))
|
||||
out.append((deck.action_label(a), texts))
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
# 1. every .pro file survives decode/encode byte for byte
|
||||
for f in sorted(os.listdir(PRO)):
|
||||
if f.endswith(".pro"):
|
||||
b = open(os.path.join(PRO, f), "rb").read()
|
||||
check("roundtrip %s" % f, deck.C.encode(deck.C.decode(b, "rv.data.Presentation")) == b)
|
||||
|
||||
# 2. every notes file parses; the slide list survives the TextEdit format
|
||||
for f in sorted(os.listdir(NOTES)):
|
||||
try:
|
||||
p = notes.parse(open(os.path.join(NOTES, f), "rb").read())
|
||||
p2 = plantext.load(plantext.dump(p, f))
|
||||
same = [(s.kind, s.ref, [(l.text, l.lead) for l in s.lines]) for s in p.slides] == \
|
||||
[(s.kind, s.ref, [(l.text, l.lead) for l in s.lines]) for s in p2.slides]
|
||||
check("notes %s (%d slides)" % (f, len(p.slides)), same and p.title_slide is not None)
|
||||
except Exception:
|
||||
check("notes %s" % f, False, traceback.format_exc().splitlines()[-1])
|
||||
|
||||
# 3. Part 7 from its notes matches the hand-made Part 7 (apart from the known differences)
|
||||
tpl = deck.Template(open(SERIES, "rb").read())
|
||||
plan = notes.parse(open(os.path.join(NOTES, "Slides - Where Is Your Faith - Part 7.rtf"), "rb").read())
|
||||
data = deck.build(tpl, plan, "TEST", ("LOCATION PASTOR", "Mehdi Chniouli"))
|
||||
gen = slides_of(data)
|
||||
hand = slides_of(open(os.path.join(PRO, "261011 Where Is Your Faith Part 7.pro"), "rb").read())
|
||||
check("part 7 slide count", len(gen) == len(hand), "%d vs %d" % (len(gen), len(hand)))
|
||||
check("part 7 labels", [g[0] for g in gen] == [h[0] for h in hand])
|
||||
norm = lambda s: re.sub(r"\s+", " ", s.replace(" ", " ")).strip()
|
||||
diffs = []
|
||||
for k, (g, h) in enumerate(zip(gen, hand), 1):
|
||||
if [(n, norm(t)) for n, t in g[1]] != [(n, norm(t)) for n, t in h[1]]:
|
||||
diffs.append(k)
|
||||
# 17: the operator added a manual line break ("can't follow / me THROUGH")
|
||||
check("part 7 texts identical except slide 17", diffs == [17], "differs on %s" % diffs)
|
||||
|
||||
# 4. both templates build without text measurement, and the result decodes
|
||||
for path in (SERIES, GENERIC):
|
||||
t = deck.Template(open(path, "rb").read())
|
||||
for f in ("Slides - 7 Letters - Part 2.rtf", "Slides - May 2026 Forge Night.rtf"):
|
||||
p = notes.parse(open(os.path.join(NOTES, f), "rb").read())
|
||||
d = deck.build(t, p, "TEST", t.speaker())
|
||||
out = slides_of(d)
|
||||
n_build = sum(1 for s in p.slides if s.kind == "build")
|
||||
want = len(t.head) + sum(1 for s in p.slides if s.kind != "title") + n_build + len(t.tail)
|
||||
check("build %s + %s" % (os.path.basename(path)[:22], f), len(out) == want, "%d vs %d" % (len(out), want))
|
||||
|
||||
# 5. fresh ids everywhere, references to shared things untouched
|
||||
tu = set(re.findall(rb"[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}", open(SERIES, "rb").read()))
|
||||
gu = set(re.findall(rb"[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}", data))
|
||||
shared = tu & gu
|
||||
check("only shared references reused (%d)" % len(shared), all(
|
||||
x.decode() in open(SERIES, "rb").read().decode("latin-1") for x in shared) and len(shared) <= 12)
|
||||
|
||||
print()
|
||||
print("FAILED: %s" % failures if failures else "all checks passed")
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user