Source code for rekordbox_set_list_manager.controllers.project_controller
"""Project lifecycle controller: load, save, dirty tracking."""
from __future__ import annotations
from pathlib import Path # noqa: TC003 — used at runtime in method signatures
from PySide6.QtCore import QObject, Signal
from rekordbox_set_list_manager.models.project import Project
from rekordbox_set_list_manager.services.project_io import ( # noqa: F401 — re-exported
ProjectIOError,
load_project,
save_project,
)
from rekordbox_set_list_manager.utils.config import add_recent_file
from rekordbox_set_list_manager.utils.constants import PROJECT_FILE_EXTENSION
_DEFAULT_PROJECT_NAME = "Untitled"
[docs]
class ProjectController(QObject):
"""Manages the active project, save path, and dirty state.
Signals:
- ``project_changed(Project | None)`` — Fired after a new project is loaded,
created, or restored from a snapshot.
- ``save_path_changed(Path | None)`` — Fired when the save path changes.
- ``dirty_changed(bool)`` — Fired when dirty state transitions.
"""
[docs]
project_changed = Signal(object) # Project | None
[docs]
save_path_changed = Signal(object) # Path | None
[docs]
dirty_changed = Signal(bool)
def __init__(self, parent: QObject | None = None) -> None:
"""Initialise with no project loaded and a clean dirty state."""
super().__init__(parent)
self._project: Project | None = None
self._save_path: Path | None = None
self._dirty: bool = False
# ------------------------------------------------------------------
# Properties
# ------------------------------------------------------------------
@property
[docs]
def project(self) -> Project | None:
"""Return the currently loaded project, or None if none is open."""
return self._project
@property
[docs]
def save_path(self) -> Path | None:
"""Return the path the project was last saved to, or None."""
return self._save_path
@property
[docs]
def dirty(self) -> bool:
"""Return True if the project has unsaved changes."""
return self._dirty
# ------------------------------------------------------------------
# Mutations
# ------------------------------------------------------------------
[docs]
def new(self, name: str = _DEFAULT_PROJECT_NAME) -> Project:
"""Create a fresh project and emit signals.
Parameters
----------
name : str
Display name for the new project. Defaults to ``"Untitled"``.
Returns
-------
Project
The newly created project.
"""
self._project = Project(name=name)
self._save_path = None
self._emit_dirty(dirty=False)
self.save_path_changed.emit(None)
self.project_changed.emit(self._project)
return self._project
[docs]
def load(self, path: Path) -> Project:
"""Load project from *path*. Raises :class:`ProjectIOError` on failure.
Parameters
----------
path : Path
Path to the ``.setmgr`` project file.
Returns
-------
Project
The loaded project.
"""
project = load_project(path)
self._project = project
self._save_path = path
self._emit_dirty(dirty=False)
add_recent_file(str(path))
self.save_path_changed.emit(path)
self.project_changed.emit(project)
return project
[docs]
def save(self, path: Path) -> None:
"""Save current project to *path*. Raises :class:`ProjectIOError` on failure.
Normalises the path suffix to :data:`~rekordbox_set_list_manager.utils.constants
.PROJECT_FILE_EXTENSION`
if needed, then writes the file, updates the save path, clears dirty, and
records the file in the recent-files list.
Parameters
----------
path : Path
Destination path for the saved project file.
"""
if self._project is None:
return
if path.suffix.lower() != PROJECT_FILE_EXTENSION:
path = path.with_suffix(PROJECT_FILE_EXTENSION)
self._project.touch()
save_project(self._project, path)
prev = self._save_path
self._save_path = path
self._emit_dirty(dirty=False)
add_recent_file(str(path))
if path != prev:
self.save_path_changed.emit(path)
[docs]
def mark_dirty(self) -> None:
"""Mark the project as having unsaved changes."""
self._emit_dirty(dirty=True)
[docs]
def restore(self, project: Project) -> None:
"""Replace the active project with *project* (e.g. an undo/redo snapshot).
Sets dirty = True and emits :attr:`project_changed` so the UI refreshes.
The save path is not changed.
Parameters
----------
project : Project
The project snapshot to restore.
"""
self._project = project
self._emit_dirty(dirty=True)
self.project_changed.emit(project)
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
def _emit_dirty(self, dirty: bool) -> None:
if self._dirty != dirty:
self._dirty = dirty
self.dirty_changed.emit(dirty)