"""Backup manager — snapshots the entire app state into zip files. Each backup captures: - All profile JSONs (profiles/*.json) - Profile metadata (profiles_meta.json) - Macro images (images/*) Backups are stored as zip files named `backup_YYYY-MM-DD_HH-MM-SS.zip` in APPDATA_DIR/backups/. """ import os import re import shutil import zipfile from datetime import datetime, timedelta from pathlib import Path from utils.constants import ( APPDATA_DIR, BACKUPS_DIR, PROFILES_DIR, PROFILES_META_FILE, IMAGES_DIR, ) BACKUP_NAME_RE = re.compile(r"^backup_(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})\.zip$") class BackupInfo: """Metadata for one backup file.""" def __init__(self, path: Path): self.path = path self.filename = path.name try: self.size = path.stat().st_size except OSError: self.size = 0 # Extract timestamp from filename; fall back to mtime if the name # doesn't match the expected pattern. m = BACKUP_NAME_RE.match(path.name) if m: try: self.timestamp = datetime.strptime(m.group(1), "%Y-%m-%d_%H-%M-%S") except ValueError: self.timestamp = datetime.fromtimestamp(path.stat().st_mtime) else: self.timestamp = datetime.fromtimestamp(path.stat().st_mtime) def human_readable_time(self) -> str: """Return a friendly description of when this backup was taken.""" now = datetime.now() delta = now - self.timestamp if delta < timedelta(seconds=45): return "Just now" if delta < timedelta(minutes=1): return "Less than a minute ago" if delta < timedelta(minutes=60): mins = int(delta.total_seconds() // 60) return f"{mins} minute{'s' if mins != 1 else ''} ago" if delta < timedelta(hours=6): hrs = int(delta.total_seconds() // 3600) return f"{hrs} hour{'s' if hrs != 1 else ''} ago" today = now.date() ts_date = self.timestamp.date() time_part = self.timestamp.strftime("%I:%M %p").lstrip("0") if ts_date == today: return f"Today at {time_part}" if ts_date == today - timedelta(days=1): return f"Yesterday at {time_part}" if today - ts_date < timedelta(days=7): weekday = self.timestamp.strftime("%A") return f"{weekday} at {time_part}" return self.timestamp.strftime("%b %d, %Y at ") + time_part def human_readable_size(self) -> str: return _format_bytes(self.size) def _format_bytes(n: int) -> str: if n < 1024: return f"{n} B" if n < 1024 * 1024: return f"{n / 1024:.1f} KB" if n < 1024 * 1024 * 1024: return f"{n / (1024 * 1024):.1f} MB" return f"{n / (1024 * 1024 * 1024):.2f} GB" class BackupManager: """Creates, lists, restores, and deletes zip-file backups of app state.""" def __init__(self, backups_dir: str = BACKUPS_DIR): self.backups_dir = Path(backups_dir) self.backups_dir.mkdir(parents=True, exist_ok=True) def create_backup(self) -> Path | None: """Create a new backup zip. Returns the path on success, None on failure.""" try: timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") # If a backup with this exact second already exists, append a counter out_path = self.backups_dir / f"backup_{timestamp}.zip" counter = 1 while out_path.exists(): out_path = self.backups_dir / f"backup_{timestamp}_{counter}.zip" counter += 1 with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as zf: meta_path = Path(PROFILES_META_FILE) if meta_path.exists(): zf.write(meta_path, arcname="profiles_meta.json") prof_dir = Path(PROFILES_DIR) if prof_dir.exists(): for p in prof_dir.glob("*.json"): zf.write(p, arcname=f"profiles/{p.name}") img_dir = Path(IMAGES_DIR) if img_dir.exists(): for p in img_dir.iterdir(): if p.is_file(): zf.write(p, arcname=f"images/{p.name}") return out_path except Exception as e: print(f"[backup] create_backup failed: {e}") return None def list_backups(self) -> list[BackupInfo]: """Return all backups, newest first.""" if not self.backups_dir.exists(): return [] backups = [BackupInfo(p) for p in self.backups_dir.glob("backup_*.zip")] backups.sort(key=lambda b: b.timestamp, reverse=True) return backups def total_size(self) -> int: total = 0 for info in self.list_backups(): total += info.size return total def total_size_formatted(self) -> str: return _format_bytes(self.total_size()) def delete_backup(self, path) -> bool: try: p = Path(path) if p.exists() and p.parent == self.backups_dir: p.unlink() return True except OSError as e: print(f"[backup] delete_backup failed: {e}") return False def restore_backup(self, path) -> bool: """Restore app state from a backup zip. Replaces the profiles directory, profiles_meta.json, and images. Returns True on success, False on failure. The caller is responsible for reloading the app's in-memory state (Project, ProfileManager, etc.) after this returns. """ src = Path(path) if not src.exists(): print(f"[backup] restore: source missing: {src}") return False try: with zipfile.ZipFile(src, "r") as zf: # Sanity-check: a real backup must contain profiles_meta or at least one profile names = zf.namelist() if not any(n == "profiles_meta.json" or n.startswith("profiles/") for n in names): print(f"[backup] restore: zip doesn't look like a valid backup") return False prof_dir = Path(PROFILES_DIR) img_dir = Path(IMAGES_DIR) if prof_dir.exists(): shutil.rmtree(prof_dir) if img_dir.exists(): shutil.rmtree(img_dir) prof_dir.mkdir(parents=True, exist_ok=True) img_dir.mkdir(parents=True, exist_ok=True) meta_path = Path(PROFILES_META_FILE) if meta_path.exists(): meta_path.unlink() appdata = Path(APPDATA_DIR).resolve() for name in names: # Block absolute paths and .. traversal (zip-slip protection) if name.startswith("/") or ".." in name.replace("\\", "/").split("/"): continue if name == "profiles_meta.json": dest = meta_path elif name.startswith("profiles/"): dest = prof_dir / name[len("profiles/"):] elif name.startswith("images/"): dest = img_dir / name[len("images/"):] else: continue # Final safety check — destination must resolve inside appdata try: resolved = dest.resolve() resolved.relative_to(appdata) except (ValueError, OSError): continue dest.parent.mkdir(parents=True, exist_ok=True) with zf.open(name) as src_f, open(dest, "wb") as out_f: shutil.copyfileobj(src_f, out_f) return True except Exception as e: print(f"[backup] restore_backup failed: {e}") return False