Campus kit for other ProPresenter Macs (v1.3.0)

- settings.py: per-Mac settings.json (campus code, youtube); ProPresenter
  folder, network port and template library read from ProPresenter itself
- First run / --setup / y asks the campus; YouTube text only at NA
- Preacher picked for this campus from the notes email; a lone surname
  ("Pastor Geyser") matches the most recent speaker with it
- Never restart ProPresenter when its network API is off or not answering
- tools/make_kit.sh builds "Message Notes Kit <version>.zip" (installer,
  program, launcher, READ ME FIRST); deploy.sh writes NA's settings
- Email setup text no longer names media@

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 12:57:44 -04:00
co-authored by Claude Opus 5.5
parent 1c30753743
commit d7eee1bc05
10 changed files with 382 additions and 50 deletions
+18 -2
View File
@@ -54,6 +54,21 @@ speaker / name / date / notes file · `v` view the description · `y` setup · `
PICTURE / CLEAR PICTURE slides are always kept. When the notes mark a picture ("[PICTURE: …]", PICTURE / CLEAR PICTURE slides are always kept. When the notes mark a picture ("[PICTURE: …]",
"<pic - ….jpg>"), they go there with the picture named in the slide notes. "<pic - ….jpg>"), they go there with the picture named in the slide notes.
## Other campuses
`tools/make_kit.sh` builds `dist/Message Notes Kit <version>.zip`: an installer, the program, the
Desktop launcher and `READ ME FIRST.txt`. Send the zip to the campus; on their ProPresenter Mac
they unzip it and double-click `Install Message Notes.command` (it installs Apple's Command Line
Tools first if Python is missing). The first run asks which campus it is and saves it in
`~/Library/Application Support/MessageNotes/settings.json`:
- The campus code picks the preacher from the notes email ("Pastor Geyser will be preaching live
in AB"); a lone surname matches the most recent speaker with it.
- YouTube text is only written at NA (`"youtube": true`); `tools/deploy.sh` writes NA's settings.
- The ProPresenter folder, network port and template library are read from ProPresenter itself.
Templates are any `_TEMPLATE…DO NOT DELETE….pro` (with GENERIC in the name = generic).
- Without ProPresenter's network on, it never restarts ProPresenter to link the playlist.
## How notes become slides ## How notes become slides
| In the notes | On the slides | | In the notes | On the slides |
@@ -96,10 +111,11 @@ from the rest of their box keep bold/italic.
- `youtube.py` writes the YouTube text (Claude API over HTTPS); `secrets_store.py` keeps the key in the Keychain - `youtube.py` writes the YouTube text (Claude API over HTTPS); `secrets_store.py` keeps the key in the Keychain
- `spelling.py` is the typo check (macOS spelling checker via JXA) - `spelling.py` is the typo check (macOS spelling checker via JXA)
- `docxnum.py` keeps Word list numbers - `docxnum.py` keeps Word list numbers
- `app.py` is the Terminal app - `app.py` is the Terminal app; `settings.py` is the per-Mac campus settings
- `apps-script/SaveSermonNotes.gs` is the Google Apps Script for email pickup. - `apps-script/SaveSermonNotes.gs` is the Google Apps Script for email pickup.
- `tools/build.sh` builds `dist/message-notes.pyz` and the launcher. `tools/deploy.sh` builds - `tools/build.sh` builds `dist/message-notes.pyz` and the launcher. `tools/deploy.sh` builds
and installs both on the ProPresenter Mac, via the ingest Mac's SSH key. and installs both on the NA ProPresenter Mac, via the ingest Mac's SSH key. `tools/make_kit.sh`
builds the kit for other campuses (`tools/kit/` holds its installer and read-me).
- `tools/gen_schema.py` regenerates `schema.json` from the community ProPresenter `.proto` files - `tools/gen_schema.py` regenerates `schema.json` from the community ProPresenter `.proto` files
(github.com/greyshirtguy/ProPresenter7-Proto, `autogen-proto`) after a ProPresenter upgrade. (github.com/greyshirtguy/ProPresenter7-Proto, `autogen-proto`) after a ProPresenter upgrade.
- `tests/run_tests.py` runs offline checks against real notes and presentations. Last Sunday's - `tests/run_tests.py` runs offline checks against real notes and presentations. Last Sunday's
+66 -32
View File
@@ -28,19 +28,17 @@ import notes as notesmod
import plantext import plantext
import playlist as playlist_mod import playlist as playlist_mod
import ppctl import ppctl
import settings
import spelling import spelling
import themes import themes
import youtube import youtube
VERSION = "1.2.0" VERSION = "1.3.0"
LIBRARY_CANDIDATES = ["/Volumes/MEDIA/ProPresenter/Libraries/Messages", API = None # ProPresenter's network API on this Mac - set in run() from its preferences
"~/Documents/ProPresenter/Libraries/Messages"]
API = "http://127.0.0.1:1025"
SUPPORT = os.path.expanduser("~/Library/Application Support/MessageNotes") SUPPORT = os.path.expanduser("~/Library/Application Support/MessageNotes")
LOG = os.path.expanduser("~/Library/Logs/message-notes.log") LOG = os.path.expanduser("~/Library/Logs/message-notes.log")
# where the email script saves the notes (checked first), then the usual places # where the email script saves the notes (checked first), then the usual places
NOTES_INBOX = ["~/Library/CloudStorage/GoogleDrive-media@waterschurch.org/My Drive/Message Notes", NOTES_INBOX = ["~/Library/CloudStorage/GoogleDrive-*/My Drive/Message Notes"]
"~/Library/CloudStorage/GoogleDrive-christian@waterschurch.org/My Drive/Message Notes"]
NOTE_DIRS = ["~/Downloads", "~/Desktop", "~/Documents", NOTE_DIRS = ["~/Downloads", "~/Desktop", "~/Documents",
"~/Library/Mobile Documents/com~apple~TextEdit/Documents", "~/Library/Mobile Documents/com~apple~TextEdit/Documents",
"~/Library/CloudStorage/*/My Drive"] "~/Library/CloudStorage/*/My Drive"]
@@ -112,21 +110,11 @@ def short(text, n):
# ----------------------------------------------------------------------------- environment # ----------------------------------------------------------------------------- environment
def find_library(explicit=None): def find_library(explicit=None):
for c in ([explicit] if explicit else LIBRARY_CANDIDATES): return settings.find_library(explicit=explicit)
p = os.path.expanduser(c)
if os.path.isdir(p):
return p
for p in glob.glob("/Volumes/*/ProPresenter/Libraries/Messages"):
return p
return None
def find_templates(library): def find_templates(library):
out = [] out = [os.path.join(library, f) for f in sorted(os.listdir(library)) if settings.is_template(f)]
for f in sorted(os.listdir(library)):
if f.lower().endswith(".pro") and not f.startswith("._") and re.match(r"^_?template", f, re.I) \
and "do not delete" in f.lower():
out.append(os.path.join(library, f))
series = sorted([p for p in out if "generic" not in os.path.basename(p).lower()], key=os.path.getmtime, reverse=True) series = sorted([p for p in out if "generic" not in os.path.basename(p).lower()], key=os.path.getmtime, reverse=True)
generic = sorted([p for p in out if "generic" in os.path.basename(p).lower()], key=os.path.getmtime, reverse=True) generic = sorted([p for p in out if "generic" in os.path.basename(p).lower()], key=os.path.getmtime, reverse=True)
return series, generic return series, generic
@@ -240,8 +228,10 @@ def speaker_from_email(text, known):
last = "" last = ""
if not SPEAK_RE.search(rest): if not SPEAK_RE.search(rest):
continue continue
here = re.search(r"\b(NA|North Attleboro|all locations|all other)\b", rest, re.I) is not None words = settings.here_words()
score = 2 if here else 1 here = bool(words) and re.search(r"\b(%s)\b" % "|".join(re.escape(w) for w in words), rest) is not None
everyone = re.search(r"\b(all locations|all other|all campuses)\b", rest, re.I) is not None
score = 3 if here else 2 if everyone else 1
if best is None or score > best[0]: if best is None or score > best[0]:
best = (score, first, last) best = (score, first, last)
if not best: if not best:
@@ -251,6 +241,11 @@ def speaker_from_email(text, known):
parts = name.split() parts = name.split()
if parts and parts[0].lower() == first.lower() and (not last or last.lower() in name.lower()): if parts and parts[0].lower() == first.lower() and (not last or last.lower() in name.lower()):
return role, name return role, name
if not last: # "Pastor Geyser": a surname - the most recent speaker with it
for role, name in known:
parts = name.split()
if len(parts) > 1 and parts[-1].lower() == first.lower():
return role, name
return ("PASTOR", ("%s %s" % (first, last)).strip().upper()) return ("PASTOR", ("%s %s" % (first, last)).strip().upper())
@@ -267,6 +262,8 @@ def email_date(notes_path):
def api_json(path, timeout=5): def api_json(path, timeout=5):
if not API:
raise OSError("ProPresenter's network API is off")
with urllib.request.urlopen(API + path, timeout=timeout) as r: with urllib.request.urlopen(API + path, timeout=timeout) as r:
return json.loads(r.read().decode("utf-8")) return json.loads(r.read().decode("utf-8"))
@@ -371,6 +368,9 @@ def edit_in_textedit(plan, name):
def find_outputs(tpl, library): def find_outputs(tpl, library):
themes_dir = os.path.join(os.path.dirname(os.path.dirname(library)), "Themes") themes_dir = os.path.join(os.path.dirname(os.path.dirname(library)), "Themes")
if not API:
return [], ("ProPresenter's network API is off, so text was only checked on the slide itself "
"(turn it on in ProPresenter > Settings > Network)")
try: try:
return themes.outputs_for(tpl, API, themes_dir), None return themes.outputs_for(tpl, API, themes_dir), None
except (OSError, ValueError, KeyError) as e: except (OSError, ValueError, KeyError) as e:
@@ -474,12 +474,38 @@ class YouTubeJob:
return self.result return self.result
def setup_youtube(): def setup():
say("") say("")
if not youtube.secrets_store.get(youtube.KEY_SERVICE, "api-key"): say("%sSetup for this ProPresenter Mac%s" % (B, R))
cur = settings.load()
pp = settings.propresenter_dir()
lib = settings.find_library(pp)
say(" ProPresenter folder: %s" % (pp or "%snot found - is the ProPresenter drive connected?%s" % (RED, R)))
say(" Message templates in: %s" % (lib or "%snot found%s" % (RED, R)))
api = settings.api_base()
say(" ProPresenter network: %s" % (api or "%soff - turn it on in ProPresenter > Settings > Network%s" % (YEL, R)))
codes = list(settings.CAMPUSES)
say("")
say(" Which campus is this?")
for k, c in enumerate(codes, 1):
say(" %d) %s %s" % (k, c, settings.CAMPUSES[c][1]))
default = str(codes.index(cur["campus"]) + 1) if cur.get("campus") in codes else ""
while True:
a = ask(" Campus (number, or type a code)", default).strip()
if a.isdigit() and 1 <= int(a) <= len(codes):
cur["campus"] = codes[int(a) - 1]
break
if re.fullmatch(r"[A-Za-z]{2,4}", a or ""):
cur["campus"] = a.upper()
break
cur["youtube"] = cur["campus"] == settings.STREAMING_CAMPUS
settings.save(cur)
say(" Saved: %s%s" % (cur["campus"], " (writes the YouTube title and description)" if cur["youtube"] else ""))
if cur["youtube"] and not youtube.secrets_store.get(youtube.KEY_SERVICE, "api-key"):
say("")
say("Claude API key (from https://console.anthropic.com/settings/keys) - it goes straight into the Keychain:") say("Claude API key (from https://console.anthropic.com/settings/keys) - it goes straight into the Keychain:")
youtube.secrets_store.prompt_put(youtube.KEY_SERVICE, "api-key") youtube.secrets_store.prompt_put(youtube.KEY_SERVICE, "api-key")
if ask("Set up picking the notes up from email now? (Enter = yes, s = skip)", "").lower() != "s": if ask("Set up picking the notes up from email now? (Enter = yes, s = skip - you can do it later with y)", "").lower() != "s":
import pkgutil import pkgutil
gs = os.path.join(SUPPORT, "SaveSermonNotes.gs") gs = os.path.join(SUPPORT, "SaveSermonNotes.gs")
os.makedirs(SUPPORT, exist_ok=True) os.makedirs(SUPPORT, exist_ok=True)
@@ -490,11 +516,12 @@ def setup_youtube():
subprocess.run(["/usr/bin/open", "https://script.google.com/create"]) subprocess.run(["/usr/bin/open", "https://script.google.com/create"])
say("") say("")
say(" The script is copied. In the browser page that just opened (Google Apps Script):") say(" The script is copied. In the browser page that just opened (Google Apps Script):")
say(" 1. Check the round picture top right is media@waterschurch.org (switch account if not).") say(" 1. Check the round picture top right is the Google account that gets the Notes/Slides")
say(" emails AND is signed in to Google Drive on this Mac (switch account if not).")
say(" 2. Click in the big code area, press Cmd-A then Cmd-V (replaces the sample code).") say(" 2. Click in the big code area, press Cmd-A then Cmd-V (replaces the sample code).")
say(" 3. Press Cmd-S to save.") say(" 3. Press Cmd-S to save.")
say(" 4. At the top, change the dropdown next to 'Debug' from 'myFunction' to 'install', click Run.") say(" 4. At the top, change the dropdown next to 'Debug' from 'myFunction' to 'install', click Run.")
say(" 5. A box says 'Authorization required': Review permissions > media@ > Allow") say(" 5. A box says 'Authorization required': Review permissions > that account > Allow")
say(" (if it says 'Google hasn't verified this app': Advanced > Go to ... > Allow).") say(" (if it says 'Google hasn't verified this app': Advanced > Go to ... > Allow).")
say(" Done when the bottom panel says 'Execution completed'.") say(" Done when the bottom panel says 'Execution completed'.")
ask(" Press Enter when that's done", "") ask(" Press Enter when that's done", "")
@@ -533,7 +560,7 @@ def main(argv=None):
ap.add_argument("--out", help="write the .pro here instead of the library (testing)") ap.add_argument("--out", help="write the .pro here instead of the library (testing)")
ap.add_argument("--no-playlist", action="store_true", help="don't link into the service playlist") ap.add_argument("--no-playlist", action="store_true", help="don't link into the service playlist")
ap.add_argument("--no-youtube", action="store_true", help="don't write the YouTube title and description") ap.add_argument("--no-youtube", action="store_true", help="don't write the YouTube title and description")
ap.add_argument("--setup", action="store_true", help="set up the Claude API key and email pickup") ap.add_argument("--setup", action="store_true", help="set up this Mac (campus, email pickup, Claude key at NA)")
ap.add_argument("--yes", action="store_true", help="don't ask for confirmation") ap.add_argument("--yes", action="store_true", help="don't ask for confirmation")
ap.add_argument("--version", action="version", version=VERSION) ap.add_argument("--version", action="version", version=VERSION)
args = ap.parse_args(argv) args = ap.parse_args(argv)
@@ -615,12 +642,18 @@ def describe_fix(f):
def run(args): def run(args):
say("%s\u2501\u2501 Message Notes \u2192 ProPresenter %s \u2501\u2501%s" % (B, VERSION, R)) say("%s\u2501\u2501 Message Notes \u2192 ProPresenter %s \u2501\u2501%s" % (B, VERSION, R))
if args.setup: global API
setup_youtube() if args.setup or not os.path.exists(settings.PATH):
return 0 setup()
if args.setup:
return 0
say("")
API = settings.api_base()
if not settings.youtube_enabled():
args.no_youtube = True
library = find_library(args.library) library = find_library(args.library)
if not library: if not library:
raise SystemExit("Can't find the ProPresenter Messages library (is the MEDIA drive connected?).") raise SystemExit("Can't find ProPresenter's message templates (is the ProPresenter drive connected?).")
series, generic = find_templates(library) series, generic = find_templates(library)
if not (series or generic): if not (series or generic):
raise SystemExit("No message templates found (files named '_TEMPLATE ... DO NOT DELETE ...pro').") raise SystemExit("No message templates found (files named '_TEMPLATE ... DO NOT DELETE ...pro').")
@@ -697,7 +730,7 @@ def run(args):
ask("%sPress Enter to go back%s" % (DIM, R), "") ask("%sPress Enter to go back%s" % (DIM, R), "")
continue continue
if a == "y": if a == "y":
setup_youtube() setup()
yt_dirty = True yt_dirty = True
continue continue
if a == "r": if a == "r":
@@ -835,7 +868,8 @@ def show_summary(st, tpl, speaker, speaker_src, name, date, svc_date, svc_name,
for n in notes: for n in notes:
say(" %s\u2022%s %s" % (YEL, R, wrap(re.sub(r"\s+", " ", n), w - 6, 4))) say(" %s\u2022%s %s" % (YEL, R, wrap(re.sub(r"\s+", " ", n), w - 6, 4)))
say("") say("")
say(" %sEnter = do it e = edit slides r = undo typo fixes t/s/n/d/f = change template/speaker/name/date/notes v = view description q = quit%s" % (DIM, R)) say(" %sEnter = do it e = edit slides r = undo typo fixes t/s/n/d/f = change template/speaker/name/date/notes %sy = setup q = quit%s"
% (DIM, "" if args.no_youtube else "v = view description ", R))
def typo_notes(st, tpl): def typo_notes(st, tpl):
+4 -2
View File
@@ -42,6 +42,8 @@ def busy_reason(api, now=None):
now = now or datetime.datetime.now() now = now or datetime.datetime.now()
if now.weekday() == 6 and 6 <= now.hour < 14: if now.weekday() == 6 and 6 <= now.hour < 14:
return "it's Sunday morning" return "it's Sunday morning"
if not api:
return "ProPresenter's network API is off, so the tool can't see what's on screen"
try: try:
layers = _api(api, "/v1/status/layers") or {} layers = _api(api, "/v1/status/layers") or {}
slide = _api(api, "/v1/status/slide") or {} slide = _api(api, "/v1/status/slide") or {}
@@ -54,7 +56,7 @@ def busy_reason(api, now=None):
if t.get("state") in ("running", "overrunning") and "message" in (t.get("id", {}).get("name", "").lower()): if t.get("state") in ("running", "overrunning") and "message" in (t.get("id", {}).get("name", "").lower()):
return "the Message timer is running" return "the Message timer is running"
except (OSError, ValueError): except (OSError, ValueError):
pass return "ProPresenter didn't answer, so the tool can't see what's on screen"
return None return None
@@ -90,6 +92,6 @@ def restart_with(change, api, say=print, quit_timeout=45):
subprocess.run(["/usr/bin/open", "-a", path]) subprocess.run(["/usr/bin/open", "-a", path])
elif p: elif p:
subprocess.run(["/usr/bin/open", "-b", BUNDLE]) subprocess.run(["/usr/bin/open", "-b", BUNDLE])
if p and not wait_api(api): if p and api and not wait_api(api):
say("ProPresenter is starting slowly - give it a moment.") say("ProPresenter is starting slowly - give it a moment.")
return result return result
+113
View File
@@ -0,0 +1,113 @@
"""Per-machine settings, so the same tool works at every campus.
Most things are read from ProPresenter itself (its data folder and network API port, from
its preferences); the campus is asked once and saved in settings.json:
campus - "NA", "AB", ... used to read who is preaching from the notes email
youtube - write the YouTube title/description (only the streaming campus)
library - the ProPresenter library holding the message templates (found automatically)
"""
import glob
import json
import os
import subprocess
SUPPORT = os.path.expanduser("~/Library/Application Support/MessageNotes")
PATH = os.path.join(SUPPORT, "settings.json")
PP_DOMAIN = "com.renewedvision.propresenter"
# campus code -> words that mean "here" in the notes email ("live from NA with RESI to SC and WS",
# "live in AB"); the second one is shown in setup. A campus not listed can type its own code.
CAMPUSES = {
"NA": ["NA", "North Attleboro"],
"AB": ["AB", "Apollo Beach"],
"WS": ["WS", "Woonsocket"],
"SC": ["SC", "Tiverton", "South Coast"],
"NW": ["NW", "Norwood"],
}
STREAMING_CAMPUS = "NA"
def load():
try:
with open(PATH) as f:
return json.load(f)
except (OSError, ValueError):
return {}
def save(d):
os.makedirs(SUPPORT, exist_ok=True)
with open(PATH, "w") as f:
json.dump(d, f, indent=2)
def _pref(key):
p = subprocess.run(["/usr/bin/defaults", "read", PP_DOMAIN, key], capture_output=True, text=True)
return p.stdout.strip() if p.returncode == 0 else None
def propresenter_dir():
"""ProPresenter's data folder (Libraries, Playlists, Themes...)."""
d = load().get("propresenter_dir") or _pref("applicationShowDirectory")
for cand in ([d] if d else []) + [os.path.expanduser("~/Documents/ProPresenter")] + \
sorted(glob.glob("/Volumes/*/ProPresenter")):
if cand and os.path.isdir(os.path.join(cand, "Libraries")):
return cand
return None
def api_base():
"""ProPresenter's network API on this Mac, or None when it's switched off."""
if _pref("networkEnabled") == "0":
return None
port = load().get("api_port") or _pref("networkPort") or "1025"
return "http://127.0.0.1:%s" % port
def is_template(name):
low = name.lower()
return low.endswith(".pro") and not name.startswith("._") and low.lstrip("_").startswith("template") \
and "do not delete" in low
def find_library(pp_dir=None, explicit=None):
"""The library folder with the message templates ('Messages' at every campus so far)."""
if explicit and os.path.isdir(os.path.expanduser(explicit)):
return os.path.expanduser(explicit)
pp_dir = pp_dir or propresenter_dir()
if not pp_dir:
return None
libs = os.path.join(pp_dir, "Libraries")
want = load().get("library")
if want and os.path.isdir(os.path.join(libs, want)):
return os.path.join(libs, want)
with_templates = []
for name in sorted(os.listdir(libs)):
full = os.path.join(libs, name)
if os.path.isdir(full):
try:
if any(is_template(f) for f in os.listdir(full)):
with_templates.append(full)
except OSError:
continue
for full in with_templates:
if os.path.basename(full).lower() == "messages":
return full
if with_templates:
return with_templates[0]
msgs = os.path.join(libs, "Messages")
return msgs if os.path.isdir(msgs) else None
def campus():
return load().get("campus", "")
def youtube_enabled():
d = load()
return d.get("youtube", d.get("campus") == STREAMING_CAMPUS)
def here_words():
c = campus()
return CAMPUSES.get(c, [c] if c else [])
+29
View File
@@ -112,6 +112,35 @@ def main():
except spelling.Unavailable as e: except spelling.Unavailable as e:
print("skip typo checks (%s)" % e) print("skip typo checks (%s)" % e)
# 7. other campuses: their template names, and who preaches there according to the email
import app
import settings
for n, want in (("_TEMPLATE - DO NOT DELETE 2608 Lower Thirds.pro", True),
("_TEMPLATE_GENERIC - DO NOT DELETE 2601.pro", True),
("_RESI MESSAGE TEMPLATE.pro", False), ("._TEMPLATE - DO NOT DELETE 2608.pro", False),
("261011 Where Is Your Faith Part 7.pro", False)):
check("template name %s" % n, settings.is_template(n) == want)
known = [("LOCATION PASTOR", "Mehdi Chniouli"), ("PASTOR", "Test Geyser"), ("LEAD PASTOR", "Tim Hatch"),
("PASTOR", "Other Geyser")]
mail = ("Subject: Notes/Slides - Where Is Your Faith - Part 7\n\nHi all,\nPastor Mehdi will be preaching live "
"from NA with RESI to SC and WS. I have attached the notes/slides. Pastor Geyser will be preaching "
"live in AB. Please have blank notes ready.\n")
real_load = settings.load
try:
for campus, want in (("NA", "Mehdi Chniouli"), ("SC", "Mehdi Chniouli"), ("WS", "Mehdi Chniouli"),
("AB", "Test Geyser")):
settings.load = lambda c=campus: {"campus": c}
got = app.speaker_from_email(mail, known)
check("speaker at %s" % campus, got and got[1] == want, "got %s" % (got,))
settings.load = lambda: {"campus": "AB"}
got = app.speaker_from_email("Pastor Tim will be preaching live from NA with RESI to all other locations.", known)
check("speaker at AB from 'all other locations'", got and got[1] == "Tim Hatch", "got %s" % (got,))
check("youtube only at NA", not settings.youtube_enabled())
settings.load = lambda: {"campus": "NA"}
check("youtube at NA", settings.youtube_enabled())
finally:
settings.load = real_load
print() print()
print("FAILED: %s" % failures if failures else "all checks passed") print("FAILED: %s" % failures if failures else "all checks passed")
return 1 if failures else 0 return 1 if failures else 0
+5 -9
View File
@@ -6,16 +6,12 @@
# message presentation, built from the series or generic template in the # message presentation, built from the series or generic template in the
# Messages library - the same slides you'd make by copy and paste. # Messages library - the same slides you'd make by copy and paste.
# #
# Double-click, then answer the questions: # Double-click. It picks up the newest notes (from the email folder, or
# 1. which notes file (newest ones are listed; or drag a file in) # Downloads/Desktop/Documents), checks them for typos, picks the template and
# 2. which template (series / generic - it suggests one) # speaker, and shows one summary. Enter creates the presentation and links it
# 3. who is speaking (recent speakers are listed) # into the next service's playlist (Message section). Type q at any prompt to quit.
# 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 # First run asks which campus this is (saved). Run with --setup to change it.
# opens a page of slide previews drawn by ProPresenter itself.
# #
# Program: ~/Library/Application Support/MessageNotes/message-notes.pyz # Program: ~/Library/Application Support/MessageNotes/message-notes.pyz
# Log: ~/Library/Logs/message-notes.log # Log: ~/Library/Logs/message-notes.log
+10 -5
View File
@@ -1,17 +1,22 @@
#!/bin/bash #!/bin/bash
# Build and install on the ProPresenter Mac (via the ingest Mac, which holds the SSH key). # Build and install on the North Attleboro ProPresenter Mac (via the ingest Mac, which holds the SSH key).
# app -> ~/Library/Application Support/MessageNotes/message-notes.pyz # app -> ~/Library/Application Support/MessageNotes/message-notes.pyz
# launcher -> ~/Desktop/Message Notes to ProPresenter.command # launcher -> ~/Desktop/Message Notes to ProPresenter.command
# settings -> NA + YouTube, only if that Mac has no settings.json yet (so it never asks there)
# Other campuses: tools/make_kit.sh
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
tools/build.sh >/dev/null tools/build.sh >/dev/null
HOP=checklist-ingest HOP=checklist-ingest
PROP=napropresenter@10.1.14.225 PROP=napropresenter@10.1.14.225
scp -q dist/message-notes.pyz "dist/Message Notes to ProPresenter.command" "$HOP:/tmp/" printf '{\n "campus": "NA",\n "youtube": true\n}\n' > dist/na-settings.json
ssh "$HOP" "scp -q -i ~/.ssh/id_status_panel /tmp/message-notes.pyz '/tmp/Message Notes to ProPresenter.command' $PROP:/tmp/ \ scp -q dist/message-notes.pyz "dist/Message Notes to ProPresenter.command" dist/na-settings.json "$HOP:/tmp/"
&& rm -f /tmp/message-notes.pyz '/tmp/Message Notes to ProPresenter.command' \ rm -f dist/na-settings.json
ssh "$HOP" "scp -q -i ~/.ssh/id_status_panel /tmp/message-notes.pyz '/tmp/Message Notes to ProPresenter.command' /tmp/na-settings.json $PROP:/tmp/ \
&& rm -f /tmp/message-notes.pyz '/tmp/Message Notes to ProPresenter.command' /tmp/na-settings.json \
&& ssh -i ~/.ssh/id_status_panel $PROP 'set -e; D=\"\$HOME/Library/Application Support/MessageNotes\"; mkdir -p \"\$D\"; \ && 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.pyz \"\$D/message-notes.pyz\"; \
mv \"/tmp/Message Notes to ProPresenter.command\" \"\$HOME/Desktop/Message Notes to ProPresenter.command\"; \ mv \"/tmp/Message Notes to ProPresenter.command\" \"\$HOME/Desktop/Message Notes to ProPresenter.command\"; \
chmod +x \"\$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\"'" if [ -f \"\$D/settings.json\" ]; then rm -f /tmp/na-settings.json; else mv /tmp/na-settings.json \"\$D/settings.json\"; fi; \
/usr/bin/python3 \"\$D/message-notes.pyz\" --version; cat \"\$D/settings.json\"; ls -l \"\$D/message-notes.pyz\" \"\$HOME/Desktop/Message Notes to ProPresenter.command\"'"
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
#==============================================================================
# INSTALL MESSAGE NOTES -> PROPRESENTER
# Run this once on the ProPresenter Mac. It puts the program in
# ~/Library/Application Support/MessageNotes, puts "Message Notes to
# ProPresenter" on the Desktop, and asks a few setup questions.
# Running it again updates the program and keeps the settings.
#==============================================================================
cd "$(dirname "$0")" || exit 1
clear
echo "Installing Message Notes -> ProPresenter"
echo
finish() { echo; read -r -p "Press Enter to close this window." _; exit "$1"; }
if [ ! -f message-notes.pyz ] || [ ! -f "Message Notes to ProPresenter.command" ]; then
echo "This installer has to stay in the kit folder, next to message-notes.pyz."
finish 1
fi
# macOS's own Python comes with Apple's free Command Line Tools
if ! /usr/bin/xcode-select -p >/dev/null 2>&1 || ! /usr/bin/python3 -c 'import sys; sys.exit(sys.version_info < (3, 8))' >/dev/null 2>&1; then
echo "This Mac first needs Apple's free Command Line Tools (they include Python)."
echo "A box will pop up: click Install and wait until it's finished (5-15 minutes),"
echo "then double-click this installer again."
/usr/bin/xcode-select --install >/dev/null 2>&1
finish 1
fi
D="$HOME/Library/Application Support/MessageNotes"
L="$HOME/Desktop/Message Notes to ProPresenter.command"
mkdir -p "$D" || finish 1
cp message-notes.pyz "$D/message-notes.pyz" || finish 1
cp "Message Notes to ProPresenter.command" "$L" || finish 1
chmod +x "$L"
/usr/bin/xattr -c "$D/message-notes.pyz" "$L" 2>/dev/null # no "downloaded from the internet" warning
echo "Installed version $(/usr/bin/python3 "$D/message-notes.pyz" --version 2>&1)"
echo " program: $D/message-notes.pyz"
echo " Desktop: Message Notes to ProPresenter"
/usr/bin/python3 "$D/message-notes.pyz" --setup
status=$?
echo
echo "All set. From now on just double-click 'Message Notes to ProPresenter' on the Desktop."
finish $status
+74
View File
@@ -0,0 +1,74 @@
MESSAGE NOTES -> PROPRESENTER (version VERSION_HERE)
=====================================================
Turns the pastor's notes ("Slides - <message>.rtf") into the message presentation
in ProPresenter, built from your own message templates, and links it into the
next service's playlist. Runs on the ProPresenter Mac.
INSTALL (once, on the ProPresenter Mac)
---------------------------------------
1. Unzip the kit (double-click the .zip) and open the "Message Notes Kit" folder.
2. Double-click "Install Message Notes.command".
If macOS says it "can't be opened" / "not verified":
click Done, open System Settings > Privacy & Security, scroll down,
click "Open Anyway" next to "Install Message Notes.command", then Open.
(Or: open Terminal, type bash and a space, drag the installer into the
Terminal window, press Return.)
3. If it asks for Apple's Command Line Tools: click Install, wait, run step 2 again.
4. Answer the setup questions (which campus; email pickup - you can skip that).
5. If macOS asks whether Terminal may access Documents, Desktop, Downloads or
Google Drive: click Allow.
Then use "Message Notes to ProPresenter" on the Desktop every week.
To update later: run the installer from a newer kit (settings are kept).
WHAT PROPRESENTER NEEDS
-----------------------
- The templates in a library (normally "Messages"), named like:
_TEMPLATE - DO NOT DELETE 2608 Lower Thirds (series)
_TEMPLATE_GENERIC - DO NOT DELETE 2601 (generic)
Any name works as long as it starts with _TEMPLATE and contains DO NOT DELETE;
the one with GENERIC in the name is the generic one. If there are several,
the most recently changed one is used.
- Network turned on: ProPresenter > Settings > Network > Enable Network.
The tool uses it to check the text fits your output themes and to see that
nothing is live before it restarts ProPresenter to link the playlist.
- The service playlist imported from Planning Center, with a "Message" header.
If it's missing, the tool tells you to import it.
EVERY WEEK
----------
1. Save the notes .rtf from the email into Downloads (or set up email pickup,
below, and skip this).
2. Double-click "Message Notes to ProPresenter" on the Desktop.
3. Check the summary (typos it fixed, template, speaker, service), press Enter.
4. ProPresenter restarts once (to link the playlist) - never while anything is
on screen, on Sunday mornings, or while the message timer runs. If it can't,
it tells you to drag the presentation into the Message section yourself.
5. Check the slides on your outputs. Font sizes are never changed; if text is
too long for an output, the summary says so.
Keys at the summary: Enter = create, e = edit the slide text, r = undo typo
fixes, t = switch template, s = speaker, n = name, d = date, f = other notes
file, y = run setup again, q = quit.
EMAIL PICKUP (optional)
-----------------------
Needs: Google Drive for desktop on this Mac, signed in to the Google account that
receives the "Notes/Slides" emails. During setup (or later with y) the tool copies
a small Google Apps Script and opens script.google.com - paste it, save, run
"install", allow it. From then on the notes land in My Drive/Message Notes every
10 minutes and the tool picks the newest one by itself (it also reads who is
preaching at your campus from the email).
FILES
-----
Program: ~/Library/Application Support/MessageNotes/message-notes.pyz
Settings: ~/Library/Application Support/MessageNotes/settings.json
Log: ~/Library/Logs/message-notes.log
Remove: delete the Desktop launcher and the MessageNotes folder above.
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
# Build the kit for other campuses: dist/Message Notes Kit <version>.zip
# Install Message Notes.command, message-notes.pyz, the Desktop launcher, READ ME FIRST.txt
# No settings and no keys go in it - each Mac asks its own campus on install.
set -euo pipefail
cd "$(dirname "$0")/.."
tools/build.sh >/dev/null
V=$(/usr/bin/python3 dist/message-notes.pyz --version)
K="dist/Message Notes Kit"
rm -rf "$K" && mkdir -p "$K"
cp dist/message-notes.pyz "dist/Message Notes to ProPresenter.command" "tools/kit/Install Message Notes.command" "$K/"
sed "s/VERSION_HERE/$V/" "tools/kit/READ ME FIRST.txt" > "$K/READ ME FIRST.txt"
chmod +x "$K/Install Message Notes.command" "$K/Message Notes to ProPresenter.command"
Z="dist/Message Notes Kit $V.zip"
rm -f "$Z"
(cd dist && /usr/bin/ditto -c -k --keepParent --norsrc "Message Notes Kit" "Message Notes Kit $V.zip")
rm -rf "$K"
ls -l "$Z"