Fix and check builders: every fix is built from fields; today's 27 fixes carry their templates
settings/builders.py renders a fix's command from a builder (template) and its fields; validate refuses a config whose stored command no longer equals what its fields build; the Fixes screen and the remote checks in the key editor rebuild through /api/build and /api/build-check. config.json adopts templates for all 27 fixes and 5 remote checks (ProPresenter's restart loop and Ableton's quoting normalised, same behaviour); tests/test_builders.py (174 tests pass). Deployed to NA Ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,430 @@
|
||||
"""Fix and check builders: a fix is built from fields, never typed as a command.
|
||||
|
||||
A fix with a `template` and `params` carries its generated `run` (and kind/host) beside them; the
|
||||
watchdog runs only `run`. Every Mac value is shlex-quoted, every Windows value regex-checked.
|
||||
`build(cfg, fix)` regenerates; `mismatches(cfg)` lists fixes and checks whose stored command no
|
||||
longer equals what their fields build (validate refuses those). `adopt(cfg)` gives today's fixes
|
||||
their templates (a one-time migration; it prints what it normalised)."""
|
||||
import re
|
||||
import shlex
|
||||
|
||||
WIN_SAFE = re.compile(r"^[A-Za-z0-9 ._()\\:*-]+$")
|
||||
LABEL_SAFE = re.compile(r"^[A-Za-z0-9._-]+$")
|
||||
|
||||
|
||||
def q(value):
|
||||
return shlex.quote(str(value))
|
||||
|
||||
|
||||
def win(value, what):
|
||||
value = str(value)
|
||||
if not WIN_SAFE.match(value):
|
||||
raise ValueError("%s: on a PC only letters, digits, space and . _ ( ) \\ : * - are allowed, not %r" % (what, value))
|
||||
return value
|
||||
|
||||
|
||||
def label(value, what):
|
||||
value = str(value)
|
||||
if not LABEL_SAFE.match(value):
|
||||
raise ValueError("%s: a launchd label holds letters, digits, dots, - and _ only, not %r" % (what, value))
|
||||
return value
|
||||
|
||||
|
||||
def home_dir(path):
|
||||
"""~/x -> $HOME/x inside double quotes; refuses quotes and $ in the rest."""
|
||||
path = str(path)
|
||||
if re.search(r'["$`\\]', path.replace("$HOME", "", 1)):
|
||||
raise ValueError("folder %r may not hold quotes, $ or backslashes" % path)
|
||||
if path.startswith("~/"):
|
||||
path = "$HOME/" + path[2:]
|
||||
return path.rstrip("/")
|
||||
|
||||
|
||||
def mac_quit(process, wait_seconds, force, stop_children=False):
|
||||
"""Quit a Mac program gently, give it wait_seconds, then (force) kill it."""
|
||||
p = q(process)
|
||||
ticks = max(1, int(round(float(wait_seconds) * 2)))
|
||||
s = ""
|
||||
if stop_children:
|
||||
s += "pg=$(ps -o pgid= -p \"$(pgrep -x %s | head -1)\" 2>/dev/null | tr -d ' '); " % p
|
||||
s += "pkill -TERM -x %s; i=0; while pgrep -xq %s && [ $i -lt %d ]; do sleep 0.5; i=$((i+1)); done; " % (p, p, ticks)
|
||||
if force:
|
||||
s += "pgrep -xq %s && pkill -KILL -x %s; " % (p, p)
|
||||
if stop_children:
|
||||
s += "[ -n \"$pg\" ] && [ \"$pg\" != \"$(ps -o pgid= -p $$ | tr -d ' ')\" ] && kill -KILL -- -\"$pg\" 2>/dev/null; "
|
||||
if force or stop_children:
|
||||
s += "sleep 1; "
|
||||
return s
|
||||
|
||||
|
||||
def mac_open(app, background=False, arg=None):
|
||||
"""open -a Name / open /path/App.app (+ -g to stay in the background)."""
|
||||
app = str(app)
|
||||
cmd = "open -g" if background else "open"
|
||||
if app == "$APP":
|
||||
cmd += " \"$APP\""
|
||||
elif app.startswith("/"):
|
||||
cmd += " " + q(app)
|
||||
else:
|
||||
cmd += " -a " + q(app)
|
||||
if arg:
|
||||
cmd += " " + arg
|
||||
return cmd
|
||||
|
||||
|
||||
ABLETON_SET = ("L=$(ls -td ~/Library/Preferences/Ableton/Live*/ | head -1); S=$(grep -a 'info: Loading document \"' \"${L}Log.txt\" "
|
||||
"| grep -v '/Templates/' | tail -1 | sed -E 's/^.*Loading document \"(.*)\"[^\"]*$/\\1/'); ")
|
||||
|
||||
|
||||
def ableton_open(app):
|
||||
return "if [ -n \"$S\" ] && [ -f \"$S\" ]; then %s; else %s; fi" % (mac_open(app, arg="\"$S\""), mac_open(app))
|
||||
|
||||
|
||||
def helper_restart(labels):
|
||||
one = "launchctl kickstart -k gui/$(id -u)/%s || launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/%s.plist"
|
||||
labels = [label(x, "helper") for x in labels]
|
||||
if not labels:
|
||||
raise ValueError("at least one helper (launchd label) is needed")
|
||||
if len(labels) == 1:
|
||||
return one % (labels[0], labels[0])
|
||||
return "s=0; " + " ".join("{ %s; } || s=1;" % (one % (x, x)) for x in labels) + " exit $s"
|
||||
|
||||
|
||||
def win_running(exe):
|
||||
return "tasklist /NH /FI \"IMAGENAME eq %s\" | find /I \"%s\" >nul" % (exe, exe)
|
||||
|
||||
|
||||
# --- the templates ---------------------------------------------------------------
|
||||
# params: (name, words, help, kind, default) - kind: str, host, bool, int, list, app
|
||||
P_HOST = ("host", "Computer", "Which computer it runs on.", "host", None)
|
||||
TEMPLATES = {
|
||||
"mac_start": {
|
||||
"words": "Start a Mac program if it is not running",
|
||||
"os": "mac", "start_only": True,
|
||||
"params": [P_HOST, ("process", "Process name", "As pgrep sees it (Activity Monitor's process name).", "str", None),
|
||||
("app", "App to open", "Its name, or the full /Applications/... path.", "str", None)],
|
||||
"render": lambda p, cfg: "pgrep -xq %s || %s" % (q(p["process"]), mac_open(p["app"])),
|
||||
"suggest": lambda p, cfg: "start %s if it is not running" % p["app"],
|
||||
},
|
||||
"mac_restart": {
|
||||
"words": "Restart a Mac program (quit it, then open it again)",
|
||||
"os": "mac", "closes": True,
|
||||
"params": [P_HOST, ("process", "Process name", "As pgrep sees it.", "str", None),
|
||||
("app", "App to open", "Its name, the full /Applications/... path, or $APP with 'newest of' below.", "str", None),
|
||||
("wait_seconds", "Wait for it to quit", "Seconds before it is killed.", "int", 10),
|
||||
("force", "Kill it if it will not quit", "", "bool", True),
|
||||
("background", "Open it in the background", "No window comes to the front.", "bool", False),
|
||||
("stop_children", "Also stop everything it started", "Its helper processes (a translation server, say).", "bool", False),
|
||||
("newest_of", "Newest of", "A pattern like /Applications/ProPresenter*.app: the newest installed version opens.", "str", "")],
|
||||
"render": lambda p, cfg: (("APP=$(ls -d %s | sort -V | tail -1); " % str(p["newest_of"]).strip()) if p.get("newest_of") else "")
|
||||
+ mac_quit(p["process"], p.get("wait_seconds", 10), p.get("force", True), p.get("stop_children", False))
|
||||
+ mac_open("$APP" if p.get("newest_of") else p["app"], p.get("background", False)),
|
||||
"suggest": lambda p, cfg: "restart %s" % (p["app"] if not p.get("newest_of") else "%s (newest installed)" % p["process"]),
|
||||
},
|
||||
"ableton": {
|
||||
"words": "Open Ableton Live with the set it last had open",
|
||||
"os": "mac",
|
||||
"params": [P_HOST, ("app", "Ableton app name", "As in /Applications.", "str", "Ableton Live 11 Suite"),
|
||||
("restart", "Quit it first if it is running", "Otherwise it only starts when it is not running.", "bool", False),
|
||||
("wait_seconds", "Wait for it to quit", "Seconds before it is killed (restart only).", "int", 15)],
|
||||
"render": lambda p, cfg: (ABLETON_SET + mac_quit("Live", p.get("wait_seconds", 15), True) + ableton_open(p["app"])) if p.get("restart")
|
||||
else "pgrep -xq Live || { " + ABLETON_SET + ableton_open(p["app"]) + "; }",
|
||||
"suggest": lambda p, cfg: ("restart Ableton and reopen the set it had open (unsaved changes are lost)" if p.get("restart")
|
||||
else "start Ableton if it is not running, with the set it last had open"),
|
||||
},
|
||||
"dvs": {
|
||||
"words": "Restart Dante Virtual Soundcard (through its helper)",
|
||||
"os": "mac", "closes": True,
|
||||
"params": [P_HOST, ("then_process", "Then restart this program", "Process name of a program that keeps a dead DVS input (blank: none).", "str", ""),
|
||||
("then_app", "...and open it as", "Its name or /Applications/... path.", "str", "")],
|
||||
"render": lambda p, cfg: "sudo -n %s && open -g -a 'Dante Virtual Soundcard'" % q(dvs_helper(cfg))
|
||||
+ ((" || exit 1; sleep 5; " + mac_quit(p["then_process"], 10, True) + mac_open(p["then_app"] or p["then_process"], True)) if p.get("then_process") else ""),
|
||||
"suggest": lambda p, cfg: "restart Dante Virtual Soundcard (its engine restarts; audio to and from that Mac drops for 5-10 s)"
|
||||
+ (", then %s" % p["then_process"] if p.get("then_process") else ""),
|
||||
},
|
||||
"helpers": {
|
||||
"words": "Restart a helper on this Mac (a LaunchAgent)",
|
||||
"os": "self", "closes": True,
|
||||
"params": [("labels", "Helpers", "launchd labels, one or more (org.waterschurch.lobby-captions, say).", "list", [])],
|
||||
"render": lambda p, cfg: helper_restart(p.get("labels") or []),
|
||||
"suggest": lambda p, cfg: "restart %s" % ", ".join(p.get("labels") or []),
|
||||
},
|
||||
"win_start": {
|
||||
"words": "Start a Windows program if it is not running (a StatusPanel task)",
|
||||
"os": "windows", "start_only": True,
|
||||
"params": [P_HOST, ("task", "Task name", "The scheduled task under \\StatusPanel\\ (Start Vista, say).", "str", None),
|
||||
("exe", "Program file", "As Task Manager shows it (Vista.exe).", "str", None)],
|
||||
"render": lambda p, cfg: "%s || (schtasks /Run /TN \"\\StatusPanel\\%s\" >nul && ping -n 9 127.0.0.1 >nul && %s)"
|
||||
% (win_running(win(p["exe"], "program file")), win(p["task"], "task name"), win_running(p["exe"])),
|
||||
"suggest": lambda p, cfg: "start %s if it is not running" % str(p["exe"]).replace(".exe", ""),
|
||||
},
|
||||
"win_restart": {
|
||||
"words": "Restart a Windows program (close it, then its StatusPanel task)",
|
||||
"os": "windows", "closes": True,
|
||||
"params": [P_HOST, ("task", "Task name", "The scheduled task under \\StatusPanel\\.", "str", None),
|
||||
("exe", "Program file", "As Task Manager shows it.", "str", None)],
|
||||
"render": lambda p, cfg: "taskkill /F /T /IM \"%s\" >nul 2>&1 & ping -n 3 127.0.0.1 >nul & schtasks /Run /TN \"\\StatusPanel\\%s\" >nul && ping -n 9 127.0.0.1 >nul && %s"
|
||||
% (win(p["exe"], "program file"), win(p["task"], "task name"), win_running(p["exe"])),
|
||||
"suggest": lambda p, cfg: "restart %s" % str(p["exe"]).replace(".exe", ""),
|
||||
},
|
||||
"win_service": {
|
||||
"words": "Start a Windows service (and its tray program) if they are not running",
|
||||
"os": "windows", "start_only": True,
|
||||
"params": [P_HOST, ("service", "Service name", "As services.msc lists it (rtpMIDIService).", "str", None),
|
||||
("task", "Tray program's task", "The StatusPanel task that starts the tray program (blank: none).", "str", ""),
|
||||
("exe", "Tray program file", "rtpMIDI.exe, say.", "str", "")],
|
||||
"render": lambda p, cfg: "(sc query %s | find \"RUNNING\" >nul || net start %s)" % (win(p["service"], "service"), win(p["service"], "service"))
|
||||
+ ((" && (%s || schtasks /Run /TN \"\\StatusPanel\\%s\")" % (win_running(win(p["exe"], "program file")), win(p["task"], "task name"))) if p.get("task") else ""),
|
||||
"suggest": lambda p, cfg: "start %s if it is not running" % p["service"],
|
||||
},
|
||||
"solo_afl": {
|
||||
"words": "Put Pro Tools back to Solo Mode AFL + Latch (through the solo helper)",
|
||||
"os": "mac",
|
||||
"params": [P_HOST],
|
||||
"render": lambda p, cfg: "touch \"%s/solo-mode.fix\"; sleep 3; ! grep -q '\"bad\":true' \"%s/solo-mode.json\"" % (solo_dir(cfg), solo_dir(cfg)),
|
||||
"suggest": lambda p, cfg: "set Pro Tools back to Solo Mode AFL + Latch (clicks Options > Solo Mode)",
|
||||
},
|
||||
"atem_midi_off": {
|
||||
"words": "Turn off MIDI control in ATEM Software Control (quits and reopens it)",
|
||||
"os": "self", "closes": True, "restart_ok": True,
|
||||
"params": [],
|
||||
"render": lambda p, cfg: ("was=$(pgrep -f \"ATEM Software Control.app/Contents/MacOS\"); pkill -TERM -f \"ATEM Software Control.app/Contents/MacOS\"; "
|
||||
"i=0; while pgrep -fq \"ATEM Software Control.app/Contents/MacOS\" && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; "
|
||||
"defaults write \"com.blackmagic-design.ATEM Software Control\" midiEnabled -bool false; "
|
||||
"[ -z \"$was\" ] || open -g -a \"ATEM Software Control\"; "
|
||||
"[ \"$(defaults read \"com.blackmagic-design.ATEM Software Control\" midiEnabled)\" = 0 ]"),
|
||||
"suggest": lambda p, cfg: "turn off MIDI control in ATEM Software Control (quits and reopens the app; the switcher is not affected)",
|
||||
},
|
||||
# kinds without a command: the template is the kind, the params are its fields
|
||||
"reconnect": {"words": "Reconnect a Companion connection", "kind": "reconnect", "start_only": True, "params": [], "render": None,
|
||||
"suggest": lambda p, cfg: "reconnect Companion to it"},
|
||||
"hyperdeck": {"words": "Switch a HyperDeck's connection off and on", "kind": "hyperdeck", "start_only": True, "params": [], "render": None,
|
||||
"suggest": lambda p, cfg: "turn Companion's connection to that HyperDeck off and on; never reboots the deck"},
|
||||
"midi_connect": {"words": "Reconnect a network MIDI peer", "kind": "midi_connect", "start_only": True, "params": [], "render": None,
|
||||
"suggest": lambda p, cfg: "reconnect the peer to the network MIDI session it dropped out of"},
|
||||
"atem": {"words": "Put the switcher back to the saved file", "kind": "atem", "params": [], "render": None,
|
||||
"suggest": lambda p, cfg: "set the switcher back to the saved file"},
|
||||
"netgear_poe": {"words": "Power-cycle a PoE port on a Dante switch", "kind": "netgear_poe", "params": [], "render": None,
|
||||
"suggest": lambda p, cfg: "switch the device's PoE port off and on"},
|
||||
"spotify": {"words": "Play the service playlist in Spotify", "kind": "spotify", "start_only": True, "params": [], "render": None,
|
||||
"suggest": lambda p, cfg: "play the %s playlist in Spotify on %s (opens Spotify if it is closed)"
|
||||
% ((cfg.get("spotify") or {}).get("name") or "service", (cfg.get("site") or {}).get("ingest_name") or "this Mac")},
|
||||
"custom": {"words": "Custom command (Advanced: typed by hand)", "params": [], "render": None, "suggest": lambda p, cfg: ""},
|
||||
}
|
||||
|
||||
|
||||
def dvs_helper(cfg):
|
||||
return (cfg.get("site") or {}).get("dvs_helper") or "/usr/local/sbin/status-panel-dvs-start"
|
||||
|
||||
|
||||
def solo_dir(cfg):
|
||||
return home_dir((cfg.get("site") or {}).get("solo_dir") or "~/Library/Application Support/status-panel")
|
||||
|
||||
|
||||
def template_list(cfg=None):
|
||||
"""For the page: name, words, params with defaults, what it needs."""
|
||||
out = []
|
||||
for name, t in TEMPLATES.items():
|
||||
out.append({"name": name, "words": t["words"], "os": t.get("os"), "kind": t.get("kind"), "start_only": bool(t.get("start_only")),
|
||||
"closes": bool(t.get("closes")),
|
||||
"params": [{"name": n, "words": w, "help": h, "type": k, "default": d} for n, w, h, k, d in t["params"]]})
|
||||
return out
|
||||
|
||||
|
||||
def build(cfg, fix):
|
||||
"""A fix with template + params -> the same fix with kind/host/run regenerated. Raises ValueError with plain words."""
|
||||
name = fix.get("template")
|
||||
if not name or name == "custom":
|
||||
return dict(fix)
|
||||
t = TEMPLATES.get(name)
|
||||
if t is None:
|
||||
raise ValueError("unknown builder %s" % name)
|
||||
params = dict(fix.get("params") or {})
|
||||
for n, words, _, kind, default in t["params"]:
|
||||
if params.get(n) in (None, "") and default is not None:
|
||||
params[n] = default
|
||||
if params.get(n) in (None, "") and default is None:
|
||||
raise ValueError("%s: %s is needed" % (t["words"], words.lower()))
|
||||
out = {k: v for k, v in fix.items() if k not in ("run", "host", "kind")}
|
||||
out["template"], out["params"] = name, params
|
||||
if t.get("kind"):
|
||||
out["kind"] = t["kind"]
|
||||
return out
|
||||
hosts = cfg.get("hosts") or {}
|
||||
host = params.get("host")
|
||||
if t.get("os") == "self":
|
||||
out["kind"] = "local"
|
||||
else:
|
||||
h = hosts.get(host)
|
||||
if h is None:
|
||||
raise ValueError("%s: unknown computer %s" % (t["words"], host))
|
||||
want = t.get("os")
|
||||
have = "mac" if h.get("self") else (h.get("os") or "mac")
|
||||
if want and have != want:
|
||||
raise ValueError("%s: %s is a %s computer, this builder is for %s" % (t["words"], h.get("name", host), have, want))
|
||||
if h.get("self"):
|
||||
out["kind"] = "local"
|
||||
else:
|
||||
out["kind"], out["host"] = "ssh", host
|
||||
out["run"] = t["render"](params, cfg)
|
||||
if t.get("restart_ok"):
|
||||
out["restart_ok"] = True
|
||||
return out
|
||||
|
||||
|
||||
def suggest_label(cfg, fix):
|
||||
t = TEMPLATES.get(fix.get("template") or "")
|
||||
if not t:
|
||||
return ""
|
||||
try:
|
||||
return t["suggest"](dict(fix.get("params") or {}), cfg)
|
||||
except (KeyError, TypeError):
|
||||
return ""
|
||||
|
||||
|
||||
# --- check builders (the three remote checks that carry a command) ----------------
|
||||
CHECK_TEMPLATES = {
|
||||
"mac_program": {
|
||||
"words": "A program is running on a Mac", "type": "remote",
|
||||
"params": [P_HOST, ("process", "Process name", "As pgrep sees it.", "str", None)],
|
||||
"render": lambda p, cfg: "pgrep -xq %s" % q(p["process"]),
|
||||
},
|
||||
"helper_flag": {
|
||||
"words": "A helper file does not say something is wrong", "type": "remote",
|
||||
"params": [P_HOST, ("file", "File", "In the helper's folder (solo-mode.json).", "str", "solo-mode.json"),
|
||||
("bad", "The bad text", "The check fails when the file holds this.", "str", "\"bad\":true")],
|
||||
"render": lambda p, cfg: "! grep -q %s \"%s/%s\"" % (q(p["bad"]), solo_dir(cfg), p["file"]),
|
||||
},
|
||||
"helper_fresh": {
|
||||
"words": "A helper file is fresh and says it is trusted", "type": "remote",
|
||||
"params": [P_HOST, ("file", "File", "In the helper's folder.", "str", "solo-mode.json"),
|
||||
("max_age", "Fresh means younger than", "Seconds.", "int", 20),
|
||||
("flag", "The good text", "The check fails unless the file holds this.", "str", "\"trusted\":true")],
|
||||
"render": lambda p, cfg: "f=\"%s/%s\"; [ $(( $(date +%%s) - $(stat -f %%m \"$f\") )) -lt %d ] && grep -q %s \"$f\""
|
||||
% (solo_dir(cfg), p["file"], int(p["max_age"]), q(p["flag"])),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def check_template_list():
|
||||
return [{"name": n, "words": t["words"], "type": t["type"],
|
||||
"params": [{"name": a, "words": w, "help": h, "type": k, "default": d} for a, w, h, k, d in t["params"]]} for n, t in CHECK_TEMPLATES.items()]
|
||||
|
||||
|
||||
def build_check(cfg, chk):
|
||||
name = chk.get("template")
|
||||
if not name:
|
||||
return dict(chk)
|
||||
t = CHECK_TEMPLATES.get(name)
|
||||
if t is None:
|
||||
raise ValueError("unknown check builder %s" % name)
|
||||
params = dict(chk.get("params") or {})
|
||||
for n, words, _, kind, default in t["params"]:
|
||||
if params.get(n) in (None, "") and default is not None:
|
||||
params[n] = default
|
||||
if params.get(n) in (None, "") and default is None:
|
||||
raise ValueError("%s: %s is needed" % (t["words"], words.lower()))
|
||||
h = (cfg.get("hosts") or {}).get(params.get("host"))
|
||||
if h is None:
|
||||
raise ValueError("%s: unknown computer %s" % (t["words"], params.get("host")))
|
||||
if h.get("self"):
|
||||
raise ValueError("%s: %s is this Mac - use 'A program is running on this Mac'" % (t["words"], h.get("name")))
|
||||
out = dict(chk, type=t["type"], template=name, params=params, host=params["host"])
|
||||
for k, v in chk.items():
|
||||
if k in ("fix", "tag", "service_only", "debounce_seconds", "msg", "name"):
|
||||
out[k] = v
|
||||
for k in ("fix", "tag", "service_only", "debounce_seconds"):
|
||||
if k in chk:
|
||||
out[k] = chk[k]
|
||||
out["cmd"] = t["render"](params, cfg)
|
||||
return out
|
||||
|
||||
|
||||
def mismatches(cfg):
|
||||
"""Fixes and checks whose stored command differs from what their fields build (plain words)."""
|
||||
out = []
|
||||
for fid, fix in (cfg.get("fixes") or {}).items():
|
||||
if fix.get("template") and fix["template"] != "custom":
|
||||
try:
|
||||
b = build(cfg, fix)
|
||||
except ValueError as e:
|
||||
out.append("fix %s: %s" % (fid, e))
|
||||
continue
|
||||
for k in ("run", "kind", "host"):
|
||||
if (b.get(k) or None) != (fix.get(k) or None):
|
||||
out.append("fix %s: its %s no longer matches its fields - open it on Fixes and press Save" % (fid, "command" if k == "run" else k))
|
||||
break
|
||||
for t in cfg.get("tiles") or []:
|
||||
for i, c in enumerate(t.get("checks") or []):
|
||||
if c.get("template"):
|
||||
try:
|
||||
b = build_check(cfg, c)
|
||||
except ValueError as e:
|
||||
out.append("key %s check %d: %s" % (t.get("label", t["id"]), i + 1, e))
|
||||
continue
|
||||
if b.get("cmd") != c.get("cmd") or b.get("host") != c.get("host"):
|
||||
out.append("key %s check %d: its command no longer matches its fields" % (t.get("label", t["id"]), i + 1))
|
||||
return out
|
||||
|
||||
|
||||
# --- today's fixes get their templates (one-time) ------------------------------------
|
||||
TODAY = {
|
||||
"propresenter": ("mac_restart", {"host": "prop", "process": "ProPresenter", "app": "$APP", "newest_of": "/Applications/ProPresenter*.app", "wait_seconds": 10, "force": True}),
|
||||
"ableton": ("ableton", {"host": "tracks", "app": "Ableton Live 11 Suite", "restart": False}),
|
||||
"ableton_restart": ("ableton", {"host": "tracks", "app": "Ableton Live 11 Suite", "restart": True, "wait_seconds": 15}),
|
||||
"translate": ("mac_start", {"host": "translation", "process": "Church Translate", "app": "Church Translate"}),
|
||||
"translate_restart": ("mac_restart", {"host": "translation", "process": "Church Translate", "app": "Church Translate", "wait_seconds": 15, "force": True, "stop_children": True}),
|
||||
"dvs_prop": ("dvs", {"host": "prop"}), "dvs_tracks": ("dvs", {"host": "tracks"}), "dvs_translation": ("dvs", {"host": "translation"}),
|
||||
"dvs_ingest": ("dvs", {"host": "ingest", "then_process": "ProdCom", "then_app": "/Applications/ProdCom.app"}),
|
||||
"bridge": ("helpers", {"labels": ["org.waterschurch.resi-propresenter"]}),
|
||||
"captions": ("helpers", {"labels": ["org.waterschurch.lobby-captions", "org.waterschurch.lobby-captions-display"]}),
|
||||
"caption_screen": ("helpers", {"labels": ["org.waterschurch.lobby-captions-display"]}),
|
||||
"bibleapp": ("win_restart", {"host": "bibletv", "task": "Start BibleApp", "exe": "Bible App.exe"}),
|
||||
"obs": ("win_start", {"host": "livestream", "task": "Start OBS", "exe": "obs64.exe"}),
|
||||
"arena": ("win_start", {"host": "lighting", "task": "Start Arena", "exe": "Arena.exe"}),
|
||||
"vista": ("win_start", {"host": "lighting", "task": "Start Vista", "exe": "Vista.exe"}),
|
||||
"rtpmidi_start": ("win_service", {"host": "lighting", "service": "rtpMIDIService", "task": "Start rtpMIDI", "exe": "rtpMIDI.exe"}),
|
||||
"solo_afl": ("solo_afl", {"host": "studiob"}),
|
||||
"atem_midi": ("atem_midi_off", {}),
|
||||
"proddash": ("mac_restart", {"host": "ingest", "process": "ProdDash", "app": "/Applications/ProdDash.app", "wait_seconds": 10, "force": False, "background": True}),
|
||||
"prodcom": ("mac_restart", {"host": "ingest", "process": "ProdCom", "app": "/Applications/ProdCom.app", "wait_seconds": 10, "force": True, "background": True}),
|
||||
"midi_reconnect": ("midi_connect", {}), "atem_audio": ("atem", {}), "atem_camera": ("atem", {}), "spotify_play": ("spotify", {}),
|
||||
"tascam_reconnect": ("reconnect", {}), "hyperdeck": ("hyperdeck", {}),
|
||||
}
|
||||
TODAY_CHECKS = { # (key id, cmd) -> template + params
|
||||
("apps", "pgrep -xq ProPresenter"): ("mac_program", {"host": "prop", "process": "ProPresenter"}),
|
||||
("apps", "pgrep -xq Live"): ("mac_program", {"host": "tracks", "process": "Live"}),
|
||||
("apps", "pgrep -xq 'Church Translate'"): ("mac_program", {"host": "translation", "process": "Church Translate"}),
|
||||
("apps", "! grep -q '\"bad\":true' \"$HOME/Library/Application Support/status-panel/solo-mode.json\""): ("helper_flag", {"host": "studiob"}),
|
||||
("apps", "f=\"$HOME/Library/Application Support/status-panel/solo-mode.json\"; [ $(( $(date +%s) - $(stat -f %m \"$f\") )) -lt 20 ] && grep -q '\"trusted\":true' \"$f\""):
|
||||
("helper_fresh", {"host": "studiob"}),
|
||||
}
|
||||
|
||||
|
||||
def adopt(cfg):
|
||||
"""Give today's fixes and checks their templates; returns [(what, before, after)] for every command that changed shape."""
|
||||
changed = []
|
||||
for fid, fix in cfg.get("fixes", {}).items():
|
||||
if fix.get("template") or fid not in TODAY:
|
||||
continue
|
||||
name, params = TODAY[fid]
|
||||
fix["template"], fix["params"] = name, params
|
||||
built = build(cfg, fix)
|
||||
if built.get("run") != fix.get("run"):
|
||||
changed.append(("fix " + fid, fix.get("run"), built.get("run")))
|
||||
fix.update({k: v for k, v in built.items() if k in ("run", "kind", "host", "restart_ok")})
|
||||
for k in ("host",):
|
||||
if k not in built and k in fix:
|
||||
del fix[k]
|
||||
for t in cfg.get("tiles", []):
|
||||
for c in t.get("checks", []):
|
||||
key = (t["id"], c.get("cmd"))
|
||||
if c.get("template") or key not in TODAY_CHECKS:
|
||||
continue
|
||||
name, params = TODAY_CHECKS[key]
|
||||
c["template"], c["params"] = name, params
|
||||
built = build_check(cfg, c)
|
||||
if built["cmd"] != c.get("cmd"):
|
||||
changed.append(("key %s check" % t["id"], c.get("cmd"), built["cmd"]))
|
||||
c["cmd"], c["host"] = built["cmd"], built["host"]
|
||||
return changed
|
||||
@@ -31,6 +31,7 @@ from settings import PARENT, HERE # noqa: F401
|
||||
import schema
|
||||
import status_panel as sp
|
||||
from settings import apply as applying
|
||||
from settings import builders
|
||||
from settings import keyguide, probes
|
||||
|
||||
log = logging.getLogger("status-settings")
|
||||
@@ -644,6 +645,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
if path == "/api/live/midi":
|
||||
snap = site.read_json("snapshot.json", {}) or {}
|
||||
return self.send(200, {"local": snap.get("midi"), "remote": snap.get("remote_midi")})
|
||||
if path == "/api/builders":
|
||||
return self.send(200, {"fixes": builders.template_list(site.cfg), "checks": builders.check_template_list()})
|
||||
if path == "/api/facts":
|
||||
return self.send(200, {"facts": COMPANION_FACTS})
|
||||
if path == "/api/snapshot":
|
||||
@@ -734,6 +737,15 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
site.draft.clear()
|
||||
self.discard_atem()
|
||||
return self.send(200, {"ok": True})
|
||||
if path in ("/api/build", "/api/build-check"):
|
||||
cfg = (site.draft.read() or {}).get("config") or site.load()
|
||||
try:
|
||||
if path == "/api/build":
|
||||
fix = builders.build(cfg, body.get("fix") or {})
|
||||
return self.send(200, {"ok": True, "fix": fix, "label": builders.suggest_label(cfg, fix)})
|
||||
return self.send(200, {"ok": True, "check": builders.build_check(cfg, body.get("check") or {})})
|
||||
except ValueError as e:
|
||||
return self.send(200, {"ok": False, "error": str(e)})
|
||||
if path == "/api/validate":
|
||||
cfg = schema.unmask(body.get("config") or (site.draft.read() or {}).get("config") or site.load(), site.load())
|
||||
return self.send(200, self.check(cfg))
|
||||
|
||||
+81
-13
@@ -69,8 +69,8 @@
|
||||
|
||||
// ---- the draft ------------------------------------------------------------
|
||||
async function loadAll() {
|
||||
const [c, s, d] = await Promise.all([api("/api/config"), api("/api/schema"), api("/api/draft")]);
|
||||
state.live = c.config; state.liveSha = c.sha; state.schema = s; state.draft = d.draft; state.draftStale = !!d.stale; state.atemPending = !!d.atem_pending;
|
||||
const [c, s, d, b] = await Promise.all([api("/api/config"), api("/api/schema"), api("/api/draft"), api("/api/builders")]);
|
||||
state.live = c.config; state.liveSha = c.sha; state.schema = s; state.draft = d.draft; state.draftStale = !!d.stale; state.atemPending = !!d.atem_pending; state.builders = b;
|
||||
document.getElementById("sitename").textContent = (state.live.site || {}).name || "";
|
||||
badge();
|
||||
}
|
||||
@@ -309,7 +309,13 @@
|
||||
const sentence = (kt.checks[j] || {}).sentence || chk.type;
|
||||
if (!editing()) return `<li>${esc(sentence)}<details><summary>Advanced</summary><dl class="kv small">${Object.entries(chk).map(([n, v]) => `<dt>${esc(n)}</dt><dd class="mono">${esc(typeof v === "object" ? JSON.stringify(v) : v)}</dd>`).join("")}</dl></details></li>`;
|
||||
const base = `tiles.${i}.checks.${j}.`;
|
||||
const fields = (spec.fields || []).filter((f) => f !== "fix" && f !== "type").map((f) => inputHtml(base + f, F("tiles[].checks[]." + f), chk[f])).join("");
|
||||
let fields = (spec.fields || []).filter((f) => !["fix", "type", "template", "params"].includes(f)).map((f) => inputHtml(base + f, F("tiles[].checks[]." + f), chk[f])).join("");
|
||||
if (chk.type === "remote") {
|
||||
const ctpls = state.builders.checks.concat([{ name: "custom", words: "Custom command (typed by hand)", params: [] }]);
|
||||
const ctName = chk.template || "custom", ct = ctpls.find((x) => x.name === ctName);
|
||||
fields = `<div class="field"><label>Built as</label><select class="ctplsel" data-check="${j}">${ctpls.map((x) => `<option value="${x.name}" ${x.name === ctName ? "selected" : ""}>${esc(x.words)}</option>`).join("")}</select></div><div class="ctplform" data-check="${j}">${paramsHtml(ct, chk.params || {}, "ck" + j)}</div>`
|
||||
+ (ctName === "custom" ? fields : inputHtml(base + "msg", F("tiles[].checks[].msg"), chk.msg) + (chk.cmd ? `<div class="field"><label>The command it makes (on ${esc((c.hosts[chk.host] || {}).name || chk.host)})</label><pre class="log small">${esc(chk.cmd)}</pre></div>` : ""));
|
||||
}
|
||||
const fixSel = typeof chk.fix === "object" && chk.fix ? `<div class="field"><label>Fix (per sub-check)</label><textarea data-path="${base}fix" rows="2" class="fixtable">${esc(JSON.stringify(chk.fix))}</textarea><div class="help">A table: sub-check → fix, "*" for everything else.</div></div>`
|
||||
: `<div class="field"><label>Fix: what holding runs for this check</label><select data-path="${base}fix" class="fixsel">${fixOptions(chk.fix)}</select></div>`;
|
||||
const run = typeof chk.fix === "string" && chk.fix && canRunNow(chk.fix) ? `<button class="small" data-run="${esc(t.id)}|${esc(chk.fix)}|${j}">Run this fix now</button>` : "";
|
||||
@@ -337,6 +343,19 @@
|
||||
for (const cb of main.querySelectorAll("[data-member]")) cb.onchange = () => { if (!ensureDraft()) return; const t = state.draft.config.tiles[i]; const set = new Set(t.members); cb.checked ? set.add(cb.dataset.member) : set.delete(cb.dataset.member); t.members = [...set]; saveDraft(); };
|
||||
const add = main.querySelector("#addcheck"); if (add) add.onchange = () => { if (!add.value || !ensureDraft()) return; const t = state.draft.config.tiles[i]; t.checks = t.checks || []; t.checks.push({ type: add.value }); saveDraft(); route(); };
|
||||
const pos = main.querySelector("#pos"); if (pos) pos.onchange = () => { if (!ensureDraft()) return; const t = state.draft.config.tiles[i]; const m = pos.value.match(/^\s*(\d+)\s*,\s*(\d+)\s*$/); if (m) t.pos = [+m[1], +m[2]]; else if (!pos.value.trim()) delete t.pos; else return alertBox("Row, column: two numbers, like 2, 5."); saveDraft(); route(); };
|
||||
const rebuildCheck = async (j) => {
|
||||
if (!ensureDraft()) return;
|
||||
const t = state.draft.config.tiles[i], chk = t.checks[j];
|
||||
const tplName = main.querySelector(`select.ctplsel[data-check="${j}"]`).value;
|
||||
if (tplName === "custom") { delete chk.template; delete chk.params; saveDraft(); route(); return; }
|
||||
const ct = state.builders.checks.find((x) => x.name === tplName);
|
||||
const params = readParams(main.querySelector(`.ctplform[data-check="${j}"]`), ct);
|
||||
const r = await post("/api/build-check", { check: Object.assign({}, chk, { template: tplName, params }) });
|
||||
if (!r.ok) { chk.template = tplName; chk.params = params; saveDraft(); alertBox(r.error); return; }
|
||||
t.checks[j] = r.check; saveDraft(); route();
|
||||
};
|
||||
for (const sel3 of main.querySelectorAll("select.ctplsel")) sel3.onchange = () => rebuildCheck(+sel3.dataset.check);
|
||||
for (const form of main.querySelectorAll(".ctplform")) for (const el of form.querySelectorAll("[data-param]")) el.onchange = () => rebuildCheck(+form.dataset.check);
|
||||
for (const s of main.querySelectorAll("select.fixsel")) s.onchange = () => { setPath(s.dataset.path, s.value || undefined); route(); };
|
||||
for (const ta of main.querySelectorAll("textarea.fixtable")) ta.onchange = () => { try { setPath(ta.dataset.path, JSON.parse(ta.value)); } catch (e) { alertBox("Not a valid table."); } };
|
||||
for (const s of main.querySelectorAll("select.holdsel")) s.onchange = () => { setPath(s.dataset.path, s.value || undefined); route(); };
|
||||
@@ -609,6 +628,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ---- builders: a fix or a check is made from fields ---------------------------------
|
||||
function paramsHtml(tpl, params, prefix) {
|
||||
const c = cfg();
|
||||
return tpl.params.map((p) => {
|
||||
const v = params[p.name] != null ? params[p.name] : p.default;
|
||||
const id = `${prefix}-${p.name}`;
|
||||
let input;
|
||||
if (p.type === "host") input = `<select id="${id}" data-param="${p.name}"><option value="">–</option>${Object.entries(c.hosts).filter(([, h]) => tpl.os === "self" ? h.self : tpl.os ? (h.self ? "mac" : h.os || "mac") === tpl.os : true).map(([a, h]) => `<option value="${esc(a)}" ${v === a ? "selected" : ""}>${esc(h.name || a)}</option>`).join("")}</select>`;
|
||||
else if (p.type === "bool") input = `<input type="checkbox" id="${id}" data-param="${p.name}" ${v ? "checked" : ""}>`;
|
||||
else if (p.type === "int") input = `<input type="number" id="${id}" data-param="${p.name}" value="${esc(v == null ? "" : v)}">`;
|
||||
else if (p.type === "list") input = `<input id="${id}" data-param="${p.name}" value="${esc(Array.isArray(v) ? v.join(", ") : v || "")}" placeholder="one or more, with commas">`;
|
||||
else input = `<input id="${id}" data-param="${p.name}" value="${esc(v == null ? "" : v)}">`;
|
||||
return `<div class="field"><label for="${id}">${esc(p.words)}</label>${input}<div class="help">${esc(p.help)}</div></div>`;
|
||||
}).join("");
|
||||
}
|
||||
function readParams(root, tpl) {
|
||||
const out = {};
|
||||
for (const p of tpl.params) {
|
||||
const el = root.querySelector(`[data-param="${p.name}"]`); if (!el) continue;
|
||||
if (p.type === "bool") out[p.name] = el.checked;
|
||||
else if (p.type === "int") out[p.name] = el.value === "" ? undefined : parseInt(el.value, 10);
|
||||
else if (p.type === "list") out[p.name] = el.value.split(",").map((x) => x.trim()).filter(Boolean);
|
||||
else out[p.name] = el.value.trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- Fixes -------------------------------------------------------------------------
|
||||
async function fixes(rest) {
|
||||
const c = cfg(), auto = new Set(c.auto_fix.fixes || []);
|
||||
@@ -621,20 +667,42 @@
|
||||
let detail = "";
|
||||
if (f) {
|
||||
const kind = f.kind || "ssh";
|
||||
const fields = ["label", "short"].concat((state.schema.fix_kind_fields || {})[kind] || []).concat(["restart_ok", "never_auto", "timeout"]);
|
||||
const inputs = editing() ? [...new Set(fields)].map((fl) => {
|
||||
const Fd = F("fixes.*." + fl); if (!Fd) return "";
|
||||
if (fl === "host") return `<div class="field"><label>Computer</label><select data-path="fixes.${esc(id)}.host"><option value="">–</option>${Object.entries(c.hosts).filter(([, h]) => !h.self).map(([a, h]) => `<option value="${esc(a)}" ${f.host === a ? "selected" : ""}>${esc(h.name || a)}</option>`).join("")}</select></div>`;
|
||||
if (fl === "run") return `<div class="field"><label>Command (shown as a command at Review; never run before Apply)</label><textarea data-path="fixes.${esc(id)}.run" rows="3" class="mono">${esc(f.run || "")}</textarea><div class="help">zsh on a Mac, cmd.exe on a PC. ${esc(Fd.help)}</div></div>`;
|
||||
return inputHtml(`fixes.${id}.${fl}`, Fd, f[fl]);
|
||||
}).join("") : "";
|
||||
const kindSel = editing() ? `<div class="field"><label>Built as</label><select data-path="fixes.${esc(id)}.kind" class="kindsel">${(state.schema.fix_kinds || ["ssh", "local"]).map((k) => `<option ${kind === k ? "selected" : ""}>${k}</option>`).join("")}</select></div>` : "";
|
||||
const tpls = state.builders.fixes, tplName = f.template || "custom", tpl = tpls.find((t) => t.name === tplName) || tpls.find((t) => t.name === "custom");
|
||||
const kindOnly = !!tpl.kind;
|
||||
const keys = [...(used[id] || [])];
|
||||
detail = `<div class="card"><h2>${esc(f.short || id)}</h2><p>${esc(f.label || "")}</p>${kindSel}${inputs}${canRunNow(id) && editing() ? `<p class="help">Run it from the key it belongs to (${esc(keys.join(", ") || "none yet")}).</p>` : ""}<details ${editing() ? "" : "open"}><summary>Advanced</summary><dl class="kv">${Object.entries(f).map(([k, v]) => `<dt>${esc(k)}</dt><dd class="mono small">${esc(typeof v === "object" ? JSON.stringify(v) : v)}</dd>`).join("")}<dt>id</dt><dd class="mono">${esc(id)}</dd></dl></details>${editing() && !keys.length ? `<button class="danger small" id="delfix">Delete this fix</button>` : ""}</div>`;
|
||||
let inputs = "";
|
||||
if (editing()) {
|
||||
inputs += `<div class="field"><label>Built as</label><select id="tplsel">${tpls.map((t) => `<option value="${t.name}" ${t.name === tplName ? "selected" : ""}>${esc(t.words)}</option>`).join("")}</select></div>`;
|
||||
inputs += `<div id="tplform">${paramsHtml(tpl, f.params || {}, "fx")}</div><p id="tplmsg" class="help"></p>`;
|
||||
inputs += inputHtml(`fixes.${id}.label`, F("fixes.*.label"), f.label) + inputHtml(`fixes.${id}.short`, F("fixes.*.short"), f.short);
|
||||
if (kindOnly) inputs += (state.schema.fix_kind_fields[tpl.kind] || []).map((fl) => inputHtml(`fixes.${id}.${fl}`, F("fixes.*." + fl), f[fl])).join("");
|
||||
if (tplName === "custom") {
|
||||
inputs += `<div class="field"><label>Runs on</label><select data-path="fixes.${esc(id)}.kind" class="kindsel">${["ssh", "local"].map((k) => `<option ${kind === k ? "selected" : ""}>${k === "ssh" ? "another computer" : "this Mac"}</option>`).join("")}</select></div>`;
|
||||
if (kind === "ssh") inputs += `<div class="field"><label>Computer</label><select data-path="fixes.${esc(id)}.host"><option value="">–</option>${Object.entries(c.hosts).filter(([, h]) => !h.self).map(([a, h]) => `<option value="${esc(a)}" ${f.host === a ? "selected" : ""}>${esc(h.name || a)}</option>`).join("")}</select></div>`;
|
||||
inputs += `<div class="field"><label>Command (typed by hand; shown as a command at Review; never run before Apply)</label><textarea data-path="fixes.${esc(id)}.run" rows="3" class="mono">${esc(f.run || "")}</textarea><div class="help">zsh on a Mac, cmd.exe on a PC.</div></div>`;
|
||||
} else if (f.run) inputs += `<div class="field"><label>The command it makes (${f.kind === "local" ? "this Mac" : "on " + esc((c.hosts[f.host] || {}).name || f.host)})</label><pre class="log small" style="max-height:160px">${esc(f.run)}</pre></div>`;
|
||||
inputs += `<details><summary>Advanced</summary>${["reconnect", "conn", "restart_ok", "never_auto", "timeout"].map((fl) => inputHtml(`fixes.${id}.${fl}`, F("fixes.*." + fl), f[fl])).join("")}</details>`;
|
||||
} else inputs = `<p class="small muted">Built as: ${esc(tpl.words)}${f.run ? `</p><pre class="log small" style="max-height:160px">${esc(f.run)}</pre>` : "</p>"}`;
|
||||
detail = `<div class="card"><h2>${esc(f.short || id)}</h2><p>${esc(f.label || "")}</p>${inputs}${canRunNow(id) && editing() ? `<p class="help">Run it from the key it belongs to (${esc(keys.join(", ") || "none yet")}).</p>` : ""}<details><summary>Everything stored</summary><dl class="kv">${Object.entries(f).map(([k, v]) => `<dt>${esc(k)}</dt><dd class="mono small">${esc(typeof v === "object" ? JSON.stringify(v) : v)}</dd>`).join("")}<dt>id</dt><dd class="mono">${esc(id)}</dd></dl></details>${editing() && !keys.length ? `<button class="danger small" id="delfix">Delete this fix</button>` : ""}</div>`;
|
||||
}
|
||||
const add = editing() ? `<div class="row"><input id="newfix" placeholder="new fix id (letters, digits, _)"><button id="addfix" class="small">+ New fix</button></div>` : "";
|
||||
main.innerHTML = `<h1>Fixes</h1><p class="what">Everything a hold can run. ${Object.keys(c.fixes).length} today.</p>${add}<div class="tablewrap"><table><tr><th>key text</th><th>plain words</th><th>built as</th><th>used by</th><th>by itself</th></tr>${rows}</table></div>${detail}`;
|
||||
const ks = main.querySelector("select.kindsel"); if (ks) ks.onchange = () => { setPath(ks.dataset.path, ks.value); route(); };
|
||||
const ks = main.querySelector("select.kindsel"); if (ks) ks.onchange = () => { setPath(ks.dataset.path, ks.value === "another computer" ? "ssh" : ks.value === "this Mac" ? "local" : ks.value); route(); };
|
||||
const rebuild = async () => {
|
||||
if (!ensureDraft()) return;
|
||||
const fx = state.draft.config.fixes[id], tplName = main.querySelector("#tplsel").value, tpl = state.builders.fixes.find((t) => t.name === tplName);
|
||||
const params = readParams(main.querySelector("#tplform"), tpl);
|
||||
if (tplName === "custom") { fx.template = "custom"; delete fx.params; saveDraft(); route(); return; }
|
||||
const r = await post("/api/build", { fix: Object.assign({}, fx, { template: tplName, params }) });
|
||||
const msg = main.querySelector("#tplmsg");
|
||||
if (!r.ok) { msg.textContent = r.error; fx.template = tplName; fx.params = params; saveDraft(); return; }
|
||||
const keep = { label: fx.label, short: fx.short };
|
||||
Object.assign(fx, r.fix); for (const k of ["run", "host"]) if (!(k in r.fix)) delete fx[k];
|
||||
if (!keep.label && r.label) fx.label = r.label;
|
||||
msg.textContent = "";
|
||||
saveDraft(); route();
|
||||
};
|
||||
const ts = main.querySelector("#tplsel"); if (ts) { ts.onchange = rebuild; for (const el of main.querySelectorAll("#tplform [data-param]")) el.onchange = rebuild; }
|
||||
const del = main.querySelector("#delfix"); if (del) del.onclick = () => confirmBox(`Delete the fix ${id}? No key uses it.`, "Delete", () => { if (!ensureDraft()) return; delete state.draft.config.fixes[id]; const af2 = state.draft.config.auto_fix.fixes || []; state.draft.config.auto_fix.fixes = af2.filter((x) => x !== id); saveDraft(); location.hash = "#fixes"; });
|
||||
const af = main.querySelector("#addfix"); if (af) af.onclick = () => { const nid = main.querySelector("#newfix").value.trim(); if (!/^[A-Za-z0-9_]+$/.test(nid) || c.fixes[nid]) return alertBox("A new id: letters, digits and _ only, not in use."); if (!ensureDraft()) return; state.draft.config.fixes[nid] = { kind: "local", short: nid.toUpperCase().slice(0, 12), label: "", run: "" }; saveDraft(); location.hash = "#fixes/" + nid; };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user