feat: reordering categories, backup revert tracking for categories

This commit is contained in:
h
2026-01-31 20:41:29 +01:00
parent f7b8caf86c
commit a2bce69cf0
7 changed files with 1122 additions and 47 deletions

View File

@@ -15,6 +15,8 @@ from illogical.modules.backup_models import (
BackupManifest,
BackupSettings,
BackupTrigger,
CategoryChange,
CategoryChangeType,
ChangeType,
DetailedBackupChanges,
FieldChange,
@@ -32,6 +34,7 @@ BACKUP_INDEX_FILENAME = ".backup_index.json"
TAGS_PATH = tags_path
BACKUPS_PATH = tags_path.parent / ".nothing_to_see_here_just_illogical_saving_ur_ass"
MUSICAPPS_PROPERTIES = "MusicApps.properties"
def _compute_file_checksum(file_path: Path) -> str:
@@ -353,6 +356,47 @@ def _tags_id_from_filename(filename: str) -> str:
return filename.removesuffix(".tagset")
def _parse_musicapps_sorting(path: Path) -> list[str]:
props_file = path / MUSICAPPS_PROPERTIES
if not props_file.exists():
return []
try:
with props_file.open("rb") as f:
data = plistlib.load(f)
return data.get("sorting", [])
except Exception: # noqa: BLE001
return []
def _compute_category_changes(
backup_sorting: list[str], current_sorting: list[str]
) -> list[CategoryChange]:
changes: list[CategoryChange] = []
backup_set = set(backup_sorting)
current_set = set(current_sorting)
moved_new_paths: set[str] = set()
for old_path in backup_set - current_set:
base_name = old_path.split(":")[-1]
moved_to = next(
(c for c in current_set - backup_set if c.split(":")[-1] == base_name), None
)
if moved_to:
changes.append(CategoryChange(old_path, moved_to, CategoryChangeType.MOVED))
moved_new_paths.add(moved_to)
else:
changes.append(CategoryChange(old_path, None, CategoryChangeType.DELETED))
changes.extend(
CategoryChange(None, new_path, CategoryChangeType.ADDED)
for new_path in current_set - backup_set
if new_path not in moved_new_paths
)
return changes
def compute_detailed_changes(
backup_name: str, logic: Logic | None = None
) -> DetailedBackupChanges:
@@ -404,4 +448,8 @@ def compute_detailed_changes(
PluginChange(tags_id, plugin_name, ChangeType.MODIFIED, field_changes)
)
return DetailedBackupChanges(plugins=plugin_changes)
backup_sorting = _parse_musicapps_sorting(backup_path)
current_sorting = _parse_musicapps_sorting(TAGS_PATH)
category_changes = _compute_category_changes(backup_sorting, current_sorting)
return DetailedBackupChanges(plugins=plugin_changes, categories=category_changes)

View File

@@ -20,6 +20,12 @@ class ChangeType(Enum):
DELETED = "deleted"
class CategoryChangeType(Enum):
MOVED = "moved"
DELETED = "deleted"
ADDED = "added"
@dataclass
class FieldChange:
field_name: str
@@ -35,9 +41,17 @@ class PluginChange:
field_changes: list[FieldChange] = field(default_factory=list)
@dataclass
class CategoryChange:
old_path: str | None
new_path: str | None
change_type: CategoryChangeType
@dataclass
class DetailedBackupChanges:
plugins: list[PluginChange] = field(default_factory=list)
categories: list[CategoryChange] = field(default_factory=list)
@property
def added(self) -> list[PluginChange]:
@@ -51,9 +65,23 @@ class DetailedBackupChanges:
def deleted(self) -> list[PluginChange]:
return [p for p in self.plugins if p.change_type == ChangeType.DELETED]
@property
def categories_moved(self) -> list[CategoryChange]:
return [c for c in self.categories if c.change_type == CategoryChangeType.MOVED]
@property
def categories_deleted(self) -> list[CategoryChange]:
return [
c for c in self.categories if c.change_type == CategoryChangeType.DELETED
]
@property
def categories_added(self) -> list[CategoryChange]:
return [c for c in self.categories if c.change_type == CategoryChangeType.ADDED]
@property
def is_empty(self) -> bool:
return len(self.plugins) == 0
return len(self.plugins) == 0 and len(self.categories) == 0
@dataclass

View File

@@ -2,10 +2,18 @@ from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
from logic_plugin_manager.exceptions import (
CategoryExistsError,
CategoryValidationError,
MusicAppsLoadError,
MusicAppsWriteError,
)
from PySide6.QtCore import (
QAbstractItemModel,
QAbstractListModel,
QAbstractTableModel,
QByteArray,
QMimeData,
QModelIndex,
QObject,
QSortFilterProxyModel,
@@ -14,10 +22,19 @@ from PySide6.QtCore import (
)
from illogical.modules.sf_symbols import sf_symbol
from illogical.modules.virtual_category import VirtualCategoryTree
if TYPE_CHECKING:
from logic_plugin_manager import AudioComponent, Logic
CategoryError = (
MusicAppsLoadError,
MusicAppsWriteError,
CategoryExistsError,
CategoryValidationError,
OSError,
ValueError,
)
COL_NAME = 0
COL_CUSTOM_NAME = 1
@@ -246,12 +263,21 @@ class PluginTableModel(QAbstractTableModel):
class CategoryTreeItem:
def __init__(
self, name: str, full_path: str, parent: CategoryTreeItem | None = None
self,
name: str,
full_path: str,
parent: CategoryTreeItem | None = None,
plugin_count: int = 0,
) -> None:
self.name = name
self.full_path = full_path
self.parent_item = parent
self.children: list[CategoryTreeItem] = []
self.plugin_count = plugin_count
@property
def is_empty(self) -> bool:
return self.plugin_count == 0
def append_child(self, child: CategoryTreeItem) -> None:
self.children.append(child)
@@ -270,56 +296,59 @@ class CategoryTreeItem:
return 0
CATEGORY_MIME_TYPE = "application/x-illogical-category"
class CategoryTreeModel(QAbstractItemModel):
category_changed = Signal()
error_occurred = Signal(str, str)
backup_requested = Signal(bool)
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self._root = CategoryTreeItem("", "")
self._virtual_tree: VirtualCategoryTree | None = None
self._logic: Logic | None = None
def build_from_plugins(self, logic: Logic) -> None:
self.beginResetModel()
self._root = CategoryTreeItem("", "")
categories: set[str] = set()
for plugin in logic.plugins.all():
for cat in plugin.categories:
if cat.name != "":
categories.add(cat.name)
category_items: dict[str, CategoryTreeItem] = {}
top_level_item = CategoryTreeItem("Top Level", "Top Level", self._root)
self._root.append_child(top_level_item)
for cat_path in categories:
parts = cat_path.split(":")
current_path = ""
parent_item = self._root
for part in parts:
current_path = f"{current_path}:{part}" if current_path else part
if current_path not in category_items:
item = CategoryTreeItem(part, current_path, parent_item)
parent_item.append_child(item)
category_items[current_path] = item
parent_item = category_items[current_path]
self._sort_category_tree(self._root, logic)
self._logic = logic
self._virtual_tree = VirtualCategoryTree()
self._virtual_tree.build_from_logic(logic)
self._root = self._build_qt_tree_from_virtual()
self.endResetModel()
def _sort_category_tree(self, item: CategoryTreeItem, logic: Logic) -> None:
def get_sort_key(path: str) -> tuple[int, str]:
if path in logic.categories:
return (logic.categories[path].index, path.lower())
return (2**31 - 1, path.lower())
def _build_qt_tree_from_virtual(self) -> CategoryTreeItem:
from illogical.modules.virtual_category import ( # noqa: PLC0415
VirtualCategoryNode,
)
top_level = [c for c in item.children if c.full_path == "Top Level"]
others = [c for c in item.children if c.full_path != "Top Level"]
others.sort(key=lambda c: get_sort_key(c.full_path))
item.children = top_level + others
for child in item.children:
self._sort_category_tree(child, logic)
if self._virtual_tree is None:
return CategoryTreeItem("", "")
root = CategoryTreeItem("", "")
def build_item(
virtual_node: VirtualCategoryNode, parent_item: CategoryTreeItem
) -> None:
for child_node in virtual_node.children:
item = CategoryTreeItem(
child_node.name,
child_node.full_path,
parent_item,
child_node.plugin_count,
)
parent_item.append_child(item)
build_item(child_node, item)
build_item(self._virtual_tree.root, root)
return root
def _rebuild_from_virtual(self) -> None:
self.beginResetModel()
self._root = self._build_qt_tree_from_virtual()
self.endResetModel()
self.category_changed.emit()
def index(
self, row: int, column: int, parent: QModelIndex | None = None
@@ -396,6 +425,223 @@ class CategoryTreeModel(QAbstractItemModel):
return find_in_item(self._root, QModelIndex())
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
default_flags = super().flags(index)
if not index.isValid():
return default_flags | Qt.ItemFlag.ItemIsDropEnabled
item: CategoryTreeItem = index.internalPointer()
if item.full_path == "Top Level":
return default_flags
return (
default_flags
| Qt.ItemFlag.ItemIsDragEnabled
| Qt.ItemFlag.ItemIsDropEnabled
)
def supportedDropActions(self) -> Qt.DropAction: # noqa: N802
return Qt.DropAction.MoveAction
def mimeTypes(self) -> list[str]: # noqa: N802
return [CATEGORY_MIME_TYPE]
def mimeData(self, indexes: list[QModelIndex]) -> QMimeData: # noqa: N802
mime_data = QMimeData()
if not indexes:
return mime_data
paths = []
for index in indexes:
if index.isValid():
item: CategoryTreeItem = index.internalPointer()
if item.full_path and item.full_path != "Top Level":
paths.append(item.full_path)
if paths:
mime_data.setData(CATEGORY_MIME_TYPE, QByteArray(paths[0].encode("utf-8")))
return mime_data
def dropMimeData( # noqa: N802, C901, PLR0911, PLR0912
self,
data: QMimeData,
action: Qt.DropAction,
row: int,
column: int, # noqa: ARG002
parent: QModelIndex,
) -> bool:
if action != Qt.DropAction.MoveAction:
return False
if not data.hasFormat(CATEGORY_MIME_TYPE):
return False
if self._virtual_tree is None or self._logic is None:
return False
raw_data = data.data(CATEGORY_MIME_TYPE).data()
source_path = bytes(raw_data).decode("utf-8") if raw_data else ""
source_node = self._virtual_tree.get_node(source_path)
if source_node is None:
return False
all_nodes = source_node.all_nodes_flat()
old_path_to_node = [(n.full_path, n) for n in all_nodes]
if parent.isValid():
target_item: CategoryTreeItem = parent.internalPointer()
target_path = target_item.full_path
else:
target_path = ""
if row == -1:
target_node = self._virtual_tree.get_node(target_path)
if target_node is None:
return False
if target_path == "Top Level":
return False
if not self._virtual_tree.insert_into_parent(source_node, target_node):
return False
else:
if target_path:
target_parent_node = self._virtual_tree.get_node(target_path)
else:
target_parent_node = self._virtual_tree.root
if target_parent_node is None:
return False
if row < len(target_parent_node.children):
sibling_node = target_parent_node.children[row]
if not self._virtual_tree.move_before(source_node, sibling_node):
return False
elif target_parent_node.children:
last_sibling = target_parent_node.children[-1]
if not self._virtual_tree.move_after(source_node, last_sibling):
return False
changed = {
old_path: n.full_path
for old_path, n in old_path_to_node
if old_path != n.full_path
}
self.backup_requested.emit(bool(changed))
try:
self._virtual_tree.sync_to_logic(self._logic, changed if changed else None)
self._virtual_tree.update_plugin_counts(self._logic)
except CategoryError as e:
self.error_occurred.emit("Category Move Failed", str(e))
return False
self._rebuild_from_virtual()
return True
def move_category_up(self, index: QModelIndex) -> bool:
if not index.isValid() or self._virtual_tree is None or self._logic is None:
return False
item: CategoryTreeItem = index.internalPointer()
node = self._virtual_tree.get_node(item.full_path)
if node is None:
return False
if not self._virtual_tree.move_within_level(node, -1):
return False
self.backup_requested.emit(False) # noqa: FBT003
try:
self._virtual_tree.sync_to_logic(self._logic)
except CategoryError as e:
self.error_occurred.emit("Category Move Failed", str(e))
return False
self._rebuild_from_virtual()
return True
def move_category_down(self, index: QModelIndex) -> bool:
if not index.isValid() or self._virtual_tree is None or self._logic is None:
return False
item: CategoryTreeItem = index.internalPointer()
node = self._virtual_tree.get_node(item.full_path)
if node is None:
return False
if not self._virtual_tree.move_within_level(node, 1):
return False
self.backup_requested.emit(False) # noqa: FBT003
try:
self._virtual_tree.sync_to_logic(self._logic)
except CategoryError as e:
self.error_occurred.emit("Category Move Failed", str(e))
return False
self._rebuild_from_virtual()
return True
def extract_category(self, index: QModelIndex) -> bool:
if not index.isValid() or self._virtual_tree is None or self._logic is None:
return False
item: CategoryTreeItem = index.internalPointer()
node = self._virtual_tree.get_node(item.full_path)
if node is None:
return False
all_nodes = node.all_nodes_flat()
old_path_to_node = [(n.full_path, n) for n in all_nodes]
if not self._virtual_tree.extract_from_parent(node):
return False
changed = {
old_path: n.full_path
for old_path, n in old_path_to_node
if old_path != n.full_path
}
self.backup_requested.emit(True) # noqa: FBT003
try:
self._virtual_tree.sync_to_logic(self._logic, changed if changed else None)
self._virtual_tree.update_plugin_counts(self._logic)
except CategoryError as e:
self.error_occurred.emit("Category Move Failed", str(e))
return False
self._rebuild_from_virtual()
return True
def can_delete_category(self, index: QModelIndex) -> bool:
if not index.isValid():
return False
item: CategoryTreeItem = index.internalPointer()
if item.full_path == "Top Level":
return False
return item.is_empty and not item.children
def delete_category(self, index: QModelIndex) -> bool:
if not index.isValid() or self._virtual_tree is None or self._logic is None:
return False
if not self.can_delete_category(index):
return False
self.backup_requested.emit(True) # noqa: FBT003
item: CategoryTreeItem = index.internalPointer()
if not self._virtual_tree.delete_category(item.full_path, self._logic):
return False
self._rebuild_from_virtual()
return True
def get_item_at_index(self, index: QModelIndex) -> CategoryTreeItem | None:
if not index.isValid():
return None
return index.internalPointer()
class ManufacturerListModel(QAbstractListModel):
def __init__(self, parent: QObject | None = None) -> None:

View File

@@ -0,0 +1,474 @@
from __future__ import annotations
import contextlib
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from logic_plugin_manager.exceptions import CategoryExistsError
if TYPE_CHECKING:
from logic_plugin_manager import Logic
@dataclass
class VirtualCategoryNode:
name: str
full_path: str
parent: VirtualCategoryNode | None = None
children: list[VirtualCategoryNode] = field(default_factory=list)
plugin_count: int = 0
@property
def depth(self) -> int:
level = 0
node = self.parent
while node is not None and node.full_path:
level += 1
node = node.parent
return level
@property
def siblings(self) -> list[VirtualCategoryNode]:
if self.parent is None:
return [self]
return self.parent.children
@property
def sibling_index(self) -> int:
return self.siblings.index(self)
@property
def is_first(self) -> bool:
return self.sibling_index == 0
@property
def is_last(self) -> bool:
return self.sibling_index == len(self.siblings) - 1
@property
def is_empty(self) -> bool:
return self.plugin_count == 0
def descendants_flat(self) -> list[VirtualCategoryNode]:
result: list[VirtualCategoryNode] = []
for child in self.children:
result.append(child)
result.extend(child.descendants_flat())
return result
def all_nodes_flat(self) -> list[VirtualCategoryNode]:
result = [self]
for child in self.children:
result.extend(child.all_nodes_flat())
return result
class VirtualCategoryTree:
def __init__(self) -> None:
self._root = VirtualCategoryNode(name="", full_path="")
self._nodes: dict[str, VirtualCategoryNode] = {}
self._top_level: VirtualCategoryNode | None = None
@property
def root(self) -> VirtualCategoryNode:
return self._root
def get_node(self, path: str) -> VirtualCategoryNode | None:
return self._nodes.get(path)
def build_from_logic(self, logic: Logic) -> None:
self._root = VirtualCategoryNode(name="", full_path="")
self._nodes = {}
self._top_level = VirtualCategoryNode(
name="Top Level", full_path="Top Level", parent=self._root
)
self._root.children.append(self._top_level)
self._nodes["Top Level"] = self._top_level
plugin_categories: dict[str, int] = {}
for plugin in logic.plugins.all():
for cat in plugin.categories:
if cat.name:
plugin_categories[cat.name] = plugin_categories.get(cat.name, 0) + 1
tagpool_categories = set(logic.musicapps.tagpool.categories.keys())
sorting_categories = set(logic.musicapps.properties.sorting)
all_category_paths = (
set(plugin_categories.keys()) | tagpool_categories | sorting_categories
)
for cat_path in all_category_paths:
if not cat_path:
continue
self._ensure_category_exists(cat_path, plugin_categories.get(cat_path, 0))
self._sort_by_logic_indexes(logic)
def _ensure_category_exists(
self, cat_path: str, plugin_count: int = 0
) -> VirtualCategoryNode:
if cat_path in self._nodes:
if plugin_count > 0:
self._nodes[cat_path].plugin_count = plugin_count
return self._nodes[cat_path]
parts = cat_path.split(":")
current_path = ""
parent_node = self._root
for i, part in enumerate(parts):
current_path = f"{current_path}:{part}" if current_path else part
if current_path in self._nodes:
parent_node = self._nodes[current_path]
else:
is_final = i == len(parts) - 1
node = VirtualCategoryNode(
name=part,
full_path=current_path,
parent=parent_node,
plugin_count=plugin_count if is_final else 0,
)
parent_node.children.append(node)
self._nodes[current_path] = node
parent_node = node
return self._nodes[cat_path]
def _sort_by_logic_indexes(self, logic: Logic) -> None:
def get_sort_key(node: VirtualCategoryNode) -> tuple[int, str]:
if node.full_path in logic.categories:
return (logic.categories[node.full_path].index, node.full_path.lower())
return (2**31 - 1, node.full_path.lower())
def sort_children(node: VirtualCategoryNode) -> None:
top_level = [c for c in node.children if c.full_path == "Top Level"]
others = [c for c in node.children if c.full_path != "Top Level"]
others.sort(key=get_sort_key)
node.children = top_level + others
for child in node.children:
sort_children(child)
sort_children(self._root)
def move_within_level(self, node: VirtualCategoryNode, delta: int) -> bool:
if node.parent is None:
return False
siblings = node.parent.children
current_idx = siblings.index(node)
new_idx = current_idx + delta
if node.full_path == "Top Level":
return False
if new_idx < 0 or new_idx >= len(siblings):
return False
target = siblings[new_idx]
if target.full_path == "Top Level":
return False
siblings[current_idx], siblings[new_idx] = (
siblings[new_idx],
siblings[current_idx],
)
return True
def extract_from_parent(self, node: VirtualCategoryNode) -> bool:
if node.parent is None or not node.parent.full_path:
return False
if node.full_path == "Top Level":
return False
old_parent = node.parent
grandparent = old_parent.parent
if grandparent is None:
return False
old_node_path = node.full_path
if grandparent.full_path:
new_node_path = f"{grandparent.full_path}:{node.name}"
else:
new_node_path = node.name
del self._nodes[node.full_path]
node.full_path = new_node_path
self._nodes[node.full_path] = node
for desc in node.descendants_flat():
del self._nodes[desc.full_path]
desc.full_path = desc.full_path.replace(old_node_path, new_node_path, 1)
self._nodes[desc.full_path] = desc
old_parent.children.remove(node)
parent_idx = grandparent.children.index(old_parent)
grandparent.children.insert(parent_idx + 1, node)
node.parent = grandparent
return True
def insert_into_parent( # noqa: C901
self, node: VirtualCategoryNode, new_parent: VirtualCategoryNode
) -> bool:
if node.full_path == "Top Level":
return False
if new_parent.full_path == "Top Level":
return False
if node.parent is None:
return False
old_path = node.full_path
new_path = (
f"{new_parent.full_path}:{node.name}" if new_parent.full_path else node.name
)
if new_path == old_path:
return False
if new_path in self._nodes and self._nodes[new_path] != node:
return False
old_paths = {node.full_path: node}
for desc in node.descendants_flat():
old_paths[desc.full_path] = desc
if node.full_path in self._nodes:
del self._nodes[node.full_path]
node.full_path = new_path
self._nodes[new_path] = node
for desc in node.descendants_flat():
old_desc_path = next(k for k, v in old_paths.items() if v == desc)
if old_desc_path in self._nodes:
del self._nodes[old_desc_path]
desc.full_path = desc.full_path.replace(old_path, new_path, 1)
self._nodes[desc.full_path] = desc
if node in node.parent.children:
node.parent.children.remove(node)
new_parent.children.append(node)
node.parent = new_parent
return True
def move_before( # noqa: C901, PLR0911
self, node: VirtualCategoryNode, target: VirtualCategoryNode
) -> bool:
if node.full_path == "Top Level" or target.full_path == "Top Level":
return False
if node == target:
return False
if target.parent is None:
return False
target_parent = target.parent
if target not in target_parent.children:
return False
target_idx = target_parent.children.index(target)
if node.parent == target_parent:
if node not in target_parent.children:
return False
node_idx = target_parent.children.index(node)
target_parent.children.remove(node)
if node_idx < target_idx:
target_idx -= 1
target_parent.children.insert(target_idx, node)
return True
old_path = node.full_path
new_prefix = target_parent.full_path
new_path = f"{new_prefix}:{node.name}" if new_prefix else node.name
if new_path in self._nodes and self._nodes[new_path] != node:
return False
if new_path != old_path:
old_paths = list(self._nodes.keys())
for p in old_paths:
if p == old_path or p.startswith(old_path + ":"):
n = self._nodes.pop(p)
n.full_path = p.replace(old_path, new_path, 1)
self._nodes[n.full_path] = n
node.full_path = new_path
if node.parent and node in node.parent.children:
node.parent.children.remove(node)
target_parent.children.insert(target_idx, node)
node.parent = target_parent
return True
def move_after( # noqa: C901, PLR0911
self, node: VirtualCategoryNode, target: VirtualCategoryNode
) -> bool:
if node.full_path == "Top Level" or target.full_path == "Top Level":
return False
if node == target:
return False
if target.parent is None:
return False
target_parent = target.parent
if target not in target_parent.children:
return False
target_idx = target_parent.children.index(target) + 1
if node.parent == target_parent:
if node not in target_parent.children:
return False
node_idx = target_parent.children.index(node)
target_parent.children.remove(node)
if node_idx < target_idx:
target_idx -= 1
target_parent.children.insert(target_idx, node)
return True
old_path = node.full_path
new_prefix = target_parent.full_path
new_path = f"{new_prefix}:{node.name}" if new_prefix else node.name
if new_path in self._nodes and self._nodes[new_path] != node:
return False
if new_path != old_path:
old_paths = list(self._nodes.keys())
for p in old_paths:
if p == old_path or p.startswith(old_path + ":"):
n = self._nodes.pop(p)
n.full_path = p.replace(old_path, new_path, 1)
self._nodes[n.full_path] = n
node.full_path = new_path
if node.parent and node in node.parent.children:
node.parent.children.remove(node)
target_parent.children.insert(target_idx, node)
node.parent = target_parent
return True
def calculate_flat_indexes(self) -> dict[str, int]:
indexes: dict[str, int] = {}
current_index = 0
def traverse(node: VirtualCategoryNode) -> None:
nonlocal current_index
if node.full_path and node.full_path != "Top Level":
indexes[node.full_path] = current_index
current_index += 1
for child in node.children:
traverse(child)
traverse(self._root)
return indexes
def sync_to_logic( # noqa: C901, PLR0912
self, logic: Logic, changed_paths: dict[str, str] | None = None
) -> None:
changed_new_paths: set[str] = set()
old_paths_to_remove: set[str] = set()
if changed_paths:
sorted_changes = sorted(
changed_paths.items(), key=lambda x: len(x[0]), reverse=True
)
for old_path, new_path in sorted_changes:
if old_path == new_path:
continue
changed_new_paths.add(new_path)
old_paths_to_remove.add(old_path)
plugins_to_move = list(logic.plugins.get_by_category(old_path))
try:
new_cat = logic.introduce_category(new_path)
except CategoryExistsError:
new_cat = logic.categories.get(new_path)
if new_cat is None:
logic.discover_categories()
new_cat = logic.categories[new_path]
logic.discover_categories()
for plugin in plugins_to_move:
plugin.add_to_category(new_cat)
old_cat = logic.categories.get(old_path)
if old_cat:
with contextlib.suppress(Exception):
plugin.remove_from_category(old_cat)
logic.plugins.reindex_all()
for new_path in changed_new_paths:
if new_path in logic.categories:
logic.sync_category_plugin_amount(logic.categories[new_path])
node = self.get_node(new_path)
if node:
plugins_in_cat = list(logic.plugins.get_by_category(new_path))
node.plugin_count = len(plugins_in_cat)
logic.discover_categories()
sorted_old_paths = sorted(
old_paths_to_remove, key=lambda p: len(p), reverse=True
)
for old_path in sorted_old_paths:
if old_path in logic.categories:
remaining = list(logic.plugins.get_by_category(old_path))
if not remaining:
with contextlib.suppress(Exception):
logic.musicapps.remove_category(old_path)
flat_indexes = self.calculate_flat_indexes()
sorted_paths = sorted(flat_indexes.keys(), key=lambda p: flat_indexes[p])
logic.discover_categories()
for path in sorted_paths:
if path not in logic.categories:
continue
cat = logic.categories[path]
target_index = flat_indexes[path]
if cat.index != target_index:
with contextlib.suppress(ValueError):
cat.move_to(target_index)
def update_plugin_counts(self, logic: Logic) -> None:
logic.plugins.reindex_all()
for path, node in self._nodes.items():
if path == "Top Level":
continue
node.plugin_count = len(list(logic.plugins.get_by_category(path)))
def delete_category(self, path: str, logic: Logic) -> bool:
node = self.get_node(path)
if node is None:
return False
if node.full_path == "Top Level":
return False
if node.plugin_count > 0:
return False
if node.children:
return False
if node.parent:
node.parent.children.remove(node)
del self._nodes[path]
if path in logic.categories:
logic.musicapps.remove_category(path)
return True
def get_category_paths_for_move(self, node: VirtualCategoryNode) -> dict[str, str]:
result: dict[str, str] = {}
old_path = node.full_path
for n in node.all_nodes_flat():
if n.full_path != old_path:
result[n.full_path.replace(old_path, node.full_path, 1)] = n.full_path
return result

View File

@@ -87,6 +87,7 @@ class MainWindow(QMainWindow):
self._sidebar.category_selected.connect(self._on_category_selected)
self._sidebar.manufacturer_selected.connect(self._on_manufacturer_selected)
self._sidebar.enter_pressed.connect(self._plugin_table.focus_table)
self._sidebar.backup_requested.connect(self._on_category_backup_requested)
self._plugin_table.search_changed.connect(self._on_search_changed)
self._plugin_table.plugin_selected.connect(self._on_plugin_selected)
self._plugin_table.edit_requested.connect(self._on_plugin_edit_requested)
@@ -194,6 +195,12 @@ class MainWindow(QMainWindow):
except OSError as e:
QMessageBox.warning(self, "Edit Failed", f"Failed to save changes: {e}")
def _on_category_backup_requested(self, force: bool) -> None: # noqa: FBT001
if force or backup_manager.should_create_auto_backup():
backup_manager.create_backup(
BackupTrigger.AUTO, "Before category modification"
)
def _on_search_results(self, results: list[SearchResult]) -> None:
plugins = [r.plugin for r in results]
self._plugin_table.filter_by_search_results(

View File

@@ -19,6 +19,8 @@ from PySide6.QtWidgets import (
from illogical.modules.backup_models import (
BackupInfo,
BackupTrigger,
CategoryChange,
CategoryChangeType,
ChangeType,
DetailedBackupChanges,
FieldChange,
@@ -167,6 +169,16 @@ class RestoreBackupWindow(QDialog):
self._changes_tree.addTopLevelItem(item)
return
self._add_category_group(
changes.categories_added, "Categories to remove", "folder.badge.minus"
)
self._add_category_group(
changes.categories_moved, "Categories to revert", "folder.badge.gearshape"
)
self._add_category_group(
changes.categories_deleted, "Categories to restore", "folder.badge.plus"
)
self._add_plugin_group(changes.added, "Plugins to remove", "minus.circle")
self._add_plugin_group(
changes.modified, "Plugins to revert", "arrow.uturn.backward.circle"
@@ -195,6 +207,31 @@ class RestoreBackupWindow(QDialog):
self._changes_tree.addTopLevelItem(group_item)
group_item.setExpanded(True)
def _add_category_group(
self, categories: list[CategoryChange], label: str, icon_name: str
) -> None:
if not categories:
return
group_item = QTreeWidgetItem([f"{label} ({len(categories)})"])
icon = sf_symbol(icon_name, 14)
if not icon.isNull():
group_item.setIcon(0, icon)
for cat in sorted(
categories, key=lambda c: (c.old_path or c.new_path or "").lower()
):
if cat.change_type == CategoryChangeType.MOVED:
text = f"{cat.new_path}{cat.old_path}"
elif cat.change_type == CategoryChangeType.DELETED:
text = cat.old_path or ""
else:
text = cat.new_path or ""
QTreeWidgetItem(group_item, [text])
self._changes_tree.addTopLevelItem(group_item)
group_item.setExpanded(True)
def _format_field_change(self, change: FieldChange) -> str:
if change.field_name.startswith("category:"):
category = change.field_name.split(":", 1)[1]

View File

@@ -3,14 +3,15 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from AppKit import NSColor # type: ignore[attr-defined]
from PySide6.QtCore import QModelIndex, QRect, Qt, Signal
from PySide6.QtGui import QFont
from PySide6.QtCore import QModelIndex, QPoint, QRect, Qt, QTimer, Signal
from PySide6.QtGui import QCursor, QFont
from PySide6.QtWidgets import (
QAbstractItemView,
QFrame,
QHBoxLayout,
QLabel,
QListView,
QMenu,
QSplitter,
QStyle,
QStyledItemDelegate,
@@ -21,6 +22,7 @@ from PySide6.QtWidgets import (
)
from illogical.modules.models import (
CategoryTreeItem,
CategoryTreeModel,
ManufacturerFilterProxy,
ManufacturerListModel,
@@ -42,15 +44,109 @@ KVK_L = 0x25
class _VimTreeView(QTreeView):
enter_pressed = Signal()
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setDragEnabled(True)
self.setAcceptDrops(True)
self.setDropIndicatorShown(True)
self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
self.setDefaultDropAction(Qt.DropAction.MoveAction)
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self._on_context_menu_requested)
self._expanded_paths: set[str] = set()
self.context_menu_path: str | None = None
def setModel(self, model: CategoryTreeModel | None) -> None: # noqa: N802
old_model = self.model()
if old_model is not None:
old_model.modelAboutToBeReset.disconnect(self._save_expanded_state)
old_model.modelReset.disconnect(self._restore_expanded_state)
super().setModel(model)
if model is not None:
model.modelAboutToBeReset.connect(self._save_expanded_state)
model.modelReset.connect(self._restore_expanded_state)
def _save_expanded_state(self) -> None:
self._expanded_paths.clear()
model = self.model()
if not isinstance(model, CategoryTreeModel):
return
def collect_expanded(parent: QModelIndex) -> None:
for row in range(model.rowCount(parent)):
index = model.index(row, 0, parent)
if self.isExpanded(index):
path = index.data(Qt.ItemDataRole.UserRole)
if path:
self._expanded_paths.add(path)
collect_expanded(index)
collect_expanded(QModelIndex())
def _restore_expanded_state(self) -> None:
model = self.model()
if not isinstance(model, CategoryTreeModel):
return
for path in self._expanded_paths:
index = model.index_for_path(path)
if index.isValid():
self.expand(index)
def _select_and_activate(self, index: QModelIndex) -> None:
self.selectionModel().setCurrentIndex(
index, self.selectionModel().SelectionFlag.ClearAndSelect
)
self.clicked.emit(index)
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
def _on_context_menu_requested(self, pos: QPoint) -> None:
index = self.indexAt(pos)
if index.isValid():
self._show_context_menu(index)
def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802
if event.button() == Qt.MouseButton.RightButton:
event.accept()
return
super().mousePressEvent(event)
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802, C901, PLR0911, PLR0912
vk = event.nativeVirtualKey()
key = event.key()
mods = event.modifiers()
has_alt = bool(mods & Qt.KeyboardModifier.AltModifier)
has_shift = bool(mods & Qt.KeyboardModifier.ShiftModifier)
has_ctrl = bool(mods & Qt.KeyboardModifier.ControlModifier)
has_meta = bool(mods & Qt.KeyboardModifier.MetaModifier)
if has_alt and has_shift and not has_ctrl and not has_meta:
model = self.model()
current = self.currentIndex()
if isinstance(model, CategoryTreeModel):
if key == Qt.Key.Key_Up:
if not model.move_category_up(current):
model.extract_category(current)
self._restore_selection_after_move(current)
event.accept()
return
if key == Qt.Key.Key_Down:
if not model.move_category_down(current):
model.extract_category(current)
self._restore_selection_after_move(current)
event.accept()
return
if (
has_alt
and not has_shift
and not has_ctrl
and not has_meta
and key in (Qt.Key.Key_Return, Qt.Key.Key_Enter)
):
self._show_context_menu(self.currentIndex())
event.accept()
return
if vk == KVK_J or key == Qt.Key.Key_Down:
next_idx = self.indexBelow(self.currentIndex())
@@ -78,6 +174,109 @@ class _VimTreeView(QTreeView):
return
super().keyPressEvent(event)
def _restore_selection_after_move(self, old_index: QModelIndex) -> None:
if not old_index.isValid():
return
item: CategoryTreeItem = old_index.internalPointer()
path = item.full_path
model = self.model()
if isinstance(model, CategoryTreeModel):
new_index = model.index_for_path(path)
if new_index.isValid():
parent = new_index.parent()
while parent.isValid():
self.expand(parent)
parent = parent.parent()
self.selectionModel().setCurrentIndex(
new_index, self.selectionModel().SelectionFlag.ClearAndSelect
)
def _show_context_menu(self, index: QModelIndex) -> None:
if not index.isValid():
return
model = self.model()
if not isinstance(model, CategoryTreeModel):
return
item: CategoryTreeItem = index.internalPointer()
if item.full_path == "Top Level":
return
self.context_menu_path = item.full_path
self.viewport().update()
import pyqt_liquidglass as glass # noqa: PLC0415
menu = QMenu(self)
menu.aboutToHide.connect(self._on_context_menu_hidden)
menu.setStyleSheet("""
QMenu {
background: transparent;
border: none;
border-radius: 10px;
padding: 4px 2px;
}
QMenu::item {
padding: 6px 14px 6px 6px;
margin: 0px 2px;
border-radius: 6px;
color: rgba(255, 255, 255, 0.9);
}
QMenu::item:selected {
background-color: rgba(255, 255, 255, 0.15);
}
QMenu::icon {
padding-left: 6px;
}
QMenu::separator {
height: 1px;
background: rgba(255, 255, 255, 0.12);
margin: 6px 10px;
}
""")
glass.prepare_window_for_glass(menu)
move_up_action = menu.addAction(sf_symbol("arrow.up", 14), "Move Up")
move_up_action.setShortcut("Alt+Shift+Up")
move_up_action.triggered.connect(lambda: self._do_move_up(model, index))
move_down_action = menu.addAction(sf_symbol("arrow.down", 14), "Move Down")
move_down_action.setShortcut("Alt+Shift+Down")
move_down_action.triggered.connect(lambda: self._do_move_down(model, index))
if item.parent_item and item.parent_item.full_path:
menu.addSeparator()
extract_action = menu.addAction(
sf_symbol("arrow.turn.left.up", 14), "Move Out of Parent"
)
extract_action.triggered.connect(lambda: self._do_extract(model, index))
if model.can_delete_category(index):
menu.addSeparator()
delete_action = menu.addAction(sf_symbol("trash", 14), "Delete Category")
delete_action.triggered.connect(lambda: model.delete_category(index))
menu.popup(QCursor.pos())
opts = glass.GlassOptions(corner_radius=10.0)
QTimer.singleShot(0, lambda: glass.apply_glass_to_window(menu, opts))
def _on_context_menu_hidden(self) -> None:
self.context_menu_path = None
self.viewport().update()
def _do_move_up(self, model: CategoryTreeModel, index: QModelIndex) -> None:
model.move_category_up(index)
self._restore_selection_after_move(index)
def _do_move_down(self, model: CategoryTreeModel, index: QModelIndex) -> None:
model.move_category_down(index)
self._restore_selection_after_move(index)
def _do_extract(self, model: CategoryTreeModel, index: QModelIndex) -> None:
model.extract_category(index)
self._restore_selection_after_move(index)
class _VimListView(QListView):
enter_pressed = Signal()
@@ -117,9 +316,23 @@ class _CategoryDelegate(QStyledItemDelegate):
def paint(
self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex
) -> None:
from PySide6.QtGui import QBrush, QColor, QPainterPath # noqa: PLC0415
full_path = index.data(Qt.ItemDataRole.UserRole)
icon = index.data(Qt.ItemDataRole.DecorationRole)
tree_view = option.widget
is_context_target = (
isinstance(tree_view, _VimTreeView)
and tree_view.context_menu_path == full_path
)
if is_context_target:
painter.save()
path = QPainterPath()
path.addRoundedRect(option.rect.toRectF(), 4, 4)
painter.fillPath(path, QBrush(QColor(128, 128, 128, 60)))
painter.restore()
if full_path == "Top Level" and icon and not icon.isNull():
opt = QStyleOptionViewItem(option)
self.initStyleOption(opt, index)
@@ -252,6 +465,7 @@ class Sidebar(QWidget):
category_selected = Signal(object)
manufacturer_selected = Signal(str)
enter_pressed = Signal()
backup_requested = Signal(bool)
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
@@ -320,7 +534,9 @@ class Sidebar(QWidget):
tree_layout.setSpacing(0)
self._category_model = CategoryTreeModel()
self._category_tree = _VimTreeView()
self._category_model.error_occurred.connect(self._show_category_error)
self._category_model.backup_requested.connect(self.backup_requested)
self._category_tree = _VimTreeView(self)
self._category_tree.setItemDelegate(_CategoryDelegate(self._category_tree))
self._category_tree.setModel(self._category_model)
self._category_tree.setHeaderHidden(True)
@@ -394,6 +610,25 @@ class Sidebar(QWidget):
self._manufacturer_list.clearSelection()
self._uncategorized.set_selected(False)
def _show_category_error(self, title: str, message: str) -> None:
from AppKit import NSAlert, NSAlertStyleWarning, NSApp # noqa: PLC0415
alert = NSAlert.alloc().init()
alert.setMessageText_(title)
alert.setInformativeText_(message)
alert.setAlertStyle_(NSAlertStyleWarning)
alert.addButtonWithTitle_("OK")
window = None
if self.window():
window = self.window().winId().__int__()
ns_window = NSApp.windowWithWindowNumber_(window)
if ns_window:
alert.beginSheetModalForWindow_completionHandler_(ns_window, None)
return
alert.runModal()
def _on_show_all_clicked(self) -> None:
self._active_category = "Show All"
self._active_manufacturer = None