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
|
||||
+1011
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,230 @@
|
||||
"""Project serialization - save/load the complete project state."""
|
||||
|
||||
import os
|
||||
import json
|
||||
from .macro import Macro, NodeData
|
||||
from .settings import Settings
|
||||
from utils.constants import APPDATA_DIR, PROJECT_FILE
|
||||
|
||||
|
||||
class Project:
|
||||
"""The complete project state: all macros and settings."""
|
||||
|
||||
def __init__(self):
|
||||
self.settings = Settings()
|
||||
self.macros: list[Macro] = []
|
||||
# Schema:
|
||||
# {"universal": {name: value, ...},
|
||||
# "devices": {"AA:BB:CC:DD:EE:FF": {name: value, ...}, ...}}
|
||||
self.ble_variables: dict = {"universal": {}, "devices": {}}
|
||||
# Per-variable comments — purely a host-side annotation that
|
||||
# mirrors the ble_variables schema by name. Kept in a separate
|
||||
# field so the wire format (BLE pull/push payloads, device-side
|
||||
# storage) stays untouched: comments never leave the desktop app.
|
||||
# Names that appear in ble_variables but not here have no comment.
|
||||
self.ble_comments: dict = {"universal": {}, "devices": {}}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_ble_variables(raw) -> dict:
|
||||
"""Coerce any saved shape into the {universal, devices} schema.
|
||||
|
||||
Old projects stored a flat {name: value} dict. Wrap it as universal.
|
||||
"""
|
||||
if not isinstance(raw, dict):
|
||||
return {"universal": {}, "devices": {}}
|
||||
if "universal" in raw or "devices" in raw:
|
||||
return {
|
||||
"universal": dict(raw.get("universal") or {}),
|
||||
"devices": {
|
||||
str(mac): dict(vars_ or {})
|
||||
for mac, vars_ in (raw.get("devices") or {}).items()
|
||||
},
|
||||
}
|
||||
# Legacy flat dict — treat as universal
|
||||
return {"universal": dict(raw), "devices": {}}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_ble_comments(raw) -> dict:
|
||||
"""Coerce any saved shape into the {universal, devices} schema.
|
||||
|
||||
Pre-comments projects simply omit this field — we just produce an
|
||||
empty structure. Comments are scoped exactly like variables so a
|
||||
per-device variable's comment lives next to its sibling.
|
||||
"""
|
||||
if not isinstance(raw, dict):
|
||||
return {"universal": {}, "devices": {}}
|
||||
if "universal" in raw or "devices" in raw:
|
||||
return {
|
||||
"universal": {str(k): str(v) for k, v in (raw.get("universal") or {}).items()},
|
||||
"devices": {
|
||||
str(mac): {str(k): str(v) for k, v in (cs or {}).items()}
|
||||
for mac, cs in (raw.get("devices") or {}).items()
|
||||
},
|
||||
}
|
||||
# Legacy flat dict (unlikely for comments but mirrored for safety)
|
||||
return {"universal": {str(k): str(v) for k, v in raw.items()}, "devices": {}}
|
||||
|
||||
def get_universal(self) -> dict:
|
||||
return self.ble_variables.setdefault("universal", {})
|
||||
|
||||
def get_device(self, mac: str) -> dict:
|
||||
devices = self.ble_variables.setdefault("devices", {})
|
||||
return devices.setdefault(mac, {})
|
||||
|
||||
def set_device(self, mac: str, vars_: dict):
|
||||
devices = self.ble_variables.setdefault("devices", {})
|
||||
devices[mac] = dict(vars_ or {})
|
||||
|
||||
def get_universal_comments(self) -> dict:
|
||||
return self.ble_comments.setdefault("universal", {})
|
||||
|
||||
def get_device_comments(self, mac: str) -> dict:
|
||||
devices = self.ble_comments.setdefault("devices", {})
|
||||
return devices.setdefault(mac, {})
|
||||
|
||||
def set_device_comments(self, mac: str, comments: dict):
|
||||
devices = self.ble_comments.setdefault("devices", {})
|
||||
devices[mac] = {str(k): str(v) for k, v in (comments or {}).items()}
|
||||
|
||||
def known_devices(self) -> list:
|
||||
devices = self.ble_variables.get("devices") or {}
|
||||
return sorted(devices.keys())
|
||||
|
||||
def add_macro(self, macro: Macro = None) -> Macro:
|
||||
if macro is None:
|
||||
macro = Macro(name=f"Routine {len(self.macros) + 1}")
|
||||
start_node = NodeData("start", x=100, y=100)
|
||||
macro.add_node(start_node)
|
||||
self.macros.append(macro)
|
||||
return macro
|
||||
|
||||
def remove_macro(self, index: int):
|
||||
if 0 <= index < len(self.macros):
|
||||
self.macros.pop(index)
|
||||
|
||||
def move_macro(self, from_idx: int, to_idx: int):
|
||||
if 0 <= from_idx < len(self.macros) and 0 <= to_idx < len(self.macros):
|
||||
macro = self.macros.pop(from_idx)
|
||||
self.macros.insert(to_idx, macro)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Full in-memory dump including BLE variables/comments.
|
||||
|
||||
Kept inclusive so deep-copy round-trips
|
||||
(``Project.from_dict(p.to_dict())``) preserve everything. The
|
||||
on-disk split — main JSON vs. ``.vars.json`` sidecar — lives in
|
||||
``save`` / ``load`` below, not here.
|
||||
"""
|
||||
return {
|
||||
"version": 1,
|
||||
"settings": self.settings.to_dict(),
|
||||
"ble_variables": self.ble_variables,
|
||||
"ble_comments": self.ble_comments,
|
||||
"macros": [m.to_dict() for m in self.macros],
|
||||
}
|
||||
|
||||
def _to_main_dict(self) -> dict:
|
||||
"""The portion that goes into the git-trackable main JSON.
|
||||
|
||||
BLE variables and comments are deliberately EXCLUDED — they
|
||||
often hold per-machine credentials (passwords, hostnames, asset
|
||||
tags) and live in the sidecar file instead so they can be
|
||||
gitignored without losing the rest of the profile.
|
||||
"""
|
||||
return {
|
||||
"version": 1,
|
||||
"settings": self.settings.to_dict(),
|
||||
"macros": [m.to_dict() for m in self.macros],
|
||||
}
|
||||
|
||||
def _to_vars_dict(self) -> dict:
|
||||
"""The portion that goes into the per-machine sidecar."""
|
||||
return {
|
||||
"ble_variables": self.ble_variables,
|
||||
"ble_comments": self.ble_comments,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Project":
|
||||
p = cls()
|
||||
p.settings = Settings.from_dict(d.get("settings", {}))
|
||||
# Legacy fallback: pre-sidecar projects stored BLE state inline
|
||||
# in the same file. Accept it here so a single load() can handle
|
||||
# both old and migrated layouts.
|
||||
p.ble_variables = cls._normalize_ble_variables(d.get("ble_variables", {}))
|
||||
p.ble_comments = cls._normalize_ble_comments(d.get("ble_comments", {}))
|
||||
p.macros = [Macro.from_dict(md) for md in d.get("macros", [])]
|
||||
return p
|
||||
|
||||
@staticmethod
|
||||
def _vars_path_for(target: str) -> str:
|
||||
"""Return the sidecar path for a given profile JSON path.
|
||||
|
||||
Foo.json -> Foo.vars.json
|
||||
Anything else (rare — direct calls with non-.json names) gets a
|
||||
``.vars.json`` suffix appended verbatim.
|
||||
"""
|
||||
if target.endswith(".json"):
|
||||
return target[:-len(".json")] + ".vars.json"
|
||||
return target + ".vars.json"
|
||||
|
||||
def save(self, path=None):
|
||||
target = path or PROJECT_FILE
|
||||
os.makedirs(os.path.dirname(target) or ".", exist_ok=True)
|
||||
# Main profile JSON (synced via git for the shipped profiles).
|
||||
with open(target, "w", encoding="utf-8") as f:
|
||||
json.dump(self._to_main_dict(), f, indent=2)
|
||||
# Per-machine BLE sidecar (always written; gitignored). Skip
|
||||
# writing when there's literally nothing to persist so we don't
|
||||
# leave empty sidecars cluttering the profiles directory.
|
||||
vars_dict = self._to_vars_dict()
|
||||
has_vars = bool(
|
||||
(vars_dict["ble_variables"].get("universal") or {}) or
|
||||
(vars_dict["ble_variables"].get("devices") or {}) or
|
||||
(vars_dict["ble_comments"].get("universal") or {}) or
|
||||
(vars_dict["ble_comments"].get("devices") or {})
|
||||
)
|
||||
vars_path = self._vars_path_for(target)
|
||||
if has_vars:
|
||||
with open(vars_path, "w", encoding="utf-8") as f:
|
||||
json.dump(vars_dict, f, indent=2)
|
||||
elif os.path.exists(vars_path):
|
||||
# User cleared all variables — remove a now-empty sidecar so
|
||||
# the working tree stays tidy.
|
||||
try:
|
||||
os.remove(vars_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def load(cls, path=None) -> "Project":
|
||||
target = path or PROJECT_FILE
|
||||
if not os.path.exists(target):
|
||||
return cls()
|
||||
try:
|
||||
with open(target, "r", encoding="utf-8") as f:
|
||||
main = json.load(f)
|
||||
except (json.JSONDecodeError, KeyError, TypeError):
|
||||
return cls()
|
||||
|
||||
# Merge in the sidecar's BLE data if present. The sidecar always
|
||||
# wins over any inline copy in the main JSON — important during
|
||||
# the transition window where a profile may still have leftover
|
||||
# legacy fields from before the split.
|
||||
vars_path = cls._vars_path_for(target)
|
||||
if os.path.exists(vars_path):
|
||||
try:
|
||||
with open(vars_path, "r", encoding="utf-8") as f:
|
||||
sidecar = json.load(f)
|
||||
if isinstance(sidecar, dict):
|
||||
if "ble_variables" in sidecar:
|
||||
main["ble_variables"] = sidecar["ble_variables"]
|
||||
if "ble_comments" in sidecar:
|
||||
main["ble_comments"] = sidecar["ble_comments"]
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
|
||||
try:
|
||||
return cls.from_dict(main)
|
||||
except (KeyError, TypeError):
|
||||
return cls()
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Manages named profiles - each profile is an independent project (settings + macros)."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from models.node_graph import Project
|
||||
from utils.constants import (
|
||||
DEFAULT_PROFILE_NAME,
|
||||
PROFILES_DIR,
|
||||
PROFILES_META_FILE,
|
||||
PROJECT_FILE,
|
||||
SUBROUTINES_PROFILE_NAME,
|
||||
)
|
||||
|
||||
_ILLEGAL_CHARS = re.compile(r'[<>:"/\\|?*]')
|
||||
|
||||
|
||||
class ProfileManager:
|
||||
"""Handles CRUD for named profiles stored as individual JSON files."""
|
||||
|
||||
def __init__(self):
|
||||
self.profiles_dir = Path(PROFILES_DIR)
|
||||
self.meta_file = Path(PROFILES_META_FILE)
|
||||
self.active_name: str = DEFAULT_PROFILE_NAME
|
||||
self.project: Project = None
|
||||
|
||||
def startup_load(self) -> Project:
|
||||
"""Migrate if needed, then load and return the active profile."""
|
||||
self._migrate()
|
||||
self._load_meta()
|
||||
self.ensure_subroutines_profile()
|
||||
# Recover if the active profile was deleted on disk outside the app
|
||||
if not self._profile_path(self.active_name).exists():
|
||||
names = self.profile_names()
|
||||
self.active_name = names[0] if names else DEFAULT_PROFILE_NAME
|
||||
self._write_meta()
|
||||
self.project = Project.load(str(self._profile_path(self.active_name)))
|
||||
return self.project
|
||||
|
||||
def profile_names(self) -> list:
|
||||
"""Return sorted list of all profile names (including Sub-Routines).
|
||||
|
||||
Excludes ``*.vars.json`` sidecars — those are the per-machine BLE
|
||||
variable stores that live next to each profile JSON. They are
|
||||
NOT separate profiles. Without this filter, ``Path.stem`` would
|
||||
only strip the trailing ``.json`` and surface phantom entries
|
||||
like ``MyProfile.vars`` in the GUI; clicking one would treat the
|
||||
sidecar AS a profile and the next save would overwrite it,
|
||||
losing the variables.
|
||||
"""
|
||||
if not self.profiles_dir.exists():
|
||||
return []
|
||||
return sorted(
|
||||
p.stem for p in self.profiles_dir.glob("*.json")
|
||||
if not p.name.endswith(".vars.json")
|
||||
)
|
||||
|
||||
def save_current(self):
|
||||
if self.project is not None:
|
||||
self.project.save(str(self._profile_path(self.active_name)))
|
||||
|
||||
def switch(self, name: str) -> Project:
|
||||
"""Save current, load the named profile, update meta. Returns new project."""
|
||||
self.save_current()
|
||||
self.active_name = name
|
||||
self._write_meta()
|
||||
self.project = Project.load(str(self._profile_path(name)))
|
||||
return self.project
|
||||
|
||||
def new_profile(self, name: str, copy_current: bool = False) -> Project:
|
||||
"""Create a new profile and switch to it. Raises ValueError on bad input."""
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise ValueError("Profile name cannot be empty.")
|
||||
# A profile literally named "Foo.vars" would land at the same path as
|
||||
# the BLE sidecar for "Foo" and would corrupt it on the next save
|
||||
if name.lower().endswith(".vars"):
|
||||
raise ValueError("Profile names cannot end with '.vars'.")
|
||||
if name in self.profile_names():
|
||||
raise ValueError(f"A profile named '{name}' already exists.")
|
||||
|
||||
if copy_current and self.project is not None:
|
||||
new_project = Project.from_dict(self.project.to_dict())
|
||||
else:
|
||||
new_project = Project()
|
||||
|
||||
new_project.save(str(self._profile_path(name)))
|
||||
self.save_current()
|
||||
self.active_name = name
|
||||
self._write_meta()
|
||||
self.project = new_project
|
||||
return self.project
|
||||
|
||||
def delete_profile(self, name: str) -> Project:
|
||||
"""Delete a profile. Raises ValueError if it's the only one. Returns active project."""
|
||||
if name == SUBROUTINES_PROFILE_NAME:
|
||||
raise ValueError("Cannot delete the Sub-Routines profile.")
|
||||
names = [n for n in self.profile_names() if n != SUBROUTINES_PROFILE_NAME]
|
||||
if len(names) <= 1:
|
||||
raise ValueError("Cannot delete the only profile.")
|
||||
|
||||
# Flush any unsaved edits to the active profile before the delete may
|
||||
# rotate us away from it (mirrors switch()'s save-then-load order)
|
||||
self.save_current()
|
||||
|
||||
profile_path = self._profile_path(name)
|
||||
profile_path.unlink(missing_ok=True)
|
||||
# Remove the matching BLE sidecar; otherwise a future profile reusing
|
||||
# this name would silently inherit the old variables
|
||||
vars_path = profile_path.with_name(profile_path.stem + ".vars.json")
|
||||
vars_path.unlink(missing_ok=True)
|
||||
|
||||
if self.active_name == name:
|
||||
remaining = self.profile_names()
|
||||
self.active_name = remaining[0]
|
||||
self._write_meta()
|
||||
self.project = Project.load(str(self._profile_path(self.active_name)))
|
||||
|
||||
return self.project
|
||||
|
||||
def duplicate_macro_to_profile(self, macro, target_profile: str):
|
||||
"""Deep-clone ``macro`` into ``target_profile``.
|
||||
|
||||
If the target is the currently active profile, the clone is
|
||||
appended to the live ``self.project`` (caller refreshes the UI
|
||||
and triggers autosave). Otherwise the target profile JSON is
|
||||
loaded from disk, the clone is appended, and the file is saved
|
||||
back — the active profile is not disturbed.
|
||||
|
||||
Returns ``(cloned_macro, is_active_target)`` so the caller knows
|
||||
whether a UI refresh is needed.
|
||||
"""
|
||||
if target_profile not in self.profile_names():
|
||||
raise ValueError(f"Profile '{target_profile}' does not exist.")
|
||||
|
||||
clone = macro.clone()
|
||||
|
||||
if target_profile == self.active_name and self.project is not None:
|
||||
self.project.add_macro(clone)
|
||||
return clone, True
|
||||
|
||||
target = Project.load(str(self._profile_path(target_profile)))
|
||||
target.add_macro(clone)
|
||||
target.save(str(self._profile_path(target_profile)))
|
||||
return clone, False
|
||||
|
||||
def get_subroutine_names(self) -> list[str]:
|
||||
"""Return names of macros in the Sub-Routines profile."""
|
||||
sub_path = self._profile_path(SUBROUTINES_PROFILE_NAME)
|
||||
if not sub_path.exists():
|
||||
return []
|
||||
try:
|
||||
sub_project = Project.load(str(sub_path))
|
||||
return [m.name for m in sub_project.macros]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def get_subroutine_project(self) -> "Project":
|
||||
sub_path = self._profile_path(SUBROUTINES_PROFILE_NAME)
|
||||
if not sub_path.exists():
|
||||
return Project()
|
||||
return Project.load(str(sub_path))
|
||||
|
||||
def ensure_subroutines_profile(self):
|
||||
sub_path = self._profile_path(SUBROUTINES_PROFILE_NAME)
|
||||
if not sub_path.exists():
|
||||
Project().save(str(sub_path))
|
||||
|
||||
def _profile_path(self, name: str) -> Path:
|
||||
return self.profiles_dir / f"{self._sanitize(name)}.json"
|
||||
|
||||
def _sanitize(self, name: str) -> str:
|
||||
return _ILLEGAL_CHARS.sub("_", name)
|
||||
|
||||
def _write_meta(self):
|
||||
with open(self.meta_file, "w", encoding="utf-8") as f:
|
||||
json.dump({"active": self.active_name}, f)
|
||||
|
||||
def _load_meta(self):
|
||||
if self.meta_file.exists():
|
||||
try:
|
||||
with open(self.meta_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
self.active_name = data.get("active", DEFAULT_PROFILE_NAME)
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
self.active_name = DEFAULT_PROFILE_NAME
|
||||
|
||||
def _migrate(self):
|
||||
"""One-time migration: move old project.json into profiles/Default.json."""
|
||||
self.profiles_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
default_path = self._profile_path(DEFAULT_PROFILE_NAME)
|
||||
|
||||
if not any(self.profiles_dir.glob("*.json")):
|
||||
old_project_file = Path(PROJECT_FILE)
|
||||
if old_project_file.exists():
|
||||
import shutil
|
||||
shutil.copy2(old_project_file, default_path)
|
||||
else:
|
||||
Project().save(str(default_path))
|
||||
|
||||
self.active_name = DEFAULT_PROFILE_NAME
|
||||
self._write_meta()
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Settings model for ATOMS3 MacroPad."""
|
||||
|
||||
from utils.constants import (
|
||||
DEFAULT_HOLD_MS, DEFAULT_TYPE_DELAY, DEFAULT_ORIENTATION, DEFAULT_RESUME_DELAY,
|
||||
DEFAULT_COMBO_PRE_MS, DEFAULT_COMBO_POST_MS, DEFAULT_PROBE_TIMEOUT_MS,
|
||||
DEFAULT_MEDIA_HOLD_MS,
|
||||
DEFAULT_TYPE_SHIFT_EXTRA_MS, DEFAULT_TYPE_SETTLE_MS,
|
||||
DEFAULT_PAUSE_MARGIN_LEFT, DEFAULT_PAUSE_MARGIN_RIGHT,
|
||||
DEFAULT_PAUSE_MARGIN_TOP, DEFAULT_PAUSE_MARGIN_BOTTOM,
|
||||
)
|
||||
|
||||
|
||||
class Settings:
|
||||
def __init__(self):
|
||||
self.hold_ms: int = DEFAULT_HOLD_MS
|
||||
self.type_delay: int = DEFAULT_TYPE_DELAY
|
||||
self.orientation: int = DEFAULT_ORIENTATION
|
||||
self.resume_delay: int = DEFAULT_RESUME_DELAY
|
||||
self.combo_pre_ms: int = DEFAULT_COMBO_PRE_MS
|
||||
self.combo_post_ms: int = DEFAULT_COMBO_POST_MS
|
||||
self.probe_timeout_ms: int = DEFAULT_PROBE_TIMEOUT_MS
|
||||
self.media_hold_ms: int = DEFAULT_MEDIA_HOLD_MS
|
||||
self.type_shift_extra_ms: int = DEFAULT_TYPE_SHIFT_EXTRA_MS
|
||||
self.type_settle_ms: int = DEFAULT_TYPE_SETTLE_MS
|
||||
self.pause_margin_left: int = DEFAULT_PAUSE_MARGIN_LEFT
|
||||
self.pause_margin_right: int = DEFAULT_PAUSE_MARGIN_RIGHT
|
||||
self.pause_margin_top: int = DEFAULT_PAUSE_MARGIN_TOP
|
||||
self.pause_margin_bottom: int = DEFAULT_PAUSE_MARGIN_BOTTOM
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"hold_ms": self.hold_ms,
|
||||
"type_delay": self.type_delay,
|
||||
"orientation": self.orientation,
|
||||
"resume_delay": self.resume_delay,
|
||||
"combo_pre_ms": self.combo_pre_ms,
|
||||
"combo_post_ms": self.combo_post_ms,
|
||||
"probe_timeout_ms": self.probe_timeout_ms,
|
||||
"media_hold_ms": self.media_hold_ms,
|
||||
"type_shift_extra_ms": self.type_shift_extra_ms,
|
||||
"type_settle_ms": self.type_settle_ms,
|
||||
"pause_margin_left": self.pause_margin_left,
|
||||
"pause_margin_right": self.pause_margin_right,
|
||||
"pause_margin_top": self.pause_margin_top,
|
||||
"pause_margin_bottom": self.pause_margin_bottom,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Settings":
|
||||
s = cls()
|
||||
s.hold_ms = d.get("hold_ms", DEFAULT_HOLD_MS)
|
||||
s.type_delay = d.get("type_delay", DEFAULT_TYPE_DELAY)
|
||||
s.orientation = d.get("orientation", DEFAULT_ORIENTATION)
|
||||
s.resume_delay = d.get("resume_delay", DEFAULT_RESUME_DELAY)
|
||||
s.combo_pre_ms = d.get("combo_pre_ms", DEFAULT_COMBO_PRE_MS)
|
||||
s.combo_post_ms = d.get("combo_post_ms", DEFAULT_COMBO_POST_MS)
|
||||
s.probe_timeout_ms = d.get("probe_timeout_ms", DEFAULT_PROBE_TIMEOUT_MS)
|
||||
s.media_hold_ms = d.get("media_hold_ms", DEFAULT_MEDIA_HOLD_MS)
|
||||
s.type_shift_extra_ms = d.get("type_shift_extra_ms", DEFAULT_TYPE_SHIFT_EXTRA_MS)
|
||||
s.type_settle_ms = d.get("type_settle_ms", DEFAULT_TYPE_SETTLE_MS)
|
||||
s.pause_margin_left = d.get("pause_margin_left", DEFAULT_PAUSE_MARGIN_LEFT)
|
||||
s.pause_margin_right = d.get("pause_margin_right", DEFAULT_PAUSE_MARGIN_RIGHT)
|
||||
s.pause_margin_top = d.get("pause_margin_top", DEFAULT_PAUSE_MARGIN_TOP)
|
||||
s.pause_margin_bottom = d.get("pause_margin_bottom", DEFAULT_PAUSE_MARGIN_BOTTOM)
|
||||
return s
|
||||
Reference in New Issue
Block a user