|
#!/usr/bin/env python3 |
|
"""Rename/merge a player name on a dart-trainer --out-dir (on-device). |
|
|
|
Use when a short name was forced longer (e.g. bj -> bjowol after the 3-char |
|
roster floor). Touches only local durable files under OUT_DIR: |
|
|
|
- stats.db games.player (NOT games.config / summary / throw context) |
|
- highscores.json entry.player |
|
- players.json roster name (+ keeps npub/color) |
|
- identities/*.nsec rename key file if present (backed up first) |
|
|
|
Does NOT talk to central (central merge is separate). Always writes a timestamped |
|
backup of each file it changes before mutating. |
|
|
|
IMPORTANT — run as the data owner, not as root (service User=opendarts): |
|
|
|
sudo systemctl stop dart-trainer |
|
sudo -u "$(stat -c %U /var/lib/dart-trainer)" python3 merge_player_rename.py \\ |
|
/var/lib/dart-trainer bj bjowol --dry-run |
|
sudo -u "$(stat -c %U /var/lib/dart-trainer)" python3 merge_player_rename.py \\ |
|
/var/lib/dart-trainer bj bjowol |
|
# If DT_PLAYER=bj in /etc/dart-trainer/config.env, change it to bjowol |
|
# or the next game recreates the short name via --player default. |
|
sudo systemctl start dart-trainer |
|
|
|
Idempotent: re-running when no source rows remain is a no-op success. |
|
|
|
Does NOT rewrite tournament journals (tournaments/*.jsonl) — stop any live |
|
tournament first. Does NOT rewrite games.config / summary / thrower context; |
|
old recaps may still display the short name inside those blobs (player column |
|
is updated). |
|
""" |
|
from __future__ import annotations |
|
|
|
import argparse |
|
import hashlib |
|
import json |
|
import os |
|
import re |
|
import shutil |
|
import sqlite3 |
|
import sys |
|
import time |
|
|
|
|
|
def _norm(s: str) -> str: |
|
"""Match trainer.identity.normalize_player (strip + lower).""" |
|
return (s or "").strip().lower() |
|
|
|
|
|
def _atomic_write(path: str, text: str) -> None: |
|
"""Write via temp + os.replace so a crash cannot leave a truncated JSON file.""" |
|
d = os.path.dirname(path) or "." |
|
tmp = os.path.join(d, ".%s.tmp.%d" % (os.path.basename(path), os.getpid())) |
|
try: |
|
with open(tmp, "w") as f: |
|
f.write(text) |
|
f.flush() |
|
os.fsync(f.fileno()) |
|
os.replace(tmp, path) |
|
finally: |
|
if os.path.exists(tmp): |
|
try: |
|
os.unlink(tmp) |
|
except OSError: |
|
pass |
|
|
|
|
|
def _backup(path: str, bak_dir: str) -> str | None: |
|
if not os.path.exists(path): |
|
return None |
|
os.makedirs(bak_dir, exist_ok=True) |
|
dest = os.path.join(bak_dir, os.path.basename(path)) |
|
shutil.copy2(path, dest) |
|
for suf in ("-wal", "-shm"): |
|
side = path + suf |
|
if os.path.exists(side): |
|
shutil.copy2(side, dest + suf) |
|
return dest |
|
|
|
|
|
def _count_games(path: str, name: str) -> int: |
|
if not os.path.exists(path): |
|
return 0 |
|
c = sqlite3.connect(path) |
|
try: |
|
row = c.execute( |
|
"SELECT COUNT(*) FROM games WHERE lower(player)=?", (name,) |
|
).fetchone() |
|
return int(row[0]) if row else 0 |
|
finally: |
|
c.close() |
|
|
|
|
|
def _count_highscores(path: str, name: str) -> int: |
|
if not os.path.exists(path): |
|
return 0 |
|
try: |
|
data = json.loads(open(path).read()) |
|
except (OSError, ValueError): |
|
return 0 |
|
n = 0 |
|
|
|
def walk(obj): |
|
nonlocal n |
|
if isinstance(obj, list): |
|
for e in obj: |
|
walk(e) |
|
elif isinstance(obj, dict): |
|
if "player" in obj and isinstance(obj["player"], str): |
|
if _norm(obj["player"]) == name: |
|
n += 1 |
|
for v in obj.values(): |
|
walk(v) |
|
|
|
walk(data) |
|
return n |
|
|
|
|
|
def preview(out: str, src: str, dst: str) -> dict: |
|
"""Read-only counts for dry-run / pre-flight print.""" |
|
stats = os.path.join(out, "stats.db") |
|
hs = os.path.join(out, "highscores.json") |
|
roster = os.path.join(out, "players.json") |
|
idir = os.path.join(out, "identities") |
|
src_nsec = os.path.join(idir, _identity_filename(src)) if os.path.isdir(idir) else "" |
|
dst_nsec = os.path.join(idir, _identity_filename(dst)) if os.path.isdir(idir) else "" |
|
roster_src = roster_dst = False |
|
if os.path.exists(roster): |
|
try: |
|
rows = json.loads(open(roster).read()) |
|
if isinstance(rows, list): |
|
roster_src = any(_norm(r.get("name")) == src for r in rows) |
|
roster_dst = any(_norm(r.get("name")) == dst for r in rows) |
|
except (OSError, ValueError): |
|
pass |
|
return { |
|
"games_src": _count_games(stats, src), |
|
"games_dst": _count_games(stats, dst), |
|
"highscores_src": _count_highscores(hs, src), |
|
"highscores_dst": _count_highscores(hs, dst), |
|
"roster_src": roster_src, |
|
"roster_dst": roster_dst, |
|
"nsec_src": bool(src_nsec and os.path.exists(src_nsec)), |
|
"nsec_dst": bool(dst_nsec and os.path.exists(dst_nsec)), |
|
"uid": os.stat(out).st_uid if os.path.isdir(out) else None, |
|
"euid": os.geteuid(), |
|
} |
|
|
|
|
|
def print_preview(src: str, dst: str, p: dict) -> None: |
|
print( |
|
" stats.db: %d games move %s → %s (%s already has %d)" |
|
% (p["games_src"], src, dst, dst, p["games_dst"]) |
|
) |
|
print( |
|
" highscores: %d entries move %s → %s (%s already has %d)" |
|
% (p["highscores_src"], src, dst, dst, p["highscores_dst"]) |
|
) |
|
print( |
|
" roster: src=%s dst=%s" |
|
% ( |
|
"yes" if p["roster_src"] else "no", |
|
"yes" if p["roster_dst"] else "no", |
|
) |
|
) |
|
print( |
|
" identities: src.nsec=%s dst.nsec=%s" |
|
% ( |
|
"yes" if p["nsec_src"] else "no", |
|
"yes" if p["nsec_dst"] else "no", |
|
) |
|
) |
|
if p.get("euid") == 0: |
|
print( |
|
" WARN: running as root — prefer:\n" |
|
" sudo -u \"$(stat -c %%U %s)\" python3 …" |
|
% "/var/lib/dart-trainer" |
|
) |
|
print( |
|
" NOTE: games.config / summary / thrower context keep the old name " |
|
"(recaps stay self-consistent; player column is updated)." |
|
) |
|
print( |
|
" NOTE: after rename, set DT_PLAYER=%s in /etc/dart-trainer/config.env " |
|
"if it still says %s — else the next game recreates the short name." |
|
% (dst, src) |
|
) |
|
|
|
|
|
def merge_stats_db(path: str, src: str, dst: str) -> int: |
|
if not os.path.exists(path): |
|
print(" stats.db: missing, skip") |
|
return 0 |
|
c = sqlite3.connect(path) |
|
try: |
|
before = c.execute( |
|
"SELECT player, COUNT(*) FROM games WHERE lower(player)=? GROUP BY 1", |
|
(src,), |
|
).fetchall() |
|
print(" stats.db games BEFORE (src):", before) |
|
cur = c.execute( |
|
"UPDATE games SET player=? WHERE lower(player)=?", (dst, src) |
|
) |
|
n = cur.rowcount |
|
c.execute( |
|
"UPDATE games SET player=? WHERE lower(player)=? AND player!=?", |
|
(dst, dst, dst), |
|
) |
|
c.commit() |
|
after = c.execute( |
|
"SELECT player, COUNT(*) FROM games WHERE lower(player) IN (?,?) GROUP BY 1", |
|
(src, dst), |
|
).fetchall() |
|
print(" stats.db games AFTER:", after, "updated_rows=", n) |
|
return n |
|
finally: |
|
c.close() |
|
|
|
|
|
def merge_highscores(path: str, src: str, dst: str) -> int: |
|
if not os.path.exists(path): |
|
print(" highscores.json: missing, skip") |
|
return 0 |
|
try: |
|
data = json.loads(open(path).read()) |
|
except ValueError as e: |
|
print(" highscores.json: parse error, aborting this file:", e, file=sys.stderr) |
|
raise |
|
n = 0 |
|
|
|
def walk(obj): |
|
nonlocal n |
|
if isinstance(obj, list): |
|
for e in obj: |
|
walk(e) |
|
elif isinstance(obj, dict): |
|
if "player" in obj and isinstance(obj["player"], str): |
|
p = obj["player"] |
|
if _norm(p) == src or (_norm(p) == dst and p != dst): |
|
if _norm(p) == src: |
|
n += 1 |
|
obj["player"] = dst |
|
for v in obj.values(): |
|
walk(v) |
|
|
|
walk(data) |
|
_atomic_write(path, json.dumps(data, separators=(",", ":"))) |
|
print(" highscores.json renamed entries:", n) |
|
return n |
|
|
|
|
|
def merge_roster(path: str, src: str, dst: str) -> int: |
|
if not os.path.exists(path): |
|
print(" players.json: missing, skip") |
|
return 0 |
|
try: |
|
rows = json.loads(open(path).read()) |
|
except ValueError as e: |
|
print(" players.json: parse error, aborting this file:", e, file=sys.stderr) |
|
raise |
|
if not isinstance(rows, list): |
|
print(" players.json: unexpected shape, skip") |
|
return 0 |
|
src_row = next((r for r in rows if _norm(r.get("name")) == src), None) |
|
dst_row = next((r for r in rows if _norm(r.get("name")) == dst), None) |
|
n = 0 |
|
if src_row and not dst_row: |
|
src_row["name"] = dst |
|
n = 1 |
|
print(" players.json: renamed row", src, "->", dst) |
|
elif src_row and dst_row: |
|
if not dst_row.get("npub") and src_row.get("npub"): |
|
dst_row["npub"] = src_row["npub"] |
|
if not dst_row.get("color") and src_row.get("color"): |
|
dst_row["color"] = src_row["color"] |
|
dst_row["last_seen"] = max( |
|
float(dst_row.get("last_seen") or 0), |
|
float(src_row.get("last_seen") or 0), |
|
) |
|
rows = [r for r in rows if _norm(r.get("name")) != src] |
|
n = 1 |
|
print(" players.json: merged src into dst, dropped src") |
|
else: |
|
print(" players.json: no src row (ok if already renamed)") |
|
for r in rows: |
|
if _norm(r.get("name")) == dst and r.get("name") != dst: |
|
r["name"] = dst |
|
n += 1 |
|
_atomic_write(path, json.dumps(rows)) |
|
return n |
|
|
|
|
|
def _identity_filename(name: str) -> str: |
|
"""Mirror trainer.identity._key_path sanitization (basename only).""" |
|
norm = _norm(name) |
|
safe = re.sub(r"[^a-z0-9_-]", "", norm) or "player" |
|
if safe != norm: |
|
safe += "-" + hashlib.sha256(norm.encode()).hexdigest()[:6] |
|
return safe + ".nsec" |
|
|
|
|
|
def merge_identity_file(out_dir: str, src: str, dst: str, bak_dir: str) -> int: |
|
idir = os.path.join(out_dir, "identities") |
|
if not os.path.isdir(idir): |
|
print(" identities/: missing, skip") |
|
return 0 |
|
sp = os.path.join(idir, _identity_filename(src)) |
|
dp = os.path.join(idir, _identity_filename(dst)) |
|
if os.path.exists(sp) and not os.path.exists(dp): |
|
# backup before rename (undo is mv back; also copy into bak_dir) |
|
_backup(sp, bak_dir) |
|
os.rename(sp, dp) |
|
print( |
|
" identities: renamed", |
|
os.path.basename(sp), |
|
"->", |
|
os.path.basename(dp), |
|
"(copy in backup; undo: mv the .nsec back)", |
|
) |
|
return 1 |
|
if os.path.exists(sp) and os.path.exists(dp): |
|
print( |
|
" identities: BOTH src and dst key files exist — left untouched " |
|
"(resolve manually; do not clobber nsec)" |
|
) |
|
return 0 |
|
print(" identities: no src key file") |
|
return 0 |
|
|
|
|
|
def main() -> int: |
|
ap = argparse.ArgumentParser( |
|
description=__doc__, |
|
formatter_class=argparse.RawDescriptionHelpFormatter, |
|
) |
|
ap.add_argument("out_dir", help="trainer --out-dir (e.g. /var/lib/dart-trainer)") |
|
ap.add_argument("src", help="old name (e.g. bj)") |
|
ap.add_argument("dst", help="new name (e.g. bjowol)") |
|
ap.add_argument( |
|
"--dry-run", |
|
action="store_true", |
|
help="print counts only; no backup, no writes", |
|
) |
|
ap.add_argument( |
|
"--yes", |
|
action="store_true", |
|
help="skip interactive confirm when dst already has games (typo guard)", |
|
) |
|
args = ap.parse_args() |
|
src, dst = _norm(args.src), _norm(args.dst) |
|
if not src or not dst or src == dst: |
|
print( |
|
"src and dst must be distinct non-empty normalized names", |
|
file=sys.stderr, |
|
) |
|
return 2 |
|
if len(dst) < 3: |
|
print("dst must be >= 3 chars (roster floor)", file=sys.stderr) |
|
return 2 |
|
out = os.path.abspath(args.out_dir) |
|
if not os.path.isdir(out): |
|
print("out_dir not a directory:", out, file=sys.stderr) |
|
return 2 |
|
|
|
print("OUT", out) |
|
print("RENAME", src, "->", dst) |
|
p = preview(out, src, dst) |
|
print_preview(src, dst, p) |
|
|
|
if args.dry_run: |
|
print("dry-run: no changes written") |
|
return 0 |
|
|
|
if p["games_src"] == 0 and p["highscores_src"] == 0 and not p["roster_src"] and not p["nsec_src"]: |
|
print("nothing to do (src already gone)") |
|
return 0 |
|
|
|
# Typo guard: merging into a non-empty destination needs --yes or a TTY confirm |
|
if p["games_dst"] > 0 and not args.yes: |
|
if sys.stdin.isatty(): |
|
ans = input( |
|
"dst %r already has %d games — merge into that player? [y/N] " |
|
% (dst, p["games_dst"]) |
|
) |
|
if ans.strip().lower() not in ("y", "yes"): |
|
print("aborted") |
|
return 1 |
|
else: |
|
print( |
|
"dst already has games; pass --yes to merge non-interactively", |
|
file=sys.stderr, |
|
) |
|
return 1 |
|
|
|
bak_dir = os.path.join( |
|
out, |
|
"backup-merge-%s-to-%s-%s" |
|
% (src, dst, time.strftime("%Y%m%d-%H%M%S")), |
|
) |
|
os.makedirs(bak_dir, exist_ok=True) |
|
print("backup dir", bak_dir) |
|
for name in ("stats.db", "highscores.json", "players.json"): |
|
pth = os.path.join(out, name) |
|
if _backup(pth, bak_dir): |
|
print(" backed up", name) |
|
|
|
try: |
|
merge_stats_db(os.path.join(out, "stats.db"), src, dst) |
|
merge_highscores(os.path.join(out, "highscores.json"), src, dst) |
|
merge_roster(os.path.join(out, "players.json"), src, dst) |
|
merge_identity_file(out, src, dst, bak_dir) |
|
except Exception as e: |
|
print("FAILED:", e, file=sys.stderr) |
|
print("Restore from", bak_dir, "if needed", file=sys.stderr) |
|
return 1 |
|
|
|
print("DONE") |
|
print( |
|
" 1) grep DT_PLAYER /etc/dart-trainer/config.env # set to %s if it was %s" |
|
% (dst, src) |
|
) |
|
print(" 2) restart dart-trainer (or flip mode via dart-switch if you use it)") |
|
print( |
|
" 3) verify: python3 -c \"import sqlite3;print(sqlite3.connect(" |
|
"'%s/stats.db').execute(" |
|
"\\\"SELECT player,COUNT(*) FROM games WHERE lower(player) LIKE 'bj%%' " |
|
"GROUP BY 1\\\").fetchall())\"" |
|
% out |
|
) |
|
print( |
|
" Undo nsec: mv identities/%s identities/%s (also under backup dir)" |
|
% (_identity_filename(dst), _identity_filename(src)) |
|
) |
|
return 0 |
|
|
|
|
|
if __name__ == "__main__": |
|
raise SystemExit(main()) |