feat: reordering categories, backup revert tracking for categories
This commit is contained in:
@@ -15,6 +15,8 @@ from illogical.modules.backup_models import (
|
|||||||
BackupManifest,
|
BackupManifest,
|
||||||
BackupSettings,
|
BackupSettings,
|
||||||
BackupTrigger,
|
BackupTrigger,
|
||||||
|
CategoryChange,
|
||||||
|
CategoryChangeType,
|
||||||
ChangeType,
|
ChangeType,
|
||||||
DetailedBackupChanges,
|
DetailedBackupChanges,
|
||||||
FieldChange,
|
FieldChange,
|
||||||
@@ -32,6 +34,7 @@ BACKUP_INDEX_FILENAME = ".backup_index.json"
|
|||||||
|
|
||||||
TAGS_PATH = tags_path
|
TAGS_PATH = tags_path
|
||||||
BACKUPS_PATH = tags_path.parent / ".nothing_to_see_here_just_illogical_saving_ur_ass"
|
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:
|
def _compute_file_checksum(file_path: Path) -> str:
|
||||||
@@ -353,6 +356,47 @@ def _tags_id_from_filename(filename: str) -> str:
|
|||||||
return filename.removesuffix(".tagset")
|
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(
|
def compute_detailed_changes(
|
||||||
backup_name: str, logic: Logic | None = None
|
backup_name: str, logic: Logic | None = None
|
||||||
) -> DetailedBackupChanges:
|
) -> DetailedBackupChanges:
|
||||||
@@ -404,4 +448,8 @@ def compute_detailed_changes(
|
|||||||
PluginChange(tags_id, plugin_name, ChangeType.MODIFIED, field_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)
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ class ChangeType(Enum):
|
|||||||
DELETED = "deleted"
|
DELETED = "deleted"
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryChangeType(Enum):
|
||||||
|
MOVED = "moved"
|
||||||
|
DELETED = "deleted"
|
||||||
|
ADDED = "added"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class FieldChange:
|
class FieldChange:
|
||||||
field_name: str
|
field_name: str
|
||||||
@@ -35,9 +41,17 @@ class PluginChange:
|
|||||||
field_changes: list[FieldChange] = field(default_factory=list)
|
field_changes: list[FieldChange] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CategoryChange:
|
||||||
|
old_path: str | None
|
||||||
|
new_path: str | None
|
||||||
|
change_type: CategoryChangeType
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DetailedBackupChanges:
|
class DetailedBackupChanges:
|
||||||
plugins: list[PluginChange] = field(default_factory=list)
|
plugins: list[PluginChange] = field(default_factory=list)
|
||||||
|
categories: list[CategoryChange] = field(default_factory=list)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def added(self) -> list[PluginChange]:
|
def added(self) -> list[PluginChange]:
|
||||||
@@ -51,9 +65,23 @@ class DetailedBackupChanges:
|
|||||||
def deleted(self) -> list[PluginChange]:
|
def deleted(self) -> list[PluginChange]:
|
||||||
return [p for p in self.plugins if p.change_type == ChangeType.DELETED]
|
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
|
@property
|
||||||
def is_empty(self) -> bool:
|
def is_empty(self) -> bool:
|
||||||
return len(self.plugins) == 0
|
return len(self.plugins) == 0 and len(self.categories) == 0
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -2,10 +2,18 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING, ClassVar
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
|
|
||||||
|
from logic_plugin_manager.exceptions import (
|
||||||
|
CategoryExistsError,
|
||||||
|
CategoryValidationError,
|
||||||
|
MusicAppsLoadError,
|
||||||
|
MusicAppsWriteError,
|
||||||
|
)
|
||||||
from PySide6.QtCore import (
|
from PySide6.QtCore import (
|
||||||
QAbstractItemModel,
|
QAbstractItemModel,
|
||||||
QAbstractListModel,
|
QAbstractListModel,
|
||||||
QAbstractTableModel,
|
QAbstractTableModel,
|
||||||
|
QByteArray,
|
||||||
|
QMimeData,
|
||||||
QModelIndex,
|
QModelIndex,
|
||||||
QObject,
|
QObject,
|
||||||
QSortFilterProxyModel,
|
QSortFilterProxyModel,
|
||||||
@@ -14,10 +22,19 @@ from PySide6.QtCore import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from illogical.modules.sf_symbols import sf_symbol
|
from illogical.modules.sf_symbols import sf_symbol
|
||||||
|
from illogical.modules.virtual_category import VirtualCategoryTree
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from logic_plugin_manager import AudioComponent, Logic
|
from logic_plugin_manager import AudioComponent, Logic
|
||||||
|
|
||||||
|
CategoryError = (
|
||||||
|
MusicAppsLoadError,
|
||||||
|
MusicAppsWriteError,
|
||||||
|
CategoryExistsError,
|
||||||
|
CategoryValidationError,
|
||||||
|
OSError,
|
||||||
|
ValueError,
|
||||||
|
)
|
||||||
|
|
||||||
COL_NAME = 0
|
COL_NAME = 0
|
||||||
COL_CUSTOM_NAME = 1
|
COL_CUSTOM_NAME = 1
|
||||||
@@ -246,12 +263,21 @@ class PluginTableModel(QAbstractTableModel):
|
|||||||
|
|
||||||
class CategoryTreeItem:
|
class CategoryTreeItem:
|
||||||
def __init__(
|
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:
|
) -> None:
|
||||||
self.name = name
|
self.name = name
|
||||||
self.full_path = full_path
|
self.full_path = full_path
|
||||||
self.parent_item = parent
|
self.parent_item = parent
|
||||||
self.children: list[CategoryTreeItem] = []
|
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:
|
def append_child(self, child: CategoryTreeItem) -> None:
|
||||||
self.children.append(child)
|
self.children.append(child)
|
||||||
@@ -270,56 +296,59 @@ class CategoryTreeItem:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
CATEGORY_MIME_TYPE = "application/x-illogical-category"
|
||||||
|
|
||||||
|
|
||||||
class CategoryTreeModel(QAbstractItemModel):
|
class CategoryTreeModel(QAbstractItemModel):
|
||||||
|
category_changed = Signal()
|
||||||
|
error_occurred = Signal(str, str)
|
||||||
|
backup_requested = Signal(bool)
|
||||||
|
|
||||||
def __init__(self, parent: QObject | None = None) -> None:
|
def __init__(self, parent: QObject | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._root = CategoryTreeItem("", "")
|
self._root = CategoryTreeItem("", "")
|
||||||
|
self._virtual_tree: VirtualCategoryTree | None = None
|
||||||
|
self._logic: Logic | None = None
|
||||||
|
|
||||||
def build_from_plugins(self, logic: Logic) -> None:
|
def build_from_plugins(self, logic: Logic) -> None:
|
||||||
self.beginResetModel()
|
self.beginResetModel()
|
||||||
self._root = CategoryTreeItem("", "")
|
self._logic = logic
|
||||||
|
self._virtual_tree = VirtualCategoryTree()
|
||||||
categories: set[str] = set()
|
self._virtual_tree.build_from_logic(logic)
|
||||||
for plugin in logic.plugins.all():
|
self._root = self._build_qt_tree_from_virtual()
|
||||||
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.endResetModel()
|
self.endResetModel()
|
||||||
|
|
||||||
def _sort_category_tree(self, item: CategoryTreeItem, logic: Logic) -> None:
|
def _build_qt_tree_from_virtual(self) -> CategoryTreeItem:
|
||||||
def get_sort_key(path: str) -> tuple[int, str]:
|
from illogical.modules.virtual_category import ( # noqa: PLC0415
|
||||||
if path in logic.categories:
|
VirtualCategoryNode,
|
||||||
return (logic.categories[path].index, path.lower())
|
)
|
||||||
return (2**31 - 1, path.lower())
|
|
||||||
|
|
||||||
top_level = [c for c in item.children if c.full_path == "Top Level"]
|
if self._virtual_tree is None:
|
||||||
others = [c for c in item.children if c.full_path != "Top Level"]
|
return CategoryTreeItem("", "")
|
||||||
others.sort(key=lambda c: get_sort_key(c.full_path))
|
|
||||||
item.children = top_level + others
|
root = CategoryTreeItem("", "")
|
||||||
for child in item.children:
|
|
||||||
self._sort_category_tree(child, logic)
|
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(
|
def index(
|
||||||
self, row: int, column: int, parent: QModelIndex | None = None
|
self, row: int, column: int, parent: QModelIndex | None = None
|
||||||
@@ -396,6 +425,223 @@ class CategoryTreeModel(QAbstractItemModel):
|
|||||||
|
|
||||||
return find_in_item(self._root, QModelIndex())
|
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):
|
class ManufacturerListModel(QAbstractListModel):
|
||||||
def __init__(self, parent: QObject | None = None) -> None:
|
def __init__(self, parent: QObject | None = None) -> None:
|
||||||
|
|||||||
474
src/illogical/modules/virtual_category.py
Normal file
474
src/illogical/modules/virtual_category.py
Normal 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
|
||||||
@@ -87,6 +87,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._sidebar.category_selected.connect(self._on_category_selected)
|
self._sidebar.category_selected.connect(self._on_category_selected)
|
||||||
self._sidebar.manufacturer_selected.connect(self._on_manufacturer_selected)
|
self._sidebar.manufacturer_selected.connect(self._on_manufacturer_selected)
|
||||||
self._sidebar.enter_pressed.connect(self._plugin_table.focus_table)
|
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.search_changed.connect(self._on_search_changed)
|
||||||
self._plugin_table.plugin_selected.connect(self._on_plugin_selected)
|
self._plugin_table.plugin_selected.connect(self._on_plugin_selected)
|
||||||
self._plugin_table.edit_requested.connect(self._on_plugin_edit_requested)
|
self._plugin_table.edit_requested.connect(self._on_plugin_edit_requested)
|
||||||
@@ -194,6 +195,12 @@ class MainWindow(QMainWindow):
|
|||||||
except OSError as e:
|
except OSError as e:
|
||||||
QMessageBox.warning(self, "Edit Failed", f"Failed to save changes: {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:
|
def _on_search_results(self, results: list[SearchResult]) -> None:
|
||||||
plugins = [r.plugin for r in results]
|
plugins = [r.plugin for r in results]
|
||||||
self._plugin_table.filter_by_search_results(
|
self._plugin_table.filter_by_search_results(
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ from PySide6.QtWidgets import (
|
|||||||
from illogical.modules.backup_models import (
|
from illogical.modules.backup_models import (
|
||||||
BackupInfo,
|
BackupInfo,
|
||||||
BackupTrigger,
|
BackupTrigger,
|
||||||
|
CategoryChange,
|
||||||
|
CategoryChangeType,
|
||||||
ChangeType,
|
ChangeType,
|
||||||
DetailedBackupChanges,
|
DetailedBackupChanges,
|
||||||
FieldChange,
|
FieldChange,
|
||||||
@@ -167,6 +169,16 @@ class RestoreBackupWindow(QDialog):
|
|||||||
self._changes_tree.addTopLevelItem(item)
|
self._changes_tree.addTopLevelItem(item)
|
||||||
return
|
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.added, "Plugins to remove", "minus.circle")
|
||||||
self._add_plugin_group(
|
self._add_plugin_group(
|
||||||
changes.modified, "Plugins to revert", "arrow.uturn.backward.circle"
|
changes.modified, "Plugins to revert", "arrow.uturn.backward.circle"
|
||||||
@@ -195,6 +207,31 @@ class RestoreBackupWindow(QDialog):
|
|||||||
self._changes_tree.addTopLevelItem(group_item)
|
self._changes_tree.addTopLevelItem(group_item)
|
||||||
group_item.setExpanded(True)
|
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:
|
def _format_field_change(self, change: FieldChange) -> str:
|
||||||
if change.field_name.startswith("category:"):
|
if change.field_name.startswith("category:"):
|
||||||
category = change.field_name.split(":", 1)[1]
|
category = change.field_name.split(":", 1)[1]
|
||||||
|
|||||||
@@ -3,14 +3,15 @@ from __future__ import annotations
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from AppKit import NSColor # type: ignore[attr-defined]
|
from AppKit import NSColor # type: ignore[attr-defined]
|
||||||
from PySide6.QtCore import QModelIndex, QRect, Qt, Signal
|
from PySide6.QtCore import QModelIndex, QPoint, QRect, Qt, QTimer, Signal
|
||||||
from PySide6.QtGui import QFont
|
from PySide6.QtGui import QCursor, QFont
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QAbstractItemView,
|
QAbstractItemView,
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
QListView,
|
QListView,
|
||||||
|
QMenu,
|
||||||
QSplitter,
|
QSplitter,
|
||||||
QStyle,
|
QStyle,
|
||||||
QStyledItemDelegate,
|
QStyledItemDelegate,
|
||||||
@@ -21,6 +22,7 @@ from PySide6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from illogical.modules.models import (
|
from illogical.modules.models import (
|
||||||
|
CategoryTreeItem,
|
||||||
CategoryTreeModel,
|
CategoryTreeModel,
|
||||||
ManufacturerFilterProxy,
|
ManufacturerFilterProxy,
|
||||||
ManufacturerListModel,
|
ManufacturerListModel,
|
||||||
@@ -42,15 +44,109 @@ KVK_L = 0x25
|
|||||||
class _VimTreeView(QTreeView):
|
class _VimTreeView(QTreeView):
|
||||||
enter_pressed = Signal()
|
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:
|
def _select_and_activate(self, index: QModelIndex) -> None:
|
||||||
self.selectionModel().setCurrentIndex(
|
self.selectionModel().setCurrentIndex(
|
||||||
index, self.selectionModel().SelectionFlag.ClearAndSelect
|
index, self.selectionModel().SelectionFlag.ClearAndSelect
|
||||||
)
|
)
|
||||||
self.clicked.emit(index)
|
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()
|
vk = event.nativeVirtualKey()
|
||||||
key = event.key()
|
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:
|
if vk == KVK_J or key == Qt.Key.Key_Down:
|
||||||
next_idx = self.indexBelow(self.currentIndex())
|
next_idx = self.indexBelow(self.currentIndex())
|
||||||
@@ -78,6 +174,109 @@ class _VimTreeView(QTreeView):
|
|||||||
return
|
return
|
||||||
super().keyPressEvent(event)
|
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):
|
class _VimListView(QListView):
|
||||||
enter_pressed = Signal()
|
enter_pressed = Signal()
|
||||||
@@ -117,9 +316,23 @@ class _CategoryDelegate(QStyledItemDelegate):
|
|||||||
def paint(
|
def paint(
|
||||||
self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex
|
self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex
|
||||||
) -> None:
|
) -> None:
|
||||||
|
from PySide6.QtGui import QBrush, QColor, QPainterPath # noqa: PLC0415
|
||||||
|
|
||||||
full_path = index.data(Qt.ItemDataRole.UserRole)
|
full_path = index.data(Qt.ItemDataRole.UserRole)
|
||||||
icon = index.data(Qt.ItemDataRole.DecorationRole)
|
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():
|
if full_path == "Top Level" and icon and not icon.isNull():
|
||||||
opt = QStyleOptionViewItem(option)
|
opt = QStyleOptionViewItem(option)
|
||||||
self.initStyleOption(opt, index)
|
self.initStyleOption(opt, index)
|
||||||
@@ -252,6 +465,7 @@ class Sidebar(QWidget):
|
|||||||
category_selected = Signal(object)
|
category_selected = Signal(object)
|
||||||
manufacturer_selected = Signal(str)
|
manufacturer_selected = Signal(str)
|
||||||
enter_pressed = Signal()
|
enter_pressed = Signal()
|
||||||
|
backup_requested = Signal(bool)
|
||||||
|
|
||||||
def __init__(self, parent: QWidget | None = None) -> None:
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -320,7 +534,9 @@ class Sidebar(QWidget):
|
|||||||
tree_layout.setSpacing(0)
|
tree_layout.setSpacing(0)
|
||||||
|
|
||||||
self._category_model = CategoryTreeModel()
|
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.setItemDelegate(_CategoryDelegate(self._category_tree))
|
||||||
self._category_tree.setModel(self._category_model)
|
self._category_tree.setModel(self._category_model)
|
||||||
self._category_tree.setHeaderHidden(True)
|
self._category_tree.setHeaderHidden(True)
|
||||||
@@ -394,6 +610,25 @@ class Sidebar(QWidget):
|
|||||||
self._manufacturer_list.clearSelection()
|
self._manufacturer_list.clearSelection()
|
||||||
self._uncategorized.set_selected(False)
|
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:
|
def _on_show_all_clicked(self) -> None:
|
||||||
self._active_category = "Show All"
|
self._active_category = "Show All"
|
||||||
self._active_manufacturer = None
|
self._active_manufacturer = None
|
||||||
|
|||||||
Reference in New Issue
Block a user