Initial public release
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user