Drafts rebase onto the live settings; the review speaks in words
A draft started before someone else's Apply used to carry that older config and would have put the newer changes back. Now every read of the draft replays only the draft's own changes onto the live settings (base config from history), so the review lists exactly what you changed. Values read in words (windows as days and hours), long ones fold open with was/now, commands are no longer red, and warnings the live config already had are not repeated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+95
-12
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user