206 lines
7.9 KiB
Python
206 lines
7.9 KiB
Python
"""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()
|