feat: backup restore UI (#3)
Add list_backups / backup_label / backup_diff / restore_backup to bcc_core, RestoreDialog to bcc.py, and a "Restore…" button in the profile top bar. Restore is selective: only mcpServers and _disabledMcpServers are replaced; all other keys in the config (history, project state) are preserved verbatim. Goes through write_config() so a pre-restore backup is always created first. Closes #3 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+97
@@ -14,6 +14,7 @@ in its original position.
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import difflib
|
||||
import functools
|
||||
import glob
|
||||
import json
|
||||
@@ -248,6 +249,102 @@ def write_config(path: str | os.PathLike, cfg: dict) -> Path | None:
|
||||
return backup
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Backup utility functions
|
||||
# --------------------------------------------------------------------------- #
|
||||
_BACKUP_TS_RE = re.compile(r"(\d{8}-\d{6})")
|
||||
|
||||
|
||||
def list_backups(config_path: Path | str) -> list[Path]:
|
||||
"""Return backup files for config_path, newest-first. Returns [] if none exist."""
|
||||
p = Path(config_path)
|
||||
bdir = p.parent / BACKUP_DIRNAME
|
||||
if not bdir.is_dir():
|
||||
return []
|
||||
return sorted(bdir.glob(f"{p.stem}.*.json"), reverse=True)
|
||||
|
||||
|
||||
def backup_label(backup_path: Path | str) -> str:
|
||||
"""Human-readable label derived from the backup filename timestamp."""
|
||||
m = _BACKUP_TS_RE.search(Path(backup_path).name)
|
||||
if not m:
|
||||
return Path(backup_path).name
|
||||
ts = m.group(1) # e.g. "20260702-004124"
|
||||
return f"{ts[:4]}-{ts[4:6]}-{ts[6:8]} {ts[9:11]}:{ts[11:13]}:{ts[13:]}"
|
||||
|
||||
|
||||
def backup_diff(
|
||||
config_path: Path | str,
|
||||
backup_path: Path | str,
|
||||
current_cfg: dict | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Unified diff showing what the current config would look like after restoring
|
||||
the backup (servers only — non-server keys are preserved).
|
||||
|
||||
fromfile = current state, tofile = what will be written after restore.
|
||||
Pass current_cfg to use an already-loaded (possibly repaired) in-memory config
|
||||
as the baseline instead of re-reading from disk.
|
||||
"""
|
||||
p = Path(config_path)
|
||||
bp = Path(backup_path)
|
||||
|
||||
if current_cfg is None:
|
||||
try:
|
||||
current_cfg = load_config(p)
|
||||
except Exception:
|
||||
current_cfg = {}
|
||||
|
||||
backup_servers = extract_servers(load_config(bp))
|
||||
after_cfg = dict(current_cfg)
|
||||
apply_servers(after_cfg, backup_servers)
|
||||
|
||||
before_lines = (json.dumps(current_cfg, indent=2, ensure_ascii=False) + "\n").splitlines(
|
||||
keepends=True
|
||||
)
|
||||
after_lines = (json.dumps(after_cfg, indent=2, ensure_ascii=False) + "\n").splitlines(
|
||||
keepends=True
|
||||
)
|
||||
|
||||
result = "".join(
|
||||
difflib.unified_diff(
|
||||
before_lines,
|
||||
after_lines,
|
||||
fromfile=f"current: {p.name}",
|
||||
tofile=f"restore: {bp.name}",
|
||||
)
|
||||
)
|
||||
return result or "(no differences — backup matches current servers)"
|
||||
|
||||
|
||||
def restore_backup(
|
||||
config_path: Path | str,
|
||||
backup_path: Path | str,
|
||||
current_cfg: dict | None = None,
|
||||
) -> Path | None:
|
||||
"""
|
||||
Restore server entries from backup_path into config_path.
|
||||
|
||||
Only mcpServers and _disabledMcpServers are replaced; all other keys in the
|
||||
current config (conversation history, project state, etc.) are preserved
|
||||
verbatim and in their original order.
|
||||
|
||||
Goes through write_config() so a pre-restore backup of the current file is
|
||||
created automatically. Returns that backup path (or None if no prior file).
|
||||
"""
|
||||
p = Path(config_path)
|
||||
if current_cfg is None:
|
||||
try:
|
||||
current_cfg = load_config(p)
|
||||
except Exception:
|
||||
current_cfg = {}
|
||||
|
||||
backup_servers = extract_servers(load_config(Path(backup_path)))
|
||||
cfg = dict(current_cfg)
|
||||
apply_servers(cfg, backup_servers)
|
||||
return write_config(p, cfg)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Paste / import parsing
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
Reference in New Issue
Block a user