diff --git a/settings/apply.py b/settings/apply.py index 16d354c..de3ce64 100644 --- a/settings/apply.py +++ b/settings/apply.py @@ -176,22 +176,105 @@ def words_for(path, before, after, cfg_before, cfg_after): elif where.endswith(field): where = where[:-len(field)] + field.replace("_", " ") - def val(v): - if v is None: - return "—" - if isinstance(v, bool): - return "yes" if v else "no" - if isinstance(v, (dict, list)): - s = json.dumps(v, ensure_ascii=False) - return s if len(s) < 80 else s[:77] + "..." - return str(v) + val = plain_value secret = any(f["secret"] for f in schema.F.values() if f["path"].endswith(field)) if secret: return {"path": path, "where": where, "text": "%s changed" % where, "command": False} - text = "%s: %s → %s" % (where, val(before), val(after)) if before is not None and after is not None else \ - ("%s added: %s" % (where, val(after)) if before is None else "%s removed (was %s)" % (where, val(before))) command = field in ("run", "cmd", "match") or path.endswith("pref.want") - return {"path": path, "where": where, "text": text, "help": help_, "command": command} + b, a = val(before), val(after) + long_ = command or len(b) + len(a) > 90 + if before is not None and after is not None: + text = "%s changed" % where if long_ else "%s: %s → %s" % (where, b, a) + elif before is None: + text = "%s added" % where if long_ else "%s added: %s" % (where, a) + else: + text = "%s removed" % where if long_ else "%s removed (was %s)" % (where, b) + out = {"path": path, "where": where, "text": text, "help": help_, "command": command} + if long_: + out["before"], out["after"] = (b if before is not None else None), (a if after is not None else None) + return out + + +def hhmm12(t): + m = re.match(r"^(\d{1,2}):(\d{2})$", str(t or "")) + if not m: + return str(t) + h, mm = int(m.group(1)), int(m.group(2)) + if h == 24 and mm == 0: + return "midnight" + return "%d%s %s" % (h % 12 or 12, (":%02d" % mm) if mm else "", "am" if h < 12 else "pm") + + +def plain_value(v): + """A value in words: windows as days and hours, lists joined, records as 'field value' pairs.""" + if v is None: + return "—" + if isinstance(v, bool): + return "yes" if v else "no" + if isinstance(v, list): + if v and all(isinstance(w, dict) and "days" in w and "start" in w for w in v): + return "; ".join("%s %s – %s" % (", ".join(d[:1].upper() + d[1:] for d in w.get("days", [])), hhmm12(w.get("start")), hhmm12(w.get("end"))) for w in v) or "never" + if all(isinstance(x, (str, int, float)) for x in v): + return ", ".join(str(x) for x in v) or "none" + return "; ".join(plain_value(x) for x in v) + if isinstance(v, dict): + return ", ".join("%s %s" % (k.replace("_", " "), plain_value(x)) for k, x in v.items()) or "nothing" + return str(v) + + +def set_path(cfg, path, value): + """Put a value at a changed_paths() path (tiles[hd2].checks.0.optional, hosts.prop.ssh ...); None removes it.""" + tokens = re.findall(r"\[([^\]]+)\]|([^.\[\]]+)", path) + node = cfg + for i, (idx, key) in enumerate(tokens): + last = i == len(tokens) - 1 + if idx: # a record in an id list + rec = next((x for x in node if isinstance(x, dict) and x.get("id") == idx), None) + if last: + if value is None: + if rec is not None: + node.remove(rec) + elif rec is None: + node.append(value) + else: + rec.clear(); rec.update(value) + return + if rec is None: + rec = {"id": idx}; node.append(rec) + node = rec + elif isinstance(node, list): + k = int(key) + if last: + if value is None: + if k < len(node): + node.pop(k) + elif k < len(node): + node[k] = value + else: + node.append(value) + return + while len(node) <= k: + node.append({}) + node = node[k] + else: + if last: + if value is None: + node.pop(key, None) + else: + node[key] = value + return + if key not in node or node[key] is None: + node[key] = [] if (i + 1 < len(tokens) and (tokens[i + 1][0] or tokens[i + 1][1].isdigit())) else {} + node = node[key] + + +def rebase(draft, live_cfg, base_cfg): + """A draft started on an older config: its own changes, replayed on the live config.""" + merged = json.loads(json.dumps(live_cfg)) + changes = changed_paths(base_cfg, draft["config"]) + for path, _, after in changes: + set_path(merged, path, after) + return merged, changes def what_apply_does(cfg_before, cfg_after, hashes_before): diff --git a/settings/server.py b/settings/server.py index 14e6dd8..aef10a6 100644 --- a/settings/server.py +++ b/settings/server.py @@ -645,7 +645,7 @@ class Handler(http.server.BaseHTTPRequestHandler): changes = [applying.words_for(p, a, b, cfg, live_cfg) for p, a, b in applying.changed_paths(cfg, live_cfg)] return self.send(200, {"config": schema.mask_secrets(cfg), "changes_to_now": changes}) if path == "/api/draft": - d = site.draft.read() + d = self.draft_now() return self.send(200, {"draft": self.masked_draft(d) if d else None, "live_sha": site.cfg_sha, "stale": bool(d and d.get("base_sha") != site.cfg_sha), "atem_pending": os.path.exists(os.path.join(site.data_dir, "draft-atem.xml"))}) @@ -667,7 +667,7 @@ class Handler(http.server.BaseHTTPRequestHandler): snap = site.read_json("snapshot.json", {}) or {} return self.send(200, {"local": snap.get("midi"), "remote": snap.get("remote_midi")}) if path == "/api/site/names": - return self.send(200, self.names_check((site.draft.read() or {}).get("config") or site.load())) + return self.send(200, self.names_check((self.draft_now() or {}).get("config") or site.load())) if path == "/api/outlets": d = outlets.load() t = d.get("tuya") or {} @@ -795,7 +795,7 @@ class Handler(http.server.BaseHTTPRequestHandler): 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() + cfg = (self.draft_now() or {}).get("config") or site.load() try: if path == "/api/build": fix = builders.build(cfg, body.get("fix") or {}) @@ -804,10 +804,10 @@ class Handler(http.server.BaseHTTPRequestHandler): 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()) + cfg = schema.unmask(body.get("config") or (self.draft_now() or {}).get("config") or site.load(), site.load()) return self.send(200, self.check(cfg)) if path == "/api/apply": - draft = site.draft.read() + draft = self.draft_now() if not draft: return self.send(400, {"error": "there is no draft to apply"}) if draft.get("base_sha") != site.cfg_sha: @@ -1071,6 +1071,29 @@ class Handler(http.server.BaseHTTPRequestHandler): raise ValueError("no keys in it") return cfg + def draft_now(self): + """The draft, rebased onto the live settings when they moved on since it was started.""" + site = self.site + d = site.draft.read() + if not d or d.get("base_sha") == site.cfg_sha: + return d + base = None + for e in applying.history_entries(site.data_dir): + meta = site.read_json(os.path.join("history", e["id"] + ".meta.json"), {}) or {} + if meta.get("sha_before") == d.get("base_sha"): + base = applying.history_config(site.data_dir, e["id"]) + break + if base is None: + return d # stale: the page says so + merged, changes = applying.rebase(d, site.load(), base) + nd, conflict = site.draft.update(merged, d.get("by", "?"), site.cfg_sha, expect=d.get("version")) + if nd is None: + return conflict + nd["started"], nd["started_by"] = d.get("started", nd["started"]), d.get("started_by", nd["started_by"]) + sp.write_json(site.draft.path, nd) + log.info("draft by %s rebased onto the live settings (%d of its own changes kept)", d.get("by"), len(changes)) + return nd + def masked_draft(self, draft): return dict(draft, config=schema.mask_secrets(draft["config"])) @@ -1083,6 +1106,8 @@ class Handler(http.server.BaseHTTPRequestHandler): except Exception: conns = None errors, warnings = schema.validate(cfg, {"connections": conns} if conns else None) + already = set(schema.validate(live_cfg)[1]) + warnings = [w for w in warnings if w not in already] # only what this change adds tmp = tempfile.NamedTemporaryFile("wb", suffix=".json", delete=False) tmp.write(applying.config_text(cfg)); tmp.close() try: diff --git a/settings/ui/app.js b/settings/ui/app.js index 9e37317..0bb60e4 100644 --- a/settings/ui/app.js +++ b/settings/ui/app.js @@ -676,11 +676,13 @@ return; } const r = await post("/api/validate", {}); - const changes = r.changes.map((c) => `
Started by ${esc(state.draft.started_by || state.draft.by || "?")}${state.draft.started ? " at " + esc(when(state.draft.started)) : ""}. ${state.draftStale ? "The live settings changed since: look again before applying." : ""}
+ main.innerHTML = `The live settings changed since this draft was started and could not be merged: look through the list before applying.
" : ""}No change yet.
"}Then: ${esc(r.feel)}
${errors ? `