"""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()