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,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
|
||||
@@ -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
@@ -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
|
||||
Executable
+17
@@ -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\"'"
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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()))
|
||||
Reference in New Issue
Block a user