From 2d92dd306077c2a8a2268a835758d097a82bad45 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Wed, 12 Mar 2025 23:44:09 -0700 Subject: [PATCH] feat: dynamically load macros into menu --- src/tagstudio/core/macro_parser.py | 47 ++++++++++++--- src/tagstudio/qt/qt_driver.py | 87 +++++++++++++++++++++------ src/tagstudio/qt/utils/file_opener.py | 10 ++- tests/qt/test_file_path_options.py | 1 + 4 files changed, 114 insertions(+), 31 deletions(-) diff --git a/src/tagstudio/core/macro_parser.py b/src/tagstudio/core/macro_parser.py index d19dfe05..13969ab8 100644 --- a/src/tagstudio/core/macro_parser.py +++ b/src/tagstudio/core/macro_parser.py @@ -106,6 +106,37 @@ class AddTagInstruction(Instruction): return str(self.tag_strings) +def get_macro_name( + macro_path: Path, +) -> str: + """Return the name of a macro, as read from the file. + + Defaults to the filename if no name is declared or able to be read. + + Args: + macro_path (Path): The full path of the macro file. + """ + name = macro_path.name + logger.info("[MacroParser] Parsing Macro for Name", macro_path=macro_path) + + if not macro_path.exists(): + logger.error("[MacroParser] Macro path does not exist", macro_path=macro_path) + return name + + if not macro_path.exists(): + logger.error("[MacroParser] Filepath does not exist", macro_path=macro_path) + return name + + with open(macro_path) as f: + try: + macro = toml.load(f) + name = str(macro.get("name", name)) + except toml.TomlDecodeError as e: + logger.error("[MacroParser] Could not parse macro", macro_path=macro_path, error=e) + logger.info("[MacroParser] Macro Name:", name=name, macro_path=macro_path) + return name + + def parse_macro_file( macro_path: Path, filepath: Path, @@ -123,7 +154,7 @@ def parse_macro_file( logger.error("[MacroParser] Macro path does not exist", macro_path=macro_path) return results - if not macro_path.exists(): + if not filepath.exists(): logger.error("[MacroParser] Filepath does not exist", filepath=filepath) return results @@ -131,11 +162,7 @@ def parse_macro_file( try: macro = toml.load(f) except toml.TomlDecodeError as e: - logger.error( - "[MacroParser] Could not parse macro", - path=macro_path, - error=e, - ) + logger.error("[MacroParser] Could not parse macro", macro_path=macro_path, error=e) return results logger.info(macro) @@ -156,15 +183,15 @@ def parse_macro_file( logger.info(f"[MacroParser] Schema Version: {schema_ver}") # Load Triggers - triggers = macro[TRIGGERS] - if not isinstance(triggers, list): + triggers = macro.get(TRIGGERS) + if triggers and not isinstance(triggers, list): logger.error( f"[MacroParser] Incorrect type for {TRIGGERS}, expected list", triggers=triggers ) # Parse each action table for table_key in macro: - if table_key in {SCHEMA_VERSION, TRIGGERS}: + if table_key in {SCHEMA_VERSION, TRIGGERS, NAME}: continue logger.info("[MacroParser] Parsing Table", table_key=table_key) @@ -175,7 +202,7 @@ def parse_macro_file( source_filters: list[str] = table.get(SOURCE_FILER, []) conditions_met: bool = False if not source_filters: - logger.info('[MacroParser] No "{SOURCE_FILER}" provided') + conditions_met = True else: for filter_ in source_filters: if glob.globmatch(filepath, filter_, flags=glob.GLOBSTAR): diff --git a/src/tagstudio/qt/qt_driver.py b/src/tagstudio/qt/qt_driver.py index 26461763..bb0fe4de 100644 --- a/src/tagstudio/qt/qt_driver.py +++ b/src/tagstudio/qt/qt_driver.py @@ -20,13 +20,16 @@ from collections import OrderedDict from functools import partial from pathlib import Path from queue import Queue -from typing import TypeVar +from shutil import which +from typing import Generic, TypeVar +from unittest.mock import Mock from warnings import catch_warnings import structlog from humanfriendly import format_size, format_timespan # pyright: ignore[reportUnknownVariableType] from PySide6.QtCore import QObject, QSettings, Qt, QThread, QThreadPool, QTimer, Signal from PySide6.QtGui import ( + QAction, QColor, QDragEnterEvent, QDragMoveEvent, @@ -38,6 +41,11 @@ from PySide6.QtGui import ( QPalette, ) from PySide6.QtWidgets import QApplication, QFileDialog, QMessageBox, QPushButton, QScrollArea +from tagstudio.qt.controllers.ffmpeg_missing_message_box import FfmpegMissingMessageBox +from tagstudio.qt.controllers.fix_ignored_modal_controller import FixIgnoredEntriesModal +from tagstudio.qt.controllers.ignore_modal_controller import IgnoreModal +from tagstudio.qt.controllers.library_info_window_controller import LibraryInfoWindow +from tagstudio.qt.global_settings import DEFAULT_GLOBAL_SETTINGS_PATH, GlobalSettings, Theme import tagstudio.qt.resources_rc # noqa: F401 # pyright: ignore[reportUnusedImport] from tagstudio.core.constants import ( @@ -61,6 +69,7 @@ from tagstudio.core.macro_parser import ( Instruction, TagResult, exec_instructions, + get_macro_name, parse_macro_file, ) from tagstudio.core.media_types import MediaCategories @@ -98,6 +107,7 @@ from tagstudio.qt.mixed.tag_color_manager import TagColorManager from tagstudio.qt.resource_manager import ResourceManager from tagstudio.qt.utils.custom_runnable import CustomRunnable from tagstudio.qt.utils.file_deleter import delete_file +from tagstudio.qt.utils.file_opener import open_file from tagstudio.qt.utils.function_iterator import FunctionIterator from tagstudio.qt.views.search_panel_view import SearchPanelView from tagstudio.qt.views.styles.palette import ColorType, Palette, UiColor, get_ui_color @@ -611,20 +621,8 @@ class QtDriver(DriverMixin, QObject): # endregion # region Macros Menu ========================================================== - - self.main_window.menu_bar.test_macro_1_action.triggered.connect( - lambda: ( - self.run_macros(self.main_window.menu_bar.test_macro_1, self.selected), - # self.main_window.preview_panel.update_widgets(update_preview=False), - ) - ) - - self.main_window.menu_bar.test_macro_2_action.triggered.connect( - lambda: ( - self.run_macros(self.main_window.menu_bar.test_macro_2, self.selected), - # self.preview_panel.update_widgets(update_preview=False), - ) - ) + self.main_window.menu_bar.macros_menu.aboutToShow.connect(self.update_macros_menu) + self.update_macros_menu() def create_folders_tags_modal(): if not hasattr(self, "folders_modal"): @@ -647,8 +645,6 @@ class QtDriver(DriverMixin, QObject): # endregion - # endregion - self.main_window.search_field.textChanged.connect(self.update_completions_list) def on_visible_changed(entry_id: int | None): @@ -857,6 +853,7 @@ class QtDriver(DriverMixin, QObject): self.set_clipboard_menu_viability() self.set_select_actions_visibility() + self.update_macros_menu(clear=True) if hasattr(self, "library_info_window"): self.library_info_window.close() @@ -1198,6 +1195,7 @@ class QtDriver(DriverMixin, QObject): """Run a specific Macro on a group of given entry_ids.""" for entry_id in entry_ids: self.run_macro(macro_name, entry_id) + self.main_window.preview_panel.update_preview() def run_macro(self, macro_name: str, entry_id: int): """Run a specific Macro on an Entry given a Macro name.""" @@ -1360,8 +1358,11 @@ class QtDriver(DriverMixin, QObject): self.main_window.preview_panel.set_selection(self.selected) + # TODO: Remove? def set_macro_menu_viability(self): - self.main_window.menu_bar.test_macro_1_action.setDisabled(not self.selected) + # for action in self.macros_menu.actions(): + # action.setDisabled(not self.selected) + pass def set_clipboard_menu_viability(self): if len(self.selected) == 1: @@ -1648,6 +1649,53 @@ class QtDriver(DriverMixin, QObject): self.cached_values.sync() self.update_recent_lib_menu() + def update_macros_menu(self, clear: bool = False): + if not self.main_window.menu_bar.macros_menu or isinstance( + self.main_window.menu_bar.macros_menu, Mock + ): # NOTE: Needed for tests? + return + + # Create actions for each macro + actions: list[QAction] = [] + if self.lib.library_dir and not clear: + macros_path = self.lib.library_dir / TS_FOLDER_NAME / MACROS_FOLDER_NAME + for f in macros_path.glob("*"): + logger.info(f) + if f.suffix != ".toml" or f.is_dir() or f.name.startswith("._"): + continue + action = QAction( + get_macro_name(f), self.main_window.menu_bar.macros_menu.parentWidget() + ) + action.triggered.connect( + lambda checked=False, name=f.name: self.run_macros(name, self.selected), + ) + actions.append(action) + + open_folder = QAction("Open Macros Folder...", self.main_window.menu_bar.macros_menu) + open_folder.triggered.connect(self.open_macros_folder) + actions.append(open_folder) + + if clear: + open_folder.setEnabled(False) + + # Clear previous actions + for action in self.main_window.menu_bar.macros_menu.actions(): + self.main_window.menu_bar.macros_menu.removeAction(action) + + # Add new actions + for action in actions: + self.main_window.menu_bar.macros_menu.addAction(action) + + self.main_window.menu_bar.macros_menu.addSeparator() + self.main_window.menu_bar.macros_menu.addAction(open_folder) + + def open_macros_folder(self): + if not self.lib.library_dir: + return + path = self.lib.library_dir / TS_FOLDER_NAME / MACROS_FOLDER_NAME + path.mkdir(exist_ok=True) + open_file(path, file_manager=True, is_dir=True) + def open_settings_modal(self): SettingsPanel.build_modal(self).show() @@ -1746,6 +1794,7 @@ class QtDriver(DriverMixin, QObject): library_dir_display = self.lib.library_dir.name self.update_libs_list(path) + self.update_macros_menu() self.main_window.setWindowTitle( Translations.format( "app.title", @@ -1773,7 +1822,7 @@ class QtDriver(DriverMixin, QObject): self.main_window.menu_bar.folders_to_tags_action.setEnabled(True) self.main_window.menu_bar.library_info_action.setEnabled(True) - self.main_window.preview_panel.set_selection(self.selected) + self.main_window.preview_panel.set_selection() # page (re)rendering, extract eventually initial_state = BrowsingState( diff --git a/src/tagstudio/qt/utils/file_opener.py b/src/tagstudio/qt/utils/file_opener.py index 7d4c8286..b1dda3b6 100644 --- a/src/tagstudio/qt/utils/file_opener.py +++ b/src/tagstudio/qt/utils/file_opener.py @@ -15,13 +15,19 @@ from tagstudio.core.utils.silent_subprocess import silent_popen # pyright: igno logger = structlog.get_logger(__name__) -def open_file(path: str | Path, file_manager: bool = False, windows_start_command: bool = False): +def open_file( + path: str | Path, + file_manager: bool = False, + is_dir: bool = False, + windows_start_command: bool = False, +): """Open a file in the default application or file explorer. Args: path (str): The path to the file to open. file_manager (bool, optional): Whether to open the file in the file manager (e.g. Finder on macOS). Defaults to False. + is_dir (bool): True if the path points towards a directory, false if a file. windows_start_command (bool): Flag to determine if the older 'start' command should be used on Windows for opening files. This fixes issues on some systems in niche cases. """ @@ -72,7 +78,7 @@ def open_file(path: str | Path, file_manager: bool = False, windows_start_comman if sys.platform == "darwin": command_name = "open" command_args = [str(path)] - if file_manager: + if file_manager and not is_dir: # will reveal in Finder command_args.append("-R") else: diff --git a/tests/qt/test_file_path_options.py b/tests/qt/test_file_path_options.py index a5ab0edb..28ba8019 100644 --- a/tests/qt/test_file_path_options.py +++ b/tests/qt/test_file_path_options.py @@ -142,6 +142,7 @@ def test_title_update( qt_driver.main_window.menu_bar.fix_dupe_files_action = QAction(menu_bar) qt_driver.main_window.menu_bar.clear_thumb_cache_action = QAction(menu_bar) qt_driver.main_window.menu_bar.folders_to_tags_action = QAction(menu_bar) + qt_driver.main_window.menu_bar.macros_menu = None # Trigger the update qt_driver._init_library(library_dir, open_status)