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:
@@ -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
@@ -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
@@ -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
@@ -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 [])
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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\"'"
|
||||||
|
|||||||
Executable
+45
@@ -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
|
||||||
@@ -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.
|
||||||
Executable
+18
@@ -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"
|
||||||
Reference in New Issue
Block a user