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:
2026-10-13 15:58:03 -04:00
co-authored by Claude Opus 5.5
commit 7122fd144d
63 changed files with 8984 additions and 0 deletions
@@ -0,0 +1,34 @@
#!/bin/bash
#==============================================================================
# MESSAGE NOTES -> PROPRESENTER
#
# Turns the pastor's notes ("Slides - <message>.rtf", or a Word file) into the
# message presentation, built from the series or generic template in the
# Messages library - the same slides you'd make by copy and paste.
#
# Double-click, then answer the questions:
# 1. which notes file (newest ones are listed; or drag a file in)
# 2. which template (series / generic - it suggests one)
# 3. who is speaking (recent speakers are listed)
# 4. date + presentation name (e.g. "261018 Where Is Your Faith Part 8")
# Then every slide is listed for review. Nothing is written until you press
# Enter at "Create this presentation?". Type q at any prompt to quit.
#
# Afterwards it checks the new presentation through ProPresenter's API and
# opens a page of slide previews drawn by ProPresenter itself.
#
# Program: ~/Library/Application Support/MessageNotes/message-notes.pyz
# Log: ~/Library/Logs/message-notes.log
#==============================================================================
APP="$HOME/Library/Application Support/MessageNotes/message-notes.pyz"
clear
if [ ! -f "$APP" ]; then
echo "Can't find $APP"
read -r -p "Press Enter to close." _
exit 1
fi
/usr/bin/python3 "$APP" "$@"
status=$?
echo
read -r -p "Press Enter to close this window." _
exit $status
+9
View File
@@ -0,0 +1,9 @@
"""Rewrite .py files so they are pure ASCII (non-ASCII characters become \\u / \\U escapes)."""
import glob, sys
for f in sys.argv[1:] or glob.glob("*.py"):
s = open(f, encoding="utf-8").read()
if all(ord(c) < 128 for c in s):
continue
out = "".join(c if ord(c) < 128 else ("\\u%04x" % ord(c) if ord(c) <= 0xFFFF else "\\U%08x" % ord(c)) for c in s)
open(f, "w", encoding="ascii").write(out)
print("escaped", f)
Executable
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# Build dist/message-notes.pyz (zipapp) and copy the Desktop launcher next to it.
set -euo pipefail
cd "$(dirname "$0")/.."
rm -rf dist build/pyz && mkdir -p dist build/pyz
cp src/*.py src/schema.json apps-script/SaveSermonNotes.gs build/pyz/
rm -rf build/pyz/__pycache__
/usr/bin/python3 -m zipapp build/pyz -o dist/message-notes.pyz -p "/usr/bin/env python3"
cp "tools/Message Notes to ProPresenter.command" dist/
chmod +x "dist/Message Notes to ProPresenter.command"
rm -rf build
ls -l dist
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# Build and install on the ProPresenter Mac (via the ingest Mac, which holds the SSH key).
# app -> ~/Library/Application Support/MessageNotes/message-notes.pyz
# launcher -> ~/Desktop/Message Notes to ProPresenter.command
set -euo pipefail
cd "$(dirname "$0")/.."
tools/build.sh >/dev/null
HOP=checklist-ingest
PROP=napropresenter@10.1.14.225
scp -q dist/message-notes.pyz "dist/Message Notes to ProPresenter.command" "$HOP:/tmp/"
ssh "$HOP" "scp -q -i ~/.ssh/id_status_panel /tmp/message-notes.pyz '/tmp/Message Notes to ProPresenter.command' $PROP:/tmp/ \
&& rm -f /tmp/message-notes.pyz '/tmp/Message Notes to ProPresenter.command' \
&& ssh -i ~/.ssh/id_status_panel $PROP 'set -e; D=\"\$HOME/Library/Application Support/MessageNotes\"; mkdir -p \"\$D\"; \
mv /tmp/message-notes.pyz \"\$D/message-notes.pyz\"; \
mv \"/tmp/Message Notes to ProPresenter.command\" \"\$HOME/Desktop/Message Notes to ProPresenter.command\"; \
chmod +x \"\$HOME/Desktop/Message Notes to ProPresenter.command\"; \
/usr/bin/python3 \"\$D/message-notes.pyz\" --version; ls -l \"\$D/message-notes.pyz\" \"\$HOME/Desktop/Message Notes to ProPresenter.command\"'"
+164
View File
@@ -0,0 +1,164 @@
"""Read .proto files as text and build a field table: {message: {num: [name, type, repeated]}}.
Types are either a scalar keyword ('string', 'bytes', 'double', 'int32', 'bool', ...),
'enum', or the fully qualified name of a message ('rv.data.UUID').
"""
import json
import os
import re
import sys
SCALARS = {"double", "float", "int32", "int64", "uint32", "uint64", "sint32", "sint64",
"fixed32", "fixed64", "sfixed32", "sfixed64", "bool", "string", "bytes"}
def tokenize(src):
src = re.sub(r"//[^\n]*", "", src)
src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
return re.findall(r'"(?:[^"\\]|\\.)*"|[A-Za-z_][\w.]*|\.[A-Za-z_][\w.]*|-?\d+(?:\.\d+)?|[{}\[\]();=<>,]', src)
def parse_file(path, messages, enums):
toks = tokenize(open(path).read())
pkg = ""
i = 0
scope = [] # stack of (kind, fqname)
pending = [] # fields with unresolved types: (msg_fq, num, name, rawtype, repeated, scope_names)
def cur_msg():
for kind, fq in reversed(scope):
if kind == "message":
return fq
return None
while i < len(toks):
t = toks[i]
if t == "package":
pkg = toks[i + 1]
i += 3
continue
if t in ("syntax", "import", "option"):
while toks[i] != ";":
i += 1
i += 1
continue
if t in ("message", "enum", "oneof", "service"):
name = toks[i + 1]
parent = scope[-1][1] if scope else pkg
fq = parent + "." + name if parent else name
if t == "oneof":
fq = scope[-1][1] # fields belong to the enclosing message
scope.append((t, fq))
if t == "message":
messages.setdefault(fq, {})
elif t == "enum":
enums.add(fq)
i += 3 # name {
continue
if t == "}":
scope.pop()
i += 1
continue
if t == "reserved" or t == "extensions":
while toks[i] != ";":
i += 1
i += 1
continue
if scope and scope[-1][0] == "enum":
while toks[i] != ";":
i += 1
i += 1
continue
if scope and scope[-1][0] in ("message", "oneof"):
repeated = False
if t in ("repeated", "optional", "required"):
repeated = t == "repeated"
i += 1
t = toks[i]
if t == "map":
# map<k, v> name = n;
j = toks.index(";", i)
name = toks[j - 3]
num = int(toks[j - 1]) if toks[j - 1] != "]" else None
vt = toks[i + 4]
pending.append((cur_msg(), int(toks[toks.index("=", i) + 1]), name, "map:" + vt, True,
[fq for _, fq in scope]))
i = j + 1
continue
rawtype = t
name = toks[i + 1]
num = int(toks[i + 3])
j = i + 4
if toks[j] == "[":
while toks[j] != "]":
j += 1
j += 1
pending.append((cur_msg(), num, name, rawtype, repeated, [fq for _, fq in scope]))
i = j + 1
continue
i += 1
return pending
def resolve(rawtype, scopes, messages, enums):
if rawtype in SCALARS:
return rawtype
if rawtype.startswith("."):
cand = rawtype[1:]
if cand in messages:
return cand
if cand in enums:
return "enum"
raise KeyError(rawtype)
# search enclosing scopes from innermost outwards
for sc in reversed(scopes):
parts = sc.split(".")
for k in range(len(parts), -1, -1):
cand = ".".join(parts[:k] + [rawtype]) if k else rawtype
if cand in messages:
return cand
if cand in enums:
return "enum"
raise KeyError(rawtype)
def build(proto_dir):
messages, enums, pending = {}, set(), []
for fn in sorted(os.listdir(proto_dir)):
if fn.endswith(".proto"):
pending += parse_file(os.path.join(proto_dir, fn), messages, enums)
for gdir in ("google/protobuf",):
d = os.path.join(proto_dir, gdir)
if os.path.isdir(d):
for fn in sorted(os.listdir(d)):
if fn.endswith(".proto"):
pending += parse_file(os.path.join(d, fn), messages, enums)
for msg, num, name, rawtype, rep, scopes in pending:
if rawtype.startswith("map:"):
messages[msg][num] = [name, "map", True]
continue
messages[msg][num] = [name, resolve(rawtype, scopes, messages, enums), rep]
return messages
def reachable(messages, root):
out, todo = {}, [root]
while todo:
m = todo.pop()
if m in out:
continue
out[m] = messages[m]
for name, typ, rep in messages[m].values():
if typ in messages and typ not in out:
todo.append(typ)
return out
if __name__ == "__main__":
msgs = build(sys.argv[1])
sub = reachable(msgs, "rv.data.Presentation")
sub.update(reachable(msgs, "rv.data.Template.Document"))
sub.update(reachable(msgs, "rv.data.PlaylistDocument"))
json.dump({k: {str(n): v for n, v in f.items()} for k, f in sorted(sub.items())},
open(sys.argv[2], "w"), separators=(",", ":"), sort_keys=True)
print(len(msgs), "messages parsed;", len(sub), "reachable from Presentation + Template.Document")
+23
View File
@@ -0,0 +1,23 @@
"""Dev helper: run measure.JXA on the ProPresenter Mac (via the ingest Mac) from a laptop."""
import json, os, subprocess, sys, tempfile
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src"))
import measure as M
HOP = "checklist-ingest"
PROP = "napropresenter@10.1.14.225"
KEY = "~/.ssh/id_status_panel"
def measure(items):
if not items:
return []
payload = json.dumps([{"rtf": it["rtf"].decode("latin-1"), "w": it["w"]} for it in items])
script = M.JXA
remote = ("d=$(mktemp -d); cat > $d/in.json; printf '%%s' %s | base64 -D > $d/m.js; "
"osascript -l JavaScript $d/m.js $d/in.json; rm -rf $d") % (
__import__("shlex").quote(__import__("base64").b64encode(script.encode()).decode()))
cmd = ["ssh", HOP, "ssh -i %s -o BatchMode=yes %s %s" % (KEY, PROP, __import__("shlex").quote(remote))]
p = subprocess.run(cmd, input=payload, capture_output=True, text=True, timeout=300)
if p.returncode != 0 or not p.stdout.strip():
raise M.Unavailable(p.stderr[-500:])
return json.loads(p.stdout)
+46
View File
@@ -0,0 +1,46 @@
"""One-off check on the ProPresenter Mac: link a message into a playlist via a restart.
Usage (on the ProP Mac):
/usr/bin/python3 test_restart_link.py <message-notes.pyz> "<notes title>" YYYY-MM-DD "<presentation name>"
Refuses to run if anything is on screen. Prints whether the link is in ProPresenter's
playlist file after ProPresenter is running again (and still there 60 s later).
"""
import datetime
import os
import sys
import time
sys.path.insert(0, sys.argv[1])
import app # noqa: E402
import playlist # noqa: E402
import ppctl # noqa: E402
title, day, name = sys.argv[2], sys.argv[3], sys.argv[4]
date = datetime.date(*map(int, day.split("-")))
library = app.find_library()
pro = os.path.join(library, name + ".pro")
assert os.path.isfile(pro), pro
why = ppctl.busy_reason(app.API)
if why:
print("NOT RUN: %s" % why)
sys.exit(1)
def linked():
_, doc = playlist.load(playlist.playlist_file(library))
d, pl = playlist.find_service(doc, title)
if pl is None:
return "no playlist"
h, k = playlist.message_slot(pl)
it = playlist.items_of(pl)[k] if k is not None else None
return playlist.linked_path(it) if it is not None else "no slot"
print("before:", repr(linked()))
t0 = time.time()
print(ppctl.restart_with(lambda: app.link_playlist(library, title, date, name, pro), app.API, print))
print("ProPresenter back after %.0fs, running: %s" % (time.time() - t0, ppctl.app_path(ppctl.pid()) if ppctl.pid() else None))
print("after:", repr(linked()))
time.sleep(60)
print("after 60s:", repr(linked()))