Initial public release
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user