2b399fdb81
PR #1223 only staged the deletions of the old paths because the new top-level directories were still untracked when the commit was authored. This commit adds the actual restructured tree: SREBOT/ (existing bot), SHARED/ (vromfs, data_parser, ICONS/MAPS/FONTS, DAGOR_FILES, update_game_files), and TSSBOT/ (skeleton). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
740 lines
28 KiB
Python
740 lines
28 KiB
Python
"""
|
|
One-shot migration: switch SREBOT storage to use clan_id (numeric squadron UID)
|
|
as the canonical identifier across DB tables and preference JSON files.
|
|
|
|
Goals
|
|
-----
|
|
1. Add `clan_id` to historical tables (player_games_hist, match_summary).
|
|
2. Rebuild points.db tables (profile_member_points, profile_totals,
|
|
game_cache) so their PKs include `clan_id` instead of squadron text.
|
|
3. Rebuild wl.db tables to use clan_id (currently empty so trivial).
|
|
4. Add `squadron_name_history` table to squadrons.db (for old-name → clan_id
|
|
redirects when a squadron renames). Seed with current squadrons_data.
|
|
5. Re-key every PREFERENCES/<guild_id>-preferences.json so squadron entries
|
|
are keyed by `str(clan_id)` instead of long_name. Special wildcard keys
|
|
("Global", "everything", "all", "*") are preserved.
|
|
6. Backups: write a tarball of STORAGE/ before any change. Each preferences
|
|
file gets copied to PREFERENCES_BACKUP_<timestamp>/.
|
|
|
|
Run with `--dry-run` first. The script prints exactly what it would do.
|
|
|
|
Usage
|
|
-----
|
|
source .venv/bin/activate
|
|
python scripts/migrate_clan_id.py --dry-run
|
|
python scripts/migrate_clan_id.py --apply
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import sqlite3
|
|
import sys
|
|
import tarfile
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
)
|
|
log = logging.getLogger("migrate_clan_id")
|
|
|
|
|
|
# Auto-load the repo's .env so SREBOT_STORAGE_VOL_PATH and friends resolve when
|
|
# the script is run directly (PM2/services already get them from .env).
|
|
try:
|
|
from dotenv import load_dotenv
|
|
|
|
_here = Path(__file__).resolve()
|
|
for candidate in (_here.parent / ".env", _here.parent.parent / ".env"):
|
|
if candidate.exists():
|
|
load_dotenv(candidate)
|
|
break
|
|
except ImportError:
|
|
pass
|
|
|
|
_storage_env = os.environ.get("SREBOT_STORAGE_VOL_PATH", "").strip()
|
|
if not _storage_env:
|
|
raise RuntimeError("SREBOT_STORAGE_VOL_PATH must be set")
|
|
STORAGE_DIR = Path(_storage_env)
|
|
|
|
SQ_BATTLES_DB = STORAGE_DIR / "sq_battles.db"
|
|
SQUADRONS_DB = STORAGE_DIR / "squadrons.db"
|
|
POINTS_DB = STORAGE_DIR / "points.db"
|
|
WL_DB = STORAGE_DIR / "wl.db"
|
|
PREFS_DIR = STORAGE_DIR / "PREFERENCES"
|
|
|
|
WILDCARD_KEYS = {"global", "everything", "all", "*"}
|
|
PGH_BATCH = 50_000
|
|
|
|
|
|
def backup_storage() -> Path:
|
|
ts = time.strftime("%Y%m%d-%H%M%S")
|
|
backup_path = STORAGE_DIR.parent / f"STORAGE_BACKUP_{ts}.tar.gz"
|
|
log.info("Creating tarball backup at %s (this may take a while)...", backup_path)
|
|
with tarfile.open(backup_path, "w:gz") as tar:
|
|
for db in (SQ_BATTLES_DB, SQUADRONS_DB, POINTS_DB, WL_DB):
|
|
if db.exists():
|
|
tar.add(db, arcname=db.name)
|
|
if PREFS_DIR.exists():
|
|
tar.add(PREFS_DIR, arcname=PREFS_DIR.name)
|
|
log.info("Backup complete: %s", backup_path)
|
|
return backup_path
|
|
|
|
|
|
def load_squadron_index() -> tuple[dict[str, int], dict[str, int], dict[int, dict[str, Any]]]:
|
|
"""Return (long_name_lower -> clan_id, short_name_lower -> clan_id, clan_id -> row)."""
|
|
long_to_id: dict[str, int] = {}
|
|
short_to_id: dict[str, int] = {}
|
|
by_id: dict[int, dict[str, Any]] = {}
|
|
con = sqlite3.connect(SQUADRONS_DB)
|
|
try:
|
|
con.row_factory = sqlite3.Row
|
|
for row in con.execute(
|
|
"SELECT clan_id, long_name, short_name, tag_name FROM squadrons_data"
|
|
):
|
|
cid = int(row["clan_id"])
|
|
by_id[cid] = dict(row)
|
|
if row["long_name"]:
|
|
long_to_id[row["long_name"].lower()] = cid
|
|
if row["short_name"]:
|
|
short_to_id[row["short_name"].lower()] = cid
|
|
finally:
|
|
con.close()
|
|
log.info("Loaded %d squadrons from squadrons_data", len(by_id))
|
|
return long_to_id, short_to_id, by_id
|
|
|
|
|
|
def ensure_squadron_name_history(apply: bool, by_id: dict[int, dict[str, Any]]) -> None:
|
|
"""Create squadron_name_history table and seed from current squadrons_data.
|
|
|
|
Schema:
|
|
clan_id INTEGER NOT NULL
|
|
long_name TEXT NOT NULL
|
|
first_seen INTEGER NOT NULL (unix)
|
|
last_seen INTEGER NOT NULL (unix)
|
|
PRIMARY KEY (clan_id, long_name)
|
|
"""
|
|
con = sqlite3.connect(SQUADRONS_DB)
|
|
try:
|
|
existing = con.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='squadron_name_history'"
|
|
).fetchone()
|
|
if existing:
|
|
log.info("squadron_name_history already exists; skipping create")
|
|
else:
|
|
log.info("Creating squadron_name_history table")
|
|
if apply:
|
|
con.executescript(
|
|
"""
|
|
CREATE TABLE squadron_name_history (
|
|
clan_id INTEGER NOT NULL,
|
|
long_name TEXT NOT NULL,
|
|
first_seen INTEGER NOT NULL,
|
|
last_seen INTEGER NOT NULL,
|
|
PRIMARY KEY (clan_id, long_name)
|
|
);
|
|
CREATE INDEX idx_snh_long_name ON squadron_name_history(long_name COLLATE NOCASE);
|
|
CREATE INDEX idx_snh_clan_id ON squadron_name_history(clan_id);
|
|
"""
|
|
)
|
|
|
|
now = int(time.time())
|
|
rows = [
|
|
(cid, row["long_name"], now, now)
|
|
for cid, row in by_id.items()
|
|
if row.get("long_name")
|
|
]
|
|
log.info("Seeding squadron_name_history with %d (clan_id, long_name) pairs", len(rows))
|
|
if apply and rows:
|
|
con.executemany(
|
|
"""
|
|
INSERT INTO squadron_name_history (clan_id, long_name, first_seen, last_seen)
|
|
VALUES (?, ?, ?, ?)
|
|
ON CONFLICT(clan_id, long_name) DO UPDATE SET last_seen = excluded.last_seen
|
|
""",
|
|
rows,
|
|
)
|
|
con.commit()
|
|
finally:
|
|
con.close()
|
|
|
|
|
|
def add_squadrons_points_index(apply: bool) -> None:
|
|
con = sqlite3.connect(SQUADRONS_DB)
|
|
try:
|
|
log.info("Adding clan_id index on squadrons_points")
|
|
if apply:
|
|
con.executescript(
|
|
"""
|
|
CREATE INDEX IF NOT EXISTS idx_squadrons_points_clanid_time
|
|
ON squadrons_points(clan_id, unix_time);
|
|
"""
|
|
)
|
|
con.commit()
|
|
finally:
|
|
con.close()
|
|
|
|
|
|
def migrate_player_games_hist(
|
|
apply: bool, long_to_id: dict[str, int]
|
|
) -> None:
|
|
"""Add clan_id INTEGER column + backfill via long_name lookup. Add index."""
|
|
con = sqlite3.connect(SQ_BATTLES_DB)
|
|
try:
|
|
con.execute("PRAGMA journal_mode=WAL")
|
|
cols = [r[1] for r in con.execute("PRAGMA table_info(player_games_hist)").fetchall()]
|
|
if "clan_id" not in cols:
|
|
log.info("Adding clan_id column to player_games_hist")
|
|
if apply:
|
|
con.execute("ALTER TABLE player_games_hist ADD COLUMN clan_id INTEGER")
|
|
con.commit()
|
|
else:
|
|
log.info("player_games_hist.clan_id already present")
|
|
|
|
# Refresh after the conditional ALTER.
|
|
cols = [r[1] for r in con.execute("PRAGMA table_info(player_games_hist)").fetchall()]
|
|
total = con.execute("SELECT COUNT(*) FROM player_games_hist").fetchone()[0]
|
|
if "clan_id" in cols:
|
|
unbackfilled = con.execute(
|
|
"SELECT COUNT(*) FROM player_games_hist WHERE clan_id IS NULL"
|
|
).fetchone()[0]
|
|
else:
|
|
unbackfilled = total
|
|
log.info(
|
|
"player_games_hist: %d total rows, %d need backfill",
|
|
total,
|
|
unbackfilled,
|
|
)
|
|
|
|
if apply and unbackfilled:
|
|
# Faster path: pre-build a name → clan_id map in Python and run one
|
|
# indexed UPDATE per distinct squadron_name. Each UPDATE hits the
|
|
# idx_pgh_squadron_name index. ~1500 small ops vs one giant
|
|
# correlated subquery. squadron_name is the WT short_name (after
|
|
# alphanum strip), but very rarely a long_name leaks through, so
|
|
# we accept matches against either.
|
|
log.info("Building name → clan_id map in Python...")
|
|
sq_con = sqlite3.connect(f"file:{SQUADRONS_DB}?mode=ro", uri=True)
|
|
try:
|
|
sq_con.row_factory = sqlite3.Row
|
|
name_to_id: dict[str, int] = {}
|
|
for row in sq_con.execute(
|
|
"SELECT clan_id, long_name, short_name FROM squadrons_data"
|
|
):
|
|
cid = int(row["clan_id"])
|
|
if row["long_name"]:
|
|
name_to_id.setdefault(row["long_name"].lower(), cid)
|
|
if row["short_name"]:
|
|
name_to_id.setdefault(row["short_name"].lower(), cid)
|
|
finally:
|
|
sq_con.close()
|
|
|
|
log.info("Fetching distinct squadron_name values from player_games_hist...")
|
|
distinct_names = [
|
|
r[0] for r in con.execute(
|
|
"SELECT DISTINCT squadron_name FROM player_games_hist WHERE clan_id IS NULL"
|
|
).fetchall()
|
|
if r[0]
|
|
]
|
|
log.info("Distinct unbackfilled squadron_names: %d", len(distinct_names))
|
|
|
|
updates = [
|
|
(name_to_id[n.lower()], n)
|
|
for n in distinct_names
|
|
if n.lower() in name_to_id
|
|
]
|
|
log.info("Will UPDATE %d distinct names that resolve to clan_ids", len(updates))
|
|
if updates:
|
|
cur = con.executemany(
|
|
"UPDATE player_games_hist SET clan_id = ? "
|
|
"WHERE squadron_name = ? AND clan_id IS NULL",
|
|
updates,
|
|
)
|
|
con.commit()
|
|
log.info("Backfill: %s row-updates committed", cur.rowcount)
|
|
|
|
cols = [r[1] for r in con.execute("PRAGMA table_info(player_games_hist)").fetchall()]
|
|
if "clan_id" in cols:
|
|
still_null = con.execute(
|
|
"SELECT COUNT(*) FROM player_games_hist WHERE clan_id IS NULL"
|
|
).fetchone()[0]
|
|
log.info("player_games_hist orphans (clan_id NULL after backfill): %d", still_null)
|
|
else:
|
|
log.info("player_games_hist orphans: n/a (column absent in dry-run)")
|
|
|
|
log.info("Ensuring index on player_games_hist(clan_id, endtime_unix)")
|
|
if apply:
|
|
con.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_pgh_clanid_endtime "
|
|
"ON player_games_hist(clan_id, endtime_unix)"
|
|
)
|
|
con.commit()
|
|
finally:
|
|
con.close()
|
|
|
|
|
|
def migrate_match_summary(apply: bool) -> None:
|
|
"""Add winning_clan_id + losing_clan_id, backfill via squadrons_data."""
|
|
con = sqlite3.connect(SQ_BATTLES_DB)
|
|
try:
|
|
con.execute("PRAGMA journal_mode=WAL")
|
|
cols = [r[1] for r in con.execute("PRAGMA table_info(match_summary)").fetchall()]
|
|
for new_col in ("winning_clan_id", "losing_clan_id"):
|
|
if new_col not in cols:
|
|
log.info("Adding %s column to match_summary", new_col)
|
|
if apply:
|
|
con.execute(f"ALTER TABLE match_summary ADD COLUMN {new_col} INTEGER")
|
|
con.commit()
|
|
else:
|
|
log.info("match_summary.%s already present", new_col)
|
|
|
|
# Refresh after potential ALTERs.
|
|
cols = [r[1] for r in con.execute("PRAGMA table_info(match_summary)").fetchall()]
|
|
total = con.execute("SELECT COUNT(*) FROM match_summary").fetchone()[0]
|
|
if "winning_clan_id" in cols and "losing_clan_id" in cols:
|
|
unbackfilled = con.execute(
|
|
"SELECT COUNT(*) FROM match_summary "
|
|
"WHERE winning_clan_id IS NULL OR losing_clan_id IS NULL"
|
|
).fetchone()[0]
|
|
else:
|
|
# Dry-run path: columns don't exist yet, so every row is "to be backfilled".
|
|
unbackfilled = total
|
|
log.info(
|
|
"match_summary: %d total rows, %d need backfill",
|
|
total,
|
|
unbackfilled,
|
|
)
|
|
|
|
if apply and unbackfilled:
|
|
con.execute(f"ATTACH DATABASE '{SQUADRONS_DB}' AS sq")
|
|
try:
|
|
# winning_sq / losing_sq can be either short_name or tag_name
|
|
# depending on replay metadata. Try short_name first since that's
|
|
# what the autologger writes most often.
|
|
for col_in, col_out in (
|
|
("winning_sq", "winning_clan_id"),
|
|
("losing_sq", "losing_clan_id"),
|
|
):
|
|
log.info("Backfilling %s ← %s via short_name then long_name", col_out, col_in)
|
|
con.execute(
|
|
f"""
|
|
UPDATE match_summary
|
|
SET {col_out} = (
|
|
SELECT clan_id FROM sq.squadrons_data
|
|
WHERE LOWER(squadrons_data.short_name) = LOWER(match_summary.{col_in})
|
|
LIMIT 1
|
|
)
|
|
WHERE {col_out} IS NULL AND {col_in} IS NOT NULL
|
|
"""
|
|
)
|
|
con.execute(
|
|
f"""
|
|
UPDATE match_summary
|
|
SET {col_out} = (
|
|
SELECT clan_id FROM sq.squadrons_data
|
|
WHERE LOWER(squadrons_data.long_name) = LOWER(match_summary.{col_in})
|
|
LIMIT 1
|
|
)
|
|
WHERE {col_out} IS NULL AND {col_in} IS NOT NULL
|
|
"""
|
|
)
|
|
con.commit()
|
|
finally:
|
|
con.execute("DETACH DATABASE sq")
|
|
|
|
cols = [r[1] for r in con.execute("PRAGMA table_info(match_summary)").fetchall()]
|
|
for col, label in (("winning_clan_id", "winning"), ("losing_clan_id", "losing")):
|
|
if col not in cols:
|
|
log.info("match_summary orphans (%s NULL): n/a (column absent in dry-run)", label)
|
|
continue
|
|
n = con.execute(
|
|
f"SELECT COUNT(*) FROM match_summary WHERE {col} IS NULL"
|
|
).fetchone()[0]
|
|
log.info("match_summary orphans (%s NULL): %d", label, n)
|
|
|
|
log.info("Ensuring indexes on match_summary clan_id columns")
|
|
if apply:
|
|
con.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_ms_winning_clanid ON match_summary(winning_clan_id)"
|
|
)
|
|
con.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_ms_losing_clanid ON match_summary(losing_clan_id)"
|
|
)
|
|
con.commit()
|
|
finally:
|
|
con.close()
|
|
|
|
|
|
def _resolve_squadron_to_clan_id(name: Optional[str], name_to_id: dict[str, int]) -> int:
|
|
if not name:
|
|
return -1
|
|
return name_to_id.get(name.lower(), -1)
|
|
|
|
|
|
def rebuild_points_db(apply: bool, long_to_id: dict[str, int]) -> None:
|
|
"""Rebuild profile_member_points, profile_totals, game_cache with clan_id columns.
|
|
|
|
The squadron text column is preserved for reference but the new tables key
|
|
off clan_id for primary keys. Resolution happens in Python so we don't have
|
|
to ATTACH squadrons.db (avoids the cross-db lock issue we hit earlier).
|
|
"""
|
|
# Build name → clan_id map once (long_name AND short_name).
|
|
sq_con = sqlite3.connect(f"file:{SQUADRONS_DB}?mode=ro", uri=True)
|
|
try:
|
|
sq_con.row_factory = sqlite3.Row
|
|
name_to_id: dict[str, int] = {}
|
|
for row in sq_con.execute(
|
|
"SELECT clan_id, long_name, short_name FROM squadrons_data"
|
|
):
|
|
cid = int(row["clan_id"])
|
|
if row["long_name"]:
|
|
name_to_id.setdefault(row["long_name"].lower(), cid)
|
|
if row["short_name"]:
|
|
name_to_id.setdefault(row["short_name"].lower(), cid)
|
|
finally:
|
|
sq_con.close()
|
|
|
|
con = sqlite3.connect(POINTS_DB)
|
|
try:
|
|
con.execute("PRAGMA journal_mode=WAL")
|
|
|
|
# profile_member_points -------------------------------------------------
|
|
cols = [r[1] for r in con.execute("PRAGMA table_info(profile_member_points)").fetchall()]
|
|
if "clan_id" in cols:
|
|
log.info("profile_member_points already migrated; skipping rebuild")
|
|
else:
|
|
log.info("Rebuilding profile_member_points with clan_id PK")
|
|
if apply:
|
|
# Idempotency: a previous half-finished run may have left _new behind.
|
|
con.execute("DROP TABLE IF EXISTS profile_member_points_new")
|
|
con.execute(
|
|
"""CREATE TABLE profile_member_points_new (
|
|
clan_id INTEGER NOT NULL,
|
|
squadron TEXT NOT NULL,
|
|
uid TEXT NOT NULL,
|
|
points INTEGER NOT NULL,
|
|
PRIMARY KEY (clan_id, uid)
|
|
)"""
|
|
)
|
|
src = con.execute(
|
|
"SELECT squadron, uid, points FROM profile_member_points"
|
|
).fetchall()
|
|
rows = [
|
|
(_resolve_squadron_to_clan_id(s, name_to_id), s, u, p)
|
|
for (s, u, p) in src
|
|
]
|
|
con.executemany(
|
|
"INSERT OR IGNORE INTO profile_member_points_new "
|
|
"(clan_id, squadron, uid, points) VALUES (?, ?, ?, ?)",
|
|
rows,
|
|
)
|
|
con.commit()
|
|
log.info("profile_member_points: copied %d rows", len(rows))
|
|
con.execute("DROP TABLE profile_member_points")
|
|
con.execute("ALTER TABLE profile_member_points_new RENAME TO profile_member_points")
|
|
con.execute("CREATE INDEX IF NOT EXISTS idx_pmp_squadron ON profile_member_points(squadron)")
|
|
con.commit()
|
|
|
|
# profile_totals --------------------------------------------------------
|
|
cols = [r[1] for r in con.execute("PRAGMA table_info(profile_totals)").fetchall()]
|
|
if "clan_id" in cols:
|
|
log.info("profile_totals already migrated; skipping rebuild")
|
|
else:
|
|
log.info("Rebuilding profile_totals with clan_id PK")
|
|
if apply:
|
|
con.execute("DROP TABLE IF EXISTS profile_totals_new")
|
|
con.execute(
|
|
"""CREATE TABLE profile_totals_new (
|
|
clan_id INTEGER PRIMARY KEY,
|
|
squadron TEXT NOT NULL,
|
|
total INTEGER NOT NULL
|
|
)"""
|
|
)
|
|
src = con.execute("SELECT squadron, total FROM profile_totals").fetchall()
|
|
rows = [
|
|
(_resolve_squadron_to_clan_id(s, name_to_id), s, t)
|
|
for (s, t) in src
|
|
]
|
|
con.executemany(
|
|
"INSERT OR IGNORE INTO profile_totals_new (clan_id, squadron, total) VALUES (?, ?, ?)",
|
|
rows,
|
|
)
|
|
con.commit()
|
|
log.info("profile_totals: copied %d rows", len(rows))
|
|
con.execute("DROP TABLE profile_totals")
|
|
con.execute("ALTER TABLE profile_totals_new RENAME TO profile_totals")
|
|
con.commit()
|
|
|
|
# game_cache ------------------------------------------------------------
|
|
cols = [r[1] for r in con.execute("PRAGMA table_info(game_cache)").fetchall()]
|
|
if "clan_id" in cols:
|
|
log.info("game_cache already migrated; skipping rebuild")
|
|
else:
|
|
log.info("Rebuilding game_cache with clan_id in PK")
|
|
if apply:
|
|
con.execute("DROP TABLE IF EXISTS game_cache_new")
|
|
con.execute(
|
|
"""CREATE TABLE game_cache_new (
|
|
game_id TEXT NOT NULL,
|
|
clan_id INTEGER NOT NULL,
|
|
squadron TEXT NOT NULL,
|
|
diffs_json TEXT NOT NULL,
|
|
diff_total INTEGER NOT NULL,
|
|
updated_json TEXT NOT NULL,
|
|
created_at REAL NOT NULL DEFAULT (strftime('%s','now')),
|
|
PRIMARY KEY (game_id, clan_id)
|
|
)"""
|
|
)
|
|
src = con.execute(
|
|
"SELECT game_id, squadron, diffs_json, diff_total, updated_json, created_at FROM game_cache"
|
|
).fetchall()
|
|
rows = [
|
|
(
|
|
gid,
|
|
_resolve_squadron_to_clan_id(s, name_to_id),
|
|
s,
|
|
dj,
|
|
dt,
|
|
uj,
|
|
ca,
|
|
)
|
|
for (gid, s, dj, dt, uj, ca) in src
|
|
]
|
|
con.executemany(
|
|
"INSERT OR IGNORE INTO game_cache_new "
|
|
"(game_id, clan_id, squadron, diffs_json, diff_total, updated_json, created_at) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
rows,
|
|
)
|
|
con.commit()
|
|
log.info("game_cache: copied %d rows", len(rows))
|
|
con.execute("DROP TABLE game_cache")
|
|
con.execute("ALTER TABLE game_cache_new RENAME TO game_cache")
|
|
con.commit()
|
|
finally:
|
|
con.close()
|
|
|
|
|
|
def rebuild_wl_db(apply: bool) -> None:
|
|
"""wl_events / wl_standings are empty - swap schemas to clan_id keyed tables."""
|
|
con = sqlite3.connect(WL_DB)
|
|
try:
|
|
# wl_standings
|
|
cols = [r[1] for r in con.execute("PRAGMA table_info(wl_standings)").fetchall()]
|
|
if "clan_id" in cols:
|
|
log.info("wl_standings already migrated")
|
|
else:
|
|
log.info("Recreating wl_standings keyed by clan_id (was empty)")
|
|
if apply:
|
|
con.executescript(
|
|
"""
|
|
DROP TABLE IF EXISTS wl_standings;
|
|
CREATE TABLE wl_standings (
|
|
clan_id INTEGER PRIMARY KEY,
|
|
squadron TEXT NOT NULL,
|
|
wins INTEGER NOT NULL DEFAULT 0,
|
|
losses INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
"""
|
|
)
|
|
con.commit()
|
|
|
|
# wl_events: store winner_clan_id alongside winner text
|
|
cols = [r[1] for r in con.execute("PRAGMA table_info(wl_events)").fetchall()]
|
|
if "winner_clan_id" in cols:
|
|
log.info("wl_events already migrated")
|
|
else:
|
|
log.info("Adding winner_clan_id column to wl_events (table is empty)")
|
|
if apply:
|
|
con.execute("ALTER TABLE wl_events ADD COLUMN winner_clan_id INTEGER")
|
|
con.commit()
|
|
finally:
|
|
con.close()
|
|
|
|
|
|
def migrate_preferences(
|
|
apply: bool,
|
|
long_to_id: dict[str, int],
|
|
short_to_id: dict[str, int],
|
|
by_id: dict[int, dict[str, Any]],
|
|
) -> None:
|
|
"""Re-key every PREFERENCES/<guild_id>-preferences.json from long_name → str(clan_id).
|
|
|
|
Special wildcard keys (Global, everything, all, *) are preserved as-is.
|
|
Each migrated entry gets a `Long` field with the squadron's current
|
|
long_name so display fallback works if squadrons_data is unavailable.
|
|
Original files are copied to PREFERENCES_BACKUP_<timestamp>/.
|
|
"""
|
|
if not PREFS_DIR.exists():
|
|
log.warning("PREFERENCES dir missing at %s; skipping prefs migration", PREFS_DIR)
|
|
return
|
|
|
|
ts = time.strftime("%Y%m%d-%H%M%S")
|
|
backup_dir = STORAGE_DIR / f"PREFERENCES_BACKUP_{ts}"
|
|
if apply:
|
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
pref_files = sorted(PREFS_DIR.glob("*-preferences.json"))
|
|
log.info("Found %d preference files to migrate", len(pref_files))
|
|
|
|
migrated_count = 0
|
|
orphan_count = 0
|
|
skipped_count = 0
|
|
files_changed = 0
|
|
|
|
for pref_file in pref_files:
|
|
try:
|
|
data = json.loads(pref_file.read_text(encoding="utf-8"))
|
|
except Exception as e:
|
|
log.warning("Could not read %s: %s", pref_file.name, e)
|
|
continue
|
|
|
|
if not isinstance(data, dict):
|
|
log.warning("Skipping %s: not an object", pref_file.name)
|
|
continue
|
|
|
|
new_data: dict[str, Any] = {}
|
|
any_changes = False
|
|
|
|
for key, value in data.items():
|
|
if not isinstance(value, dict):
|
|
new_data[key] = value
|
|
continue
|
|
|
|
key_lower = str(key).lower()
|
|
|
|
# Preserve wildcards / Global
|
|
if key_lower in WILDCARD_KEYS:
|
|
new_data[key] = value
|
|
continue
|
|
|
|
# Already a numeric clan_id (running migration twice)
|
|
if str(key).isdigit():
|
|
new_data[key] = value
|
|
continue
|
|
|
|
# Try long_name then short_name
|
|
cid = long_to_id.get(key_lower) or short_to_id.get(key_lower)
|
|
|
|
# Maybe entry has a "Short" hint we can use as fallback
|
|
if cid is None and value.get("Short"):
|
|
cid = short_to_id.get(str(value["Short"]).lower())
|
|
|
|
if cid is None:
|
|
log.warning(
|
|
" [%s] orphan key %r — squadron not found in squadrons_data",
|
|
pref_file.name,
|
|
key,
|
|
)
|
|
# Keep the original key so the user doesn't lose their data;
|
|
# a future load_guild_preferences will surface it for cleanup.
|
|
new_data[key] = value
|
|
orphan_count += 1
|
|
continue
|
|
|
|
new_key = str(cid)
|
|
row = by_id.get(cid, {})
|
|
merged = dict(value)
|
|
# Preserve display fields - the bot uses them when squadrons_data is stale
|
|
merged.setdefault("Long", row.get("long_name") or key)
|
|
if row.get("short_name"):
|
|
merged["Short"] = row["short_name"]
|
|
|
|
if new_key in new_data:
|
|
# Two prefs entries for the same squadron (rare). Merge,
|
|
# preferring values from this iteration.
|
|
existing = new_data[new_key]
|
|
if isinstance(existing, dict):
|
|
existing.update(merged)
|
|
new_data[new_key] = existing
|
|
else:
|
|
new_data[new_key] = merged
|
|
else:
|
|
new_data[new_key] = merged
|
|
|
|
if new_key != key:
|
|
any_changes = True
|
|
migrated_count += 1
|
|
|
|
if not any_changes:
|
|
skipped_count += 1
|
|
continue
|
|
|
|
if apply:
|
|
backup_path = backup_dir / pref_file.name
|
|
shutil.copy2(pref_file, backup_path)
|
|
tmp = pref_file.with_suffix(".json.tmp")
|
|
tmp.write_text(json.dumps(new_data, ensure_ascii=False), encoding="utf-8")
|
|
os.replace(tmp, pref_file)
|
|
files_changed += 1
|
|
|
|
log.info(
|
|
"Preferences migration: %d files changed, %d unchanged, %d entries migrated, %d orphans",
|
|
files_changed,
|
|
skipped_count,
|
|
migrated_count,
|
|
orphan_count,
|
|
)
|
|
if apply:
|
|
log.info("Preference backups saved to %s", backup_dir)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--apply", action="store_true", help="Actually apply changes")
|
|
parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="Print what would happen without writing anything (default)",
|
|
)
|
|
parser.add_argument(
|
|
"--skip-backup",
|
|
action="store_true",
|
|
help="Skip the tarball backup step (use only if you already have one)",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
if args.apply and args.dry_run:
|
|
log.error("--apply and --dry-run are mutually exclusive")
|
|
return 2
|
|
|
|
apply = args.apply
|
|
|
|
log.info("STORAGE_DIR=%s", STORAGE_DIR)
|
|
log.info("apply=%s", apply)
|
|
|
|
for db in (SQ_BATTLES_DB, SQUADRONS_DB, POINTS_DB, WL_DB):
|
|
if not db.exists():
|
|
log.error("Required DB missing: %s", db)
|
|
return 1
|
|
|
|
if apply and not args.skip_backup:
|
|
backup_storage()
|
|
|
|
long_to_id, short_to_id, by_id = load_squadron_index()
|
|
|
|
ensure_squadron_name_history(apply, by_id)
|
|
add_squadrons_points_index(apply)
|
|
migrate_player_games_hist(apply, long_to_id)
|
|
migrate_match_summary(apply)
|
|
rebuild_points_db(apply, long_to_id)
|
|
rebuild_wl_db(apply)
|
|
migrate_preferences(apply, long_to_id, short_to_id, by_id)
|
|
|
|
if not apply:
|
|
log.info("DRY RUN complete. Re-run with --apply to actually write changes.")
|
|
else:
|
|
log.info("Migration applied successfully.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|