diff --git a/src/logic_plugin_manager/__init__.py b/src/logic_plugin_manager/__init__.py index c3487fa..8674ee7 100644 --- a/src/logic_plugin_manager/__init__.py +++ b/src/logic_plugin_manager/__init__.py @@ -1,11 +1,16 @@ +import logging + from .components import AudioComponent, AudioUnitType, Component from .exceptions import MusicAppsLoadError, PluginLoadError, TagsetLoadError from .logic import Logic, Plugins, SearchResult -from .tags import MusicApps, Properties, Tagpool, Tagset +from .tags import Category, MusicApps, Properties, Tagpool, Tagset + +logging.getLogger(__name__).addHandler(logging.NullHandler()) __all__ = [ "AudioComponent", "AudioUnitType", + "Category", "Component", "Logic", "MusicApps", diff --git a/src/logic_plugin_manager/components/audiocomponent.py b/src/logic_plugin_manager/components/audiocomponent.py index 175c0b6..50e8ac1 100644 --- a/src/logic_plugin_manager/components/audiocomponent.py +++ b/src/logic_plugin_manager/components/audiocomponent.py @@ -1,10 +1,13 @@ -from dataclasses import dataclass +import logging +from dataclasses import dataclass, field from enum import Enum from pathlib import Path from .. import defaults from ..exceptions import CannotParseComponentError -from ..tags import Tagset +from ..tags import Category, MusicApps, Tagset + +logger = logging.getLogger(__name__) class AudioUnitType(Enum): @@ -62,12 +65,19 @@ class AudioComponent: version: int tags_id: str tagset: Tagset + categories: list[Category] = field(default_factory=list) def __init__( - self, data: dict, *, lazy: bool = False, tags_path: Path = defaults.tags_path + self, + data: dict, + *, + lazy: bool = False, + tags_path: Path = defaults.tags_path, + musicapps: MusicApps = None, ): self.tags_path = tags_path self.lazy = lazy + self.musicapps = musicapps or MusicApps(tags_path=self.tags_path, lazy=lazy) try: self.full_name = data.get("name") @@ -85,17 +95,34 @@ class AudioComponent: f"{self.subtype_code.encode('ascii').hex()}-" f"{self.manufacturer_code.encode('ascii').hex()}" ) + logger.debug(f"Created AudioComponent {self.full_name} from data") except Exception as e: - raise CannotParseComponentError(f"An error occurred while parsing: {e}") + raise CannotParseComponentError( + f"An error occurred while parsing: {e}" + ) from e if not lazy: self.load() def load(self) -> "AudioComponent": + logger.debug(f"Loading AudioComponent {self.full_name}") self.tagset = Tagset(self.tags_path / self.tags_id, lazy=self.lazy) + logger.debug(f"Loaded Tagset for {self.full_name}") + self.categories = [] + for name in self.tagset.tags.keys(): + try: + logger.debug(f"Loading category {name} for {self.full_name}") + self.categories.append( + Category(name, musicapps=self.musicapps, lazy=self.lazy) + ) + except Exception as e: + logger.warning( + f"Failed to load category {name} for {self.full_name}: {e}" + ) + logger.debug(f"Loaded {len(self.categories)} categories for {self.full_name}") return self - def __eq__(self, other): + def __eq__(self, other) -> bool: if not isinstance(other, AudioComponent): return NotImplemented return self.tags_id == other.tags_id @@ -103,5 +130,42 @@ class AudioComponent: def __hash__(self): return hash(self.tags_id) + def set_nickname(self, nickname: str) -> "AudioComponent": + self.tagset.set_nickname(nickname) + self.load() + return self + + def set_shortname(self, shortname: str) -> "AudioComponent": + self.tagset.set_shortname(shortname) + self.load() + return self + + def set_categories(self, categories: list[Category]) -> "AudioComponent": + self.tagset.set_tags({category.name: "user" for category in categories}) + self.load() + return self + + def add_to_category(self, category: Category) -> "AudioComponent": + self.tagset.add_tag(category.name, "user") + self.load() + return self + + def remove_from_category(self, category: Category) -> "AudioComponent": + self.tagset.remove_tag(category.name) + self.load() + return self + + def move_to_category(self, category: Category) -> "AudioComponent": + self.tagset.move_to_tag(category.name, "user") + self.load() + return self + + def move_to_parents(self) -> "AudioComponent": + for category in self.categories: + self.tagset.add_tag(category.parent.name, "user") + self.tagset.remove_tag(category.name) + self.load() + return self + __all__ = ["AudioComponent", "AudioUnitType"] diff --git a/src/logic_plugin_manager/components/component.py b/src/logic_plugin_manager/components/component.py index 99eb397..b6df97a 100644 --- a/src/logic_plugin_manager/components/component.py +++ b/src/logic_plugin_manager/components/component.py @@ -1,3 +1,4 @@ +import logging import plistlib from dataclasses import dataclass from pathlib import Path @@ -9,8 +10,11 @@ from ..exceptions import ( NonexistentPlistError, OldComponentFormatError, ) +from ..tags import MusicApps from .audiocomponent import AudioComponent +logger = logging.getLogger(__name__) + @dataclass class Component: @@ -21,16 +25,25 @@ class Component: audio_components: list[AudioComponent] def __init__( - self, path: Path, *, lazy: bool = False, tags_path: Path = defaults.tags_path + self, + path: Path, + *, + lazy: bool = False, + tags_path: Path = defaults.tags_path, + musicapps: MusicApps = None, ): self.path = path if path.suffix == ".component" else Path(f"{path}.component") self.lazy = lazy self.tags_path = tags_path + self.musicapps = musicapps or MusicApps(tags_path=self.tags_path, lazy=lazy) + logger.debug(f"Created Component from {self.path}") + if not lazy: self.load() def _parse_plist(self): info_plist_path = self.path / "Contents" / "Info.plist" + logger.debug(f"Parsing Info.plist at {info_plist_path}") if not info_plist_path.exists(): raise NonexistentPlistError(f"Info.plist not found at {info_plist_path}") @@ -39,25 +52,36 @@ class Component: plist_data = plistlib.load(fp) return plist_data except Exception as e: - raise CannotParsePlistError(f"An error occurred: {e}") + raise CannotParsePlistError(f"An error occurred: {e}") from e def load(self) -> "Component": plist_data = self._parse_plist() + logger.debug(f"Loaded Info.plist for {self.path}") try: self.name = self.path.name.removesuffix(".component") - self.bundle_id = plist_data["CFBundleIdentifier"] - self.version = plist_data["CFBundleVersion"] - self.short_version = plist_data["CFBundleShortVersionString"] + self.bundle_id = plist_data.get("CFBundleIdentifier") + self.version = plist_data.get("CFBundleVersion") + self.short_version = plist_data.get("CFBundleShortVersionString") + logger.debug(f"Loaded component info for {self.bundle_id}") except Exception as e: raise CannotParsePlistError( f"An error occurred while extracting: {e}" ) from e try: + logger.debug(f"Loading components for {self.bundle_id}") self.audio_components = [ - AudioComponent(name, lazy=self.lazy, tags_path=self.tags_path) + AudioComponent( + name, + lazy=self.lazy, + tags_path=self.tags_path, + musicapps=self.musicapps, + ) for name in plist_data["AudioComponents"] ] + logger.debug( + f"Loaded {len(self.audio_components)} components for {self.bundle_id}" + ) except KeyError as e: raise OldComponentFormatError( "This component is in an old format and cannot be loaded" @@ -67,6 +91,7 @@ class Component: "An error occurred while loading components" ) from e + logger.debug(f"Loaded {self.name} from {self.path}") return self def __hash__(self): diff --git a/src/logic_plugin_manager/exceptions.py b/src/logic_plugin_manager/exceptions.py index 1e93ce0..dbceabe 100644 --- a/src/logic_plugin_manager/exceptions.py +++ b/src/logic_plugin_manager/exceptions.py @@ -30,5 +30,21 @@ class CannotParseTagsetError(TagsetLoadError): pass +class TagsetWriteError(TagsetLoadError): + pass + + class MusicAppsLoadError(Exception): pass + + +class MusicAppsWriteError(Exception): + pass + + +class CategoryValidationError(Exception): + pass + + +class CategoryExistsError(Exception): + pass diff --git a/src/logic_plugin_manager/logic/logic.py b/src/logic_plugin_manager/logic/logic.py index e8a762d..9c23013 100644 --- a/src/logic_plugin_manager/logic/logic.py +++ b/src/logic_plugin_manager/logic/logic.py @@ -1,17 +1,21 @@ +import logging from dataclasses import dataclass from pathlib import Path from .. import defaults -from ..components import Component -from ..tags import MusicApps +from ..components import AudioComponent, Component +from ..tags import Category, MusicApps from .plugins import Plugins +logger = logging.getLogger(__name__) + @dataclass class Logic: musicapps: MusicApps plugins: Plugins components: set[Component] + categories: dict[str, Category] components_path: Path = defaults.components_path tags_path: Path = defaults.tags_path @@ -33,22 +37,92 @@ class Logic: self.musicapps = MusicApps(tags_path=self.tags_path, lazy=lazy) self.plugins = Plugins() self.components = set() + self.categories = {} self.lazy = lazy + logger.debug("Created Logic instance") + if not lazy: self.discover_plugins() + self.discover_categories() - def discover_plugins(self): + def discover_plugins(self) -> "Logic": for component_path in self.components_path.glob("*.component"): try: - component = Component(component_path, lazy=self.lazy) + logger.debug(f"Loading component {component_path}") + component = Component( + component_path, lazy=self.lazy, musicapps=self.musicapps + ) self.components.add(component) + logger.debug(f"Loading plugins for {component.name}") for plugin in component.audio_components: self.plugins.add(plugin, lazy=self.lazy) except Exception as e: - assert e - continue + logger.warning(f"Failed to load component {component_path}: {e}") + + return self + + def discover_categories(self) -> "Logic": + for category in self.musicapps.tagpool.categories.keys(): + logger.debug(f"Loading category {category}") + self.categories[category] = Category( + category, musicapps=self.musicapps, lazy=self.lazy + ) + + return self + + def sync_category_plugin_amount(self, category: Category | str) -> "Logic": + if isinstance(category, str): + category = self.categories[category] + logger.debug(f"Syncing plugin amount for {category.name}") + category.update_plugin_amount( + len( + self.plugins.get_by_category( + category.name if isinstance(category, Category) else category + ) + ) + ) + return self + + def sync_all_categories_plugin_amount(self) -> "Logic": + for category in self.categories.values(): + self.sync_category_plugin_amount(category) + return self + + def search_categories(self, query: str) -> set[Category]: + return { + category + for category in self.categories.values() + if query in category.name.lower() + } + + def introduce_category(self, name: str) -> Category: + return Category.introduce(name, musicapps=self.musicapps, lazy=self.lazy) + + def add_plugins_to_category( + self, category: Category, plugins: set[AudioComponent] + ) -> "Logic": + for plugin in plugins: + plugin.add_to_category(category) + self.sync_category_plugin_amount(category) + return self + + def move_plugins_to_category( + self, category: Category, plugins: set[AudioComponent] + ) -> "Logic": + for plugin in plugins: + plugin.move_to_category(category) + self.sync_category_plugin_amount(category) + return self + + def remove_plugins_from_category( + self, category: Category, plugins: set[AudioComponent] + ) -> "Logic": + for plugin in plugins: + plugin.remove_from_category(category) + self.sync_category_plugin_amount(category) + return self __all__ = ["Logic"] diff --git a/src/logic_plugin_manager/logic/plugins.py b/src/logic_plugin_manager/logic/plugins.py index 60a1134..f98dff0 100644 --- a/src/logic_plugin_manager/logic/plugins.py +++ b/src/logic_plugin_manager/logic/plugins.py @@ -1,8 +1,11 @@ +import logging from collections import defaultdict from dataclasses import dataclass from ..components import AudioComponent, AudioUnitType +logger = logging.getLogger(__name__) + @dataclass class SearchResult: @@ -26,13 +29,16 @@ class Plugins: self._by_category: dict[str | None, set[AudioComponent]] = defaultdict(set) def add(self, plugin: AudioComponent, *, lazy: bool = False) -> "Plugins": + logger.debug(f"Adding plugin {plugin.full_name}") self._plugins.add(plugin) if not lazy: self._index_plugin(plugin) return self def _index_plugin(self, plugin: AudioComponent): + logger.debug(f"Indexing plugin {plugin.full_name}") if plugin.lazy: + logger.debug(f"{plugin.full_name} is lazy, loading first") plugin.load() plugin.tagset.load() @@ -48,8 +54,10 @@ class Plugins: self._by_category[tag.lower()].add(plugin) if not plugin.tagset.tags.keys(): self._by_category[None].add(plugin) + logger.debug(f"Indexed plugin {plugin.full_name}") def reindex_all(self): + logger.debug("Reindexing all plugins") self._by_full_name.clear() self._by_manufacturer.clear() self._by_name.clear() @@ -59,9 +67,11 @@ class Plugins: self._by_subtype_code.clear() self._by_tags_id.clear() self._by_category.clear() + logger.debug("Cleared all indexes") for plugin in self._plugins: self._index_plugin(plugin) + logger.debug("Reindexed all plugins") def all(self): return self._plugins.copy() diff --git a/src/logic_plugin_manager/tags/__init__.py b/src/logic_plugin_manager/tags/__init__.py index e216a38..21cc00b 100644 --- a/src/logic_plugin_manager/tags/__init__.py +++ b/src/logic_plugin_manager/tags/__init__.py @@ -1,4 +1,5 @@ +from .category import Category from .musicapps import MusicApps, Properties, Tagpool from .tagset import Tagset -__all__ = ["MusicApps", "Properties", "Tagpool", "Tagset"] +__all__ = ["Category", "MusicApps", "Properties", "Tagpool", "Tagset"] diff --git a/src/logic_plugin_manager/tags/category.py b/src/logic_plugin_manager/tags/category.py new file mode 100644 index 0000000..6634b8c --- /dev/null +++ b/src/logic_plugin_manager/tags/category.py @@ -0,0 +1,159 @@ +import logging +from dataclasses import dataclass, field + +from ..exceptions import CategoryExistsError, CategoryValidationError +from .musicapps import MusicApps + +logger = logging.getLogger(__name__) + + +@dataclass +class Category: + name: str + musicapps: MusicApps = field(repr=False) + is_root: bool + plugin_amount: int + lazy: bool + + def __init__(self, name: str, *, musicapps: MusicApps = None, lazy: bool = False): + self.name = name + self.musicapps = musicapps or MusicApps(lazy=lazy) + self.is_root = False + self.plugin_amount = 0 + self.lazy = lazy + + if not lazy: + self.load() + + def load(self): + logger.debug(f"Validating category {self.name}") + if self.name not in self.musicapps.tagpool.categories.keys(): + raise CategoryValidationError(f"Category {self.name} not found in tagpool") + self.plugin_amount = self.musicapps.tagpool.categories[self.name] + logger.debug(f"Loaded plugin amount for {self.name} - {self.plugin_amount}") + if self.name == "": + self.is_root = True + logger.debug("This is the root category") + return + if self.name not in self.musicapps.properties.sorting: + raise CategoryValidationError(f"Category {self.name} not found in sorting") + logger.debug(f"Valid category {self.name}") + + @classmethod + def introduce(cls, name: str, *, musicapps: MusicApps = None, lazy: bool = False): + logger.debug(f"Introducing category {name}") + if musicapps is None: + musicapps = MusicApps() + try: + cls(name, musicapps=musicapps, lazy=lazy) + raise CategoryExistsError(f"Category {name} already exists") + except CategoryValidationError: + logger.debug(f"Category {name} doesn't exist, proceeding") + pass + + musicapps.introduce_category(name) + logger.debug(f"Introduced category {name}") + + return cls(name, musicapps=musicapps) + + @property + def parent(self) -> "Category": + if self.is_root: + return self + return self.__class__( + ":".join(self.name.split(":")[:-1]), + musicapps=self.musicapps, + lazy=self.lazy, + ) + + def child(self, name: str) -> "Category": + return self.__class__( + f"{self.name}:{name}", musicapps=self.musicapps, lazy=self.lazy + ) + + def delete(self): + if self.is_root: + return + self.musicapps.tagpool.remove_category(self.name) + self.musicapps.properties.remove_category(self.name) + + def update_plugin_amount(self, amount: int): + if self.is_root: + return + self.musicapps.tagpool.write_category(self.name, amount) + self.load() + + def move_up(self, steps: int = 1): + if self.is_root: + return + self.musicapps.properties.move_up(self.name, steps) + self.load() + + def move_down(self, steps: int = 1): + if self.is_root: + return + self.musicapps.properties.move_down(self.name, steps) + self.load() + + def move_to_top(self): + if self.is_root: + return + self.musicapps.properties.move_to_top(self.name) + self.load() + + def move_to_bottom(self): + if self.is_root: + return + self.musicapps.properties.move_to_bottom(self.name) + self.load() + + def move_before(self, other: "Category"): + if self.is_root: + return + self.musicapps.properties.move_before(self.name, other.name) + self.load() + + def move_after(self, other: "Category"): + if self.is_root: + return + self.musicapps.properties.move_after(self.name, other.name) + self.load() + + def move_to(self, index: int): + if self.is_root: + return + self.musicapps.properties.move_to_index(self.name, index) + self.load() + + def swap(self, other: "Category"): + if self.is_root: + return + self.musicapps.properties.swap(self.name, other.name) + self.load() + + @property + def index(self): + return self.musicapps.properties.get_index(self.name) + + @property + def neighbors(self): + if self.is_root: + return None, None + neighbors = self.musicapps.properties.get_neighbors(self.name) + if neighbors is None or len(neighbors) != 2: + return None, None + return ( + self.__class__(name=neighbors[0], musicapps=self.musicapps), + self.__class__(name=neighbors[1], musicapps=self.musicapps), + ) + + @property + def is_first(self): + return self.musicapps.properties.is_first(self.name) + + @property + def is_last(self): + return self.musicapps.properties.is_last(self.name) + + +__all__ = ["Category"] diff --git a/src/logic_plugin_manager/tags/musicapps.py b/src/logic_plugin_manager/tags/musicapps.py index 5199934..b21259c 100644 --- a/src/logic_plugin_manager/tags/musicapps.py +++ b/src/logic_plugin_manager/tags/musicapps.py @@ -1,20 +1,35 @@ +import logging import plistlib -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from .. import defaults -from ..exceptions import MusicAppsLoadError +from ..exceptions import MusicAppsLoadError, MusicAppsWriteError + +logger = logging.getLogger(__name__) def _parse_plist(path: Path): + logger.debug(f"Parsing plist at {path}") if not path.exists(): raise MusicAppsLoadError(f"File not found at {path}") try: with open(path, "rb") as fp: plist_data = plistlib.load(fp) + logger.debug(f"Parsed plist for {path}") return plist_data except Exception as e: - raise MusicAppsLoadError(f"An error occurred: {e}") + raise MusicAppsLoadError(f"An error occurred: {e}") from e + + +def _save_plist(path: Path, data: dict): + logger.debug(f"Saving plist to {path}") + try: + with open(path, "wb") as fp: + plistlib.dump(data, fp) + logger.debug(f"Saved plist to {path}") + except Exception as e: + raise MusicAppsWriteError(f"An error occurred: {e}") from e @dataclass @@ -25,32 +40,263 @@ class Tagpool: self.path = tags_path / "MusicApps.tagpool" self.lazy = lazy + logger.debug(f"Created Tagpool from {self.path}") + if not lazy: self.load() def load(self) -> "Tagpool": + logger.debug(f"Loading Tagpool data from {self.path}") self.categories = _parse_plist(self.path) + logger.debug(f"Loaded Tagpool data from {self.path}") return self + def write_category(self, name: str, plugin_count: int = 0): + self.load() + self.categories[name] = plugin_count + _save_plist(self.path, self.categories) + self.load() + + def introduce_category(self, name: str): + self.load() + if name in self.categories: + return + self.write_category(name) + + def remove_category(self, name: str): + self.load() + self.categories.pop(name, None) + _save_plist(self.path, self.categories) + self.load() + @dataclass class Properties: sorting: list[str] user_sorted: bool + __raw_data: dict[str, str | list[str] | bool] = field(repr=False) def __init__(self, tags_path: Path, *, lazy: bool = False): self.path = tags_path / "MusicApps.properties" self.lazy = lazy + logger.debug(f"Created Properties from {self.path}") + if not lazy: self.load() def load(self) -> "Properties": - properties_data = _parse_plist(self.path) - self.sorting = properties_data.get("sorting", []) - self.user_sorted = bool(properties_data.get("user_sorted", False)) + logger.debug(f"Loading Properties data from {self.path}") + self.__raw_data = _parse_plist(self.path) + logger.debug(f"Loaded Properties data from {self.path}") + + self.sorting = self.__raw_data.get("sorting", []) + self.user_sorted = bool(self.__raw_data.get("user_sorted", False)) + logger.debug(f"Parsed Properties data from {self.path}") return self + def introduce_category(self, name: str): + self.load() + if name in self.sorting: + return + self.__raw_data["sorting"].append(name) + _save_plist(self.path, self.__raw_data) + self.load() + + def enable_user_sorting(self): + self.load() + self.__raw_data["user_sorted"] = "property" + _save_plist(self.path, self.__raw_data) + self.load() + + def enable_alphabetical_sorting(self): + self.load() + del self.__raw_data["user_sorted"] + _save_plist(self.path, self.__raw_data) + self.load() + + def remove_category(self, name: str): + self.load() + self.__raw_data["sorting"].remove(name) + _save_plist(self.path, self.__raw_data) + self.load() + + def move_up(self, category: str, steps: int = 1): + self.load() + sorting = self.sorting.copy() + + if category not in sorting: + raise ValueError(f"Category '{category}' not found in sorting") + + current_idx = sorting.index(category) + new_idx = max(0, current_idx - steps) + + sorting.pop(current_idx) + sorting.insert(new_idx, category) + + self.__raw_data["sorting"] = sorting + _save_plist(self.path, self.__raw_data) + self.load() + + def move_down(self, category: str, steps: int = 1): + self.load() + sorting = self.sorting.copy() + + if category not in sorting: + raise ValueError(f"Category '{category}' not found in sorting") + + current_idx = sorting.index(category) + new_idx = min(len(sorting) - 1, current_idx + steps) + + sorting.pop(current_idx) + sorting.insert(new_idx, category) + + self.__raw_data["sorting"] = sorting + _save_plist(self.path, self.__raw_data) + self.load() + + def move_to_top(self, category: str): + self.load() + sorting = self.sorting.copy() + + if category not in sorting: + raise ValueError(f"Category '{category}' not found in sorting") + + sorting.remove(category) + sorting.insert(0, category) + + self.__raw_data["sorting"] = sorting + _save_plist(self.path, self.__raw_data) + self.load() + + def move_to_bottom(self, category: str): + self.load() + sorting = self.sorting.copy() + + if category not in sorting: + raise ValueError(f"Category '{category}' not found in sorting") + + sorting.remove(category) + sorting.append(category) + + self.__raw_data["sorting"] = sorting + _save_plist(self.path, self.__raw_data) + self.load() + + def move_before(self, category: str, target: str): + self.load() + sorting = self.sorting.copy() + + if category not in sorting: + raise ValueError(f"Category '{category}' not found") + if target not in sorting: + raise ValueError(f"Target category '{target}' not found") + + sorting.remove(category) + target_idx = sorting.index(target) + sorting.insert(target_idx, category) + + self.__raw_data["sorting"] = sorting + _save_plist(self.path, self.__raw_data) + self.load() + + def move_after(self, category: str, target: str): + self.load() + sorting = self.sorting.copy() + + if category not in sorting: + raise ValueError(f"Category '{category}' not found") + if target not in sorting: + raise ValueError(f"Target category '{target}' not found") + + sorting.remove(category) + target_idx = sorting.index(target) + sorting.insert(target_idx + 1, category) + + self.__raw_data["sorting"] = sorting + _save_plist(self.path, self.__raw_data) + self.load() + + def move_to_index(self, category: str, index: int): + self.load() + sorting = self.sorting.copy() + + if category not in sorting: + raise ValueError(f"Category '{category}' not found") + + if index < 0: + index = len(sorting) + index + + index = max(0, min(len(sorting) - 1, index)) + + sorting.remove(category) + sorting.insert(index, category) + + self.__raw_data["sorting"] = sorting + _save_plist(self.path, self.__raw_data) + self.load() + + def swap(self, category1: str, category2: str): + self.load() + sorting = self.sorting.copy() + + if category1 not in sorting or category2 not in sorting: + raise ValueError("Both categories must exist") + + idx1 = sorting.index(category1) + idx2 = sorting.index(category2) + + sorting[idx1], sorting[idx2] = sorting[idx2], sorting[idx1] + + self.__raw_data["sorting"] = sorting + _save_plist(self.path, self.__raw_data) + self.load() + + def set_order(self, categories: list[str]): + self.load() + current = set(self.sorting) + new = set(categories) + + if new != current: + missing = current - new + extra = new - current + raise ValueError(f"Category mismatch. Missing: {missing}, Extra: {extra}") + + self.__raw_data["sorting"] = categories + _save_plist(self.path, self.__raw_data) + self.load() + + def reorder(self, key_func=None, reverse: bool = False): + self.load() + sorting = self.sorting.copy() + + if key_func is None: + sorting.sort(reverse=reverse) + else: + sorting.sort(key=key_func, reverse=reverse) + + self.__raw_data["sorting"] = sorting + _save_plist(self.path, self.__raw_data) + self.load() + + def get_index(self, category: str) -> int: + return self.sorting.index(category) + + def get_at_index(self, index: int) -> str: + return self.sorting[index] + + def get_neighbors(self, category: str) -> tuple[str | None, str | None]: + idx = self.get_index(category) + prev_cat = self.sorting[idx - 1] if idx > 0 else None + next_cat = self.sorting[idx + 1] if idx < len(self.sorting) - 1 else None + return prev_cat, next_cat + + def is_first(self, category: str) -> bool: + return self.get_index(category) == 0 + + def is_last(self, category: str) -> bool: + return self.get_index(category) == len(self.sorting) - 1 + @dataclass class MusicApps: @@ -61,13 +307,24 @@ class MusicApps: self.path = tags_path self.lazy = lazy + logger.debug(f"Created MusicApps from {self.path}") + if not lazy: self.load() def load(self) -> "MusicApps": self.tagpool = Tagpool(self.path, lazy=self.lazy) self.properties = Properties(self.path, lazy=self.lazy) + logger.debug(f"Loaded MusicApps from {self.path}") return self + def introduce_category(self, name: str): + self.tagpool.introduce_category(name) + self.properties.introduce_category(name) + + def remove_category(self, name: str): + self.tagpool.remove_category(name) + self.properties.remove_category(name) + __all__ = ["MusicApps", "Properties", "Tagpool"] diff --git a/src/logic_plugin_manager/tags/tagset.py b/src/logic_plugin_manager/tags/tagset.py index 28dbd96..c4ed5c0 100644 --- a/src/logic_plugin_manager/tags/tagset.py +++ b/src/logic_plugin_manager/tags/tagset.py @@ -1,8 +1,12 @@ import plistlib -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path -from ..exceptions import CannotParseTagsetError, NonexistentTagsetError +from ..exceptions import ( + CannotParseTagsetError, + NonexistentTagsetError, + TagsetWriteError, +) @dataclass @@ -11,6 +15,7 @@ class Tagset: nickname: str shortname: str tags: dict[str, str] + __raw_data: dict[str, str | dict[str, str]] = field(repr=False) def __init__(self, path: Path, *, lazy: bool = False): self.path = path.with_suffix(".tagset") @@ -27,17 +32,61 @@ class Tagset: plist_data = plistlib.load(fp) return plist_data except Exception as e: - raise CannotParseTagsetError(f"An error occurred: {e}") + raise CannotParseTagsetError(f"An error occurred: {e}") from e + + def _write_plist(self): + try: + with open(self.path, "wb") as fp: + plistlib.dump(self.__raw_data, fp) + except Exception as e: + raise TagsetWriteError(f"An error occurred: {e}") from e def load(self) -> "Tagset": - tagset_data = self._parse_plist() + self.__raw_data = self._parse_plist() self.tags_id = self.path.name.removesuffix(".tagset") - self.nickname = tagset_data.get("nickname") - self.shortname = tagset_data.get("shortname") - self.tags = tagset_data.get("tags") or {} + self.nickname = self.__raw_data.get("nickname") + self.shortname = self.__raw_data.get("shortname") + self.tags = self.__raw_data.get("tags") or {} return self + def set_nickname(self, nickname: str): + self.load() + self.__raw_data["nickname"] = nickname + self._write_plist() + self.load() + + def set_shortname(self, shortname: str): + self.load() + self.__raw_data["shortname"] = shortname + self._write_plist() + self.load() + + def set_tags(self, tags: dict[str, str]): + self.load() + self.__raw_data["tags"] = tags + self._write_plist() + self.load() + + def add_tag(self, tag: str, value: str): + self.load() + self.tags[tag] = value + self._write_plist() + self.load() + + def remove_tag(self, tag: str): + self.load() + del self.tags[tag] + self._write_plist() + self.load() + + def move_to_tag(self, tag: str, value: str): + self.load() + self.tags.clear() + self.tags[tag] = value + self._write_plist() + self.load() + __all__ = ["Tagset"]