From f770614c4e842b7095256e77b53a5a8381634512 Mon Sep 17 00:00:00 2001 From: JCC1998 <57444325+JCC1998@users.noreply.github.com> Date: Sun, 4 May 2025 19:46:26 +0200 Subject: [PATCH 01/11] feat: add date_format and hour_format settings (#904) * feat: add date_format and hour_format settings * fix: fix ruff validation errors * fix: use ruff format command * fix: Refactor code and improve some logic * fix: remove unused import * Added zero padding setting and implement some comments * Add test assert property * fix: Unclutter selector and clarify zero-padding literal * fix: Use static strings Co-authored-by: Tony <1414927+zfbx@users.noreply.github.com> --------- Co-authored-by: Tony <1414927+zfbx@users.noreply.github.com> --- src/tagstudio/core/global_settings.py | 3 ++ src/tagstudio/qt/modals/settings_panel.py | 46 +++++++++++++++++++ .../qt/widgets/preview/file_attributes.py | 22 ++++++++- src/tagstudio/resources/translations/en.json | 6 +++ src/tagstudio/resources/translations/es.json | 6 +++ tests/qt/test_global_settings.py | 6 +++ 6 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/tagstudio/core/global_settings.py b/src/tagstudio/core/global_settings.py index 70b4144c..a2e1b1c2 100644 --- a/src/tagstudio/core/global_settings.py +++ b/src/tagstudio/core/global_settings.py @@ -49,6 +49,9 @@ class GlobalSettings(BaseModel): page_size: int = Field(default=100) show_filepath: ShowFilepathOption = Field(default=ShowFilepathOption.DEFAULT) theme: Theme = Field(default=Theme.SYSTEM) + date_format: str = Field(default="%x") + hour_format: bool = Field(default=True) + zero_padding: bool = Field(default=True) @staticmethod def read_settings(path: Path = DEFAULT_GLOBAL_SETTINGS_PATH) -> "GlobalSettings": diff --git a/src/tagstudio/qt/modals/settings_panel.py b/src/tagstudio/qt/modals/settings_panel.py index 905c65d8..e7ec82c8 100644 --- a/src/tagstudio/qt/modals/settings_panel.py +++ b/src/tagstudio/qt/modals/settings_panel.py @@ -3,6 +3,7 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio +from datetime import datetime as dt from typing import TYPE_CHECKING from PySide6.QtCore import Qt @@ -37,6 +38,24 @@ THEME_MAP: dict[Theme, str] = { Theme.SYSTEM: Translations["settings.theme.system"], } +DATE_FORMAT_MAP: dict[str, str] = { + "%d/%m/%y": "21/08/24", + "%d/%m/%Y": "21/08/2024", + "%d.%m.%y": "21.08.24", + "%d.%m.%Y": "21.08.2024", + "%d-%m-%y": "21-08-24", + "%d-%m-%Y": "21-08-2024", + "%x": "08/21/24", + "%m/%d/%Y": "08/21/2024", + "%m-%d-%y": "08-21-24", + "%m-%d-%Y": "08-21-2024", + "%m.%d.%y": "08.21.24", + "%m.%d.%Y": "08.21.2024", + "%Y/%m/%d": "2024/08/21", + "%Y-%m-%d": "2024-08-21", + "%Y.%m.%d": "2024.08.21", +} + class SettingsPanel(PanelWidget): driver: "QtDriver" @@ -147,6 +166,27 @@ class SettingsPanel(PanelWidget): self.theme_combobox.currentIndexChanged.connect(self.__update_restart_label) form_layout.addRow(Translations["settings.theme.label"], self.theme_combobox) + # Date Format + self.dateformat_combobox = QComboBox() + for k in DATE_FORMAT_MAP: + self.dateformat_combobox.addItem(DATE_FORMAT_MAP[k], k) + dateformat: str = self.driver.settings.date_format + if dateformat not in DATE_FORMAT_MAP: + dateformat = "%x" + self.dateformat_combobox.setCurrentIndex(list(DATE_FORMAT_MAP.keys()).index(dateformat)) + self.dateformat_combobox.currentIndexChanged.connect(self.__update_restart_label) + form_layout.addRow(Translations["settings.dateformat.label"], self.dateformat_combobox) + + # 24-Hour Format + self.hourformat_checkbox = QCheckBox() + self.hourformat_checkbox.setChecked(self.driver.settings.hour_format) + form_layout.addRow(Translations["settings.hourformat.label"], self.hourformat_checkbox) + + # Zero-padding + self.zeropadding_checkbox = QCheckBox() + self.zeropadding_checkbox.setChecked(self.driver.settings.zero_padding) + form_layout.addRow(Translations["settings.zeropadding.label"], self.zeropadding_checkbox) + def __build_library_settings(self): self.library_settings_container = QWidget() form_layout = QFormLayout(self.library_settings_container) @@ -167,6 +207,9 @@ class SettingsPanel(PanelWidget): "page_size": int(self.page_size_line_edit.text()), "show_filepath": self.filepath_combobox.currentData(), "theme": self.theme_combobox.currentData(), + "date_format": self.dateformat_combobox.currentData(), + "hour_format": self.hourformat_checkbox.isChecked(), + "zero_padding": self.zeropadding_checkbox.isChecked(), } def update_settings(self, driver: "QtDriver"): @@ -179,6 +222,9 @@ class SettingsPanel(PanelWidget): driver.settings.page_size = settings["page_size"] driver.settings.show_filepath = settings["show_filepath"] driver.settings.theme = settings["theme"] + driver.settings.date_format = settings["date_format"] + driver.settings.hour_format = settings["hour_format"] + driver.settings.zero_padding = settings["zero_padding"] driver.settings.save() diff --git a/src/tagstudio/qt/widgets/preview/file_attributes.py b/src/tagstudio/qt/widgets/preview/file_attributes.py index d894af18..49098d5f 100644 --- a/src/tagstudio/qt/widgets/preview/file_attributes.py +++ b/src/tagstudio/qt/widgets/preview/file_attributes.py @@ -109,11 +109,11 @@ class FileAttributes(QWidget): created = dt.fromtimestamp(filepath.stat().st_ctime) modified: dt = dt.fromtimestamp(filepath.stat().st_mtime) self.date_created_label.setText( - f"{Translations['file.date_created']}: {dt.strftime(created, '%a, %x, %X')}" + f"{Translations['file.date_created']}: {self.get_date_with_format(created)}" ) self.date_modified_label.setText( f"{Translations['file.date_modified']}: " - f"{dt.strftime(modified, '%a, %x, %X')}" + f"{self.get_date_with_format(modified)}" ) self.date_created_label.setHidden(False) self.date_modified_label.setHidden(False) @@ -246,3 +246,21 @@ class FileAttributes(QWidget): self.file_label.set_file_path("") self.dimensions_label.setText("") self.dimensions_label.setHidden(True) + + def get_date_with_format(self, date: dt) -> str: + date_format = self.driver.settings.date_format + is_24h = self.driver.settings.hour_format + hour_format = "%H:%M:%S" if is_24h else "%I:%M:%S %p" + zero_padding = self.driver.settings.zero_padding + zero_padding_symbol = "" + + if not zero_padding: + zero_padding_symbol = "#" if platform.system() == "Windows" else "-" + date_format = date_format.replace("%d", f"%{zero_padding_symbol}d").replace( + "%m", f"%{zero_padding_symbol}m" + ) + hour_format = hour_format.replace("%H", f"%{zero_padding_symbol}H").replace( + "%I", f"%{zero_padding_symbol}I" + ) + + return dt.strftime(date, f"{date_format}, {hour_format}") diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index 2d0b029b..281f69c7 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -247,6 +247,12 @@ "settings.theme.label": "Theme:", "settings.theme.light": "Light", "settings.theme.system": "System", + "settings.dateformat.label": "Date Format", + "settings.dateformat.system": "System", + "settings.dateformat.english": "English", + "settings.dateformat.international": "International", + "settings.hourformat.label": "24-Hour Time", + "settings.zeropadding.label": "Date Zero-Padding", "settings.title": "Settings", "sorting.direction.ascending": "Ascending", "sorting.direction.descending": "Descending", diff --git a/src/tagstudio/resources/translations/es.json b/src/tagstudio/resources/translations/es.json index 7eccc3c0..aa1fc05e 100644 --- a/src/tagstudio/resources/translations/es.json +++ b/src/tagstudio/resources/translations/es.json @@ -236,6 +236,12 @@ "settings.restart_required": "Por favor, reinicia TagStudio para que se los cambios surtan efecto.", "settings.show_filenames_in_grid": "Mostrar el nombre de archivo en la cuadrícula", "settings.show_recent_libraries": "Mostrar bibliotecas recientes", + "settings.dateformat.label": "Formato fecha", + "settings.dateformat.system": "Sistema", + "settings.dateformat.english": "Inglés", + "settings.dateformat.international": "Internacional", + "settings.hourformat.label": "Formato 24-horas", + "settings.zeropadding.label": "Rellenar ceros en fechas", "settings.title": "Ajustes", "sorting.direction.ascending": "Ascendiente", "sorting.direction.descending": "Descendiente", diff --git a/tests/qt/test_global_settings.py b/tests/qt/test_global_settings.py index 21953e96..25d78086 100644 --- a/tests/qt/test_global_settings.py +++ b/tests/qt/test_global_settings.py @@ -16,6 +16,9 @@ def test_read_settings(): page_size = 1337 show_filepath = 0 dark_mode = 2 + date_format = "%x" + hour_format = true + zero_padding = true """) settings = GlobalSettings.read_settings(settings_path) @@ -26,3 +29,6 @@ def test_read_settings(): assert settings.page_size == 1337 assert settings.show_filepath == 0 assert settings.theme == Theme.SYSTEM + assert settings.date_format == "%x" + assert settings.hour_format + assert settings.zero_padding From 525b382803eb45e381a7a174b798c8684df60540 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sun, 4 May 2025 10:50:57 -0700 Subject: [PATCH 02/11] chore: remove unused import --- src/tagstudio/qt/modals/settings_panel.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/tagstudio/qt/modals/settings_panel.py b/src/tagstudio/qt/modals/settings_panel.py index e7ec82c8..820be028 100644 --- a/src/tagstudio/qt/modals/settings_panel.py +++ b/src/tagstudio/qt/modals/settings_panel.py @@ -3,7 +3,6 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -from datetime import datetime as dt from typing import TYPE_CHECKING from PySide6.QtCore import Qt From 98c4b1c359e95969ca5c04f1a6ffae11dba4d77d Mon Sep 17 00:00:00 2001 From: Jann Stute <46534683+Computerdores@users.noreply.github.com> Date: Sun, 4 May 2025 20:13:58 +0200 Subject: [PATCH 03/11] feat(ui): temporary modal to edit datetime fields and small refactors (#921) * refactor: simplify datetime widget creation * refactor: fix warnings * feat: text modal to enter date * chore: change ignore comment to only apply to pyright not mypy --- .../qt/widgets/preview/field_containers.py | 66 ++++++++++++------- 1 file changed, 44 insertions(+), 22 deletions(-) diff --git a/src/tagstudio/qt/widgets/preview/field_containers.py b/src/tagstudio/qt/widgets/preview/field_containers.py index 76dc4860..b29fe580 100644 --- a/src/tagstudio/qt/widgets/preview/field_containers.py +++ b/src/tagstudio/qt/widgets/preview/field_containers.py @@ -109,8 +109,9 @@ class FieldContainers(QWidget): """Update tags and fields from a single Entry source.""" logger.warning("[FieldContainers] Updating Selection", entry_id=entry_id) - self.cached_entries = [self.lib.get_entry_full(entry_id)] - entry = self.cached_entries[0] + entry = self.lib.get_entry_full(entry_id) + assert entry is not None + self.cached_entries = [entry] self.update_granular(entry.tags, entry.fields, update_badges) def update_granular( @@ -177,6 +178,7 @@ class FieldContainers(QWidget): """ tag_obj = self.lib.get_tag(tag_id) # Get full object if p_ids is None: + assert tag_obj is not None p_ids = tag_obj.parent_ids for p_id in p_ids: @@ -186,6 +188,7 @@ class FieldContainers(QWidget): if tag_id not in cluster_map[p_id]: cluster_map[p_id].add(tag_id) p_tag = self.lib.get_tag(p_id) # Get full object + assert p_tag is not None if p_tag.parent_ids: add_to_cluster( tag_id, @@ -201,7 +204,7 @@ class FieldContainers(QWidget): logger.info("[FieldContainers] Cluster Map", cluster_map=cluster_map) # Initialize all categories from parents. - tags_ = {self.lib.get_tag(x) for x in exhausted} + tags_ = {t for tid in exhausted if (t := self.lib.get_tag(tid)) is not None} for tag in tags_: if tag.is_category: cats[tag] = set() @@ -218,20 +221,28 @@ class FieldContainers(QWidget): ) if final_tags := cluster_map.get(key.id, set()).union([key.id]): - cats[key] = {self.lib.get_tag(x) for x in final_tags if x in base_tag_ids} - added_ids = added_ids.union({x for x in final_tags if x in base_tag_ids}) + cats[key] = { + t + for tid in final_tags + if tid in base_tag_ids and (t := self.lib.get_tag(tid)) is not None + } + added_ids = added_ids.union({tid for tid in final_tags if tid in base_tag_ids}) # Add remaining tags to None key (general case). - cats[None] = {self.lib.get_tag(x) for x in base_tag_ids if x not in added_ids} + cats[None] = { + t + for tid in base_tag_ids + if tid not in added_ids and (t := self.lib.get_tag(tid)) is not None + } logger.info( - f"[FieldContainers] [{key}] Key cluster: None, general case!", - general_tags=cats[key], + "[FieldContainers] Key cluster: None, general case!", + general_tags=cats[None], added=added_ids, base_tag_ids=base_tag_ids, ) # Remove unused categories - empty: list[Tag] = [] + empty: list[Tag | None] = [] for k, v in list(cats.items()): if not v: empty.append(k) @@ -326,7 +337,7 @@ class FieldContainers(QWidget): ) if "pytest" in sys.modules: # for better testability - container.modal = modal + container.modal = modal # pyright: ignore[reportAttributeAccessIssue] container.set_edit_callback(modal.show) container.set_remove_callback( @@ -375,23 +386,34 @@ class FieldContainers(QWidget): ) elif field.type.type == FieldTypeEnum.DATETIME: + logger.info("[FieldContainers][write_container] Datetime Field", field=field) if not is_mixed: + container.set_title(field.type.name) + container.set_inline(False) try: - container.set_title(field.type.name) - container.set_inline(False) - # TODO: Localize this and/or add preferences. - date = dt.strptime(field.value, "%Y-%m-%d %H:%M:%S") title = f"{field.type.name} (Date)" - inner_widget = TextWidget(title, date.strftime("%D - %r")) - container.set_inner_widget(inner_widget) - except Exception: - container.set_title(field.type.name) - container.set_inline(False) + # TODO: Localize this and/or add preferences. + text = dt.strptime(field.value or "", "%Y-%m-%d %H:%M:%S").strftime("%D - %r") + except ValueError: title = f"{field.type.name} (Date) (Unknown Format)" - inner_widget = TextWidget(title, str(field.value)) - container.set_inner_widget(inner_widget) + text = str(field.value) - container.set_edit_callback() + inner_widget = TextWidget(title, text) + container.set_inner_widget(inner_widget) + + modal = PanelModal( # TODO Replace with proper date picker including timezone etc. + EditTextLine(field.value), + title=f"Edit {field.type.name} in 'YYYY-MM-DD HH:MM:SS' format", + window_title=f"Edit {field.type.name}", + save_callback=( + lambda content: ( + self.update_field(field, content), # type: ignore + self.update_from_entry(self.cached_entries[0].id), + ) + ), + ) + + container.set_edit_callback(modal.show) container.set_remove_callback( lambda: self.remove_message_box( prompt=self.remove_field_prompt(field.type.name), From 659863098426f27cd428dcbb17b88ae97324832c Mon Sep 17 00:00:00 2001 From: Jann Stute <46534683+Computerdores@users.noreply.github.com> Date: Sun, 4 May 2025 20:49:52 +0200 Subject: [PATCH 04/11] feat: datetime fields settings integration (#926) * refactor: move datetime formatting to global settings * feat: datetime fields settings integration --- src/tagstudio/core/global_settings.py | 20 +++++++++++++ .../qt/widgets/preview/field_containers.py | 5 ++-- .../qt/widgets/preview/file_attributes.py | 28 ++++--------------- 3 files changed, 29 insertions(+), 24 deletions(-) diff --git a/src/tagstudio/core/global_settings.py b/src/tagstudio/core/global_settings.py index a2e1b1c2..4c95ec77 100644 --- a/src/tagstudio/core/global_settings.py +++ b/src/tagstudio/core/global_settings.py @@ -2,6 +2,7 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio import platform +from datetime import datetime from enum import Enum from pathlib import Path from typing import override @@ -49,6 +50,7 @@ class GlobalSettings(BaseModel): page_size: int = Field(default=100) show_filepath: ShowFilepathOption = Field(default=ShowFilepathOption.DEFAULT) theme: Theme = Field(default=Theme.SYSTEM) + date_format: str = Field(default="%x") hour_format: bool = Field(default=True) zero_padding: bool = Field(default=True) @@ -72,3 +74,21 @@ class GlobalSettings(BaseModel): with open(path, "w") as f: toml.dump(dict(self), f, encoder=TomlEnumEncoder()) + + def format_datetime(self, dt: datetime) -> str: + date_format = self.date_format + is_24h = self.hour_format + hour_format = "%H:%M:%S" if is_24h else "%I:%M:%S %p" + zero_padding = self.zero_padding + zero_padding_symbol = "" + + if not zero_padding: + zero_padding_symbol = "#" if platform.system() == "Windows" else "-" + date_format = date_format.replace("%d", f"%{zero_padding_symbol}d").replace( + "%m", f"%{zero_padding_symbol}m" + ) + hour_format = hour_format.replace("%H", f"%{zero_padding_symbol}H").replace( + "%I", f"%{zero_padding_symbol}I" + ) + + return datetime.strftime(dt, f"{date_format}, {hour_format}") diff --git a/src/tagstudio/qt/widgets/preview/field_containers.py b/src/tagstudio/qt/widgets/preview/field_containers.py index b29fe580..bb366808 100644 --- a/src/tagstudio/qt/widgets/preview/field_containers.py +++ b/src/tagstudio/qt/widgets/preview/field_containers.py @@ -392,8 +392,9 @@ class FieldContainers(QWidget): container.set_inline(False) try: title = f"{field.type.name} (Date)" - # TODO: Localize this and/or add preferences. - text = dt.strptime(field.value or "", "%Y-%m-%d %H:%M:%S").strftime("%D - %r") + text = self.driver.settings.format_datetime( + dt.strptime(field.value or "", "%Y-%m-%d %H:%M:%S") + ) except ValueError: title = f"{field.type.name} (Date) (Unknown Format)" text = str(field.value) diff --git a/src/tagstudio/qt/widgets/preview/file_attributes.py b/src/tagstudio/qt/widgets/preview/file_attributes.py index 49098d5f..2ca9010a 100644 --- a/src/tagstudio/qt/widgets/preview/file_attributes.py +++ b/src/tagstudio/qt/widgets/preview/file_attributes.py @@ -102,18 +102,19 @@ class FileAttributes(QWidget): def update_date_label(self, filepath: Path | None = None) -> None: """Update the "Date Created" and "Date Modified" file property labels.""" if filepath and filepath.is_file(): - created: dt = None + created: dt if platform.system() == "Windows" or platform.system() == "Darwin": created = dt.fromtimestamp(filepath.stat().st_birthtime) # type: ignore[attr-defined, unused-ignore] else: created = dt.fromtimestamp(filepath.stat().st_ctime) modified: dt = dt.fromtimestamp(filepath.stat().st_mtime) self.date_created_label.setText( - f"{Translations['file.date_created']}: {self.get_date_with_format(created)}" + f"{Translations['file.date_created']}:" + + f" {self.driver.settings.format_datetime(created)}" ) self.date_modified_label.setText( f"{Translations['file.date_modified']}: " - f"{self.get_date_with_format(modified)}" + f"{self.driver.settings.format_datetime(modified)}" ) self.date_created_label.setHidden(False) self.date_modified_label.setHidden(False) @@ -130,7 +131,7 @@ class FileAttributes(QWidget): self.date_created_label.setHidden(True) self.date_modified_label.setHidden(True) - def update_stats(self, filepath: Path | None = None, ext: str = ".", stats: dict = None): + def update_stats(self, filepath: Path | None = None, ext: str = ".", stats: dict | None = None): """Render the panel widgets with the newest data from the Library.""" if not stats: stats = {} @@ -149,6 +150,7 @@ class FileAttributes(QWidget): if self.driver.settings.show_filepath == ShowFilepathOption.SHOW_FULL_PATHS: display_path = filepath elif self.driver.settings.show_filepath == ShowFilepathOption.SHOW_RELATIVE_PATHS: + assert self.library_path is not None display_path = Path(filepath).relative_to(self.library_path) elif self.driver.settings.show_filepath == ShowFilepathOption.SHOW_FILENAMES_ONLY: display_path = Path(filepath.name) @@ -246,21 +248,3 @@ class FileAttributes(QWidget): self.file_label.set_file_path("") self.dimensions_label.setText("") self.dimensions_label.setHidden(True) - - def get_date_with_format(self, date: dt) -> str: - date_format = self.driver.settings.date_format - is_24h = self.driver.settings.hour_format - hour_format = "%H:%M:%S" if is_24h else "%I:%M:%S %p" - zero_padding = self.driver.settings.zero_padding - zero_padding_symbol = "" - - if not zero_padding: - zero_padding_symbol = "#" if platform.system() == "Windows" else "-" - date_format = date_format.replace("%d", f"%{zero_padding_symbol}d").replace( - "%m", f"%{zero_padding_symbol}m" - ) - hour_format = hour_format.replace("%H", f"%{zero_padding_symbol}H").replace( - "%I", f"%{zero_padding_symbol}I" - ) - - return dt.strftime(date, f"{date_format}, {hour_format}") From efb062034dc9d11eee91ace17107fab54194af51 Mon Sep 17 00:00:00 2001 From: VasigaranAndAngel <72515046+VasigaranAndAngel@users.noreply.github.com> Date: Mon, 5 May 2025 03:43:34 +0530 Subject: [PATCH 05/11] refactor: remove placeholder video, fix type hints in preview_thumb.py (#906) * type fixes * remove `stop_file_use()` method and release the file properly. * remove "placeholder_mp4" from resources.json --- src/tagstudio/qt/resources.json | 4 -- src/tagstudio/qt/ts_qt.py | 2 +- src/tagstudio/qt/widgets/media_player.py | 4 +- .../qt/widgets/preview/preview_thumb.py | 46 ++++++++---------- .../resources/qt/videos/placeholder.mp4 | Bin 2590 -> 0 bytes 5 files changed, 23 insertions(+), 33 deletions(-) delete mode 100644 src/tagstudio/resources/qt/videos/placeholder.mp4 diff --git a/src/tagstudio/qt/resources.json b/src/tagstudio/qt/resources.json index 216b9a2b..c2404889 100644 --- a/src/tagstudio/qt/resources.json +++ b/src/tagstudio/qt/resources.json @@ -122,9 +122,5 @@ "thumb_loading": { "path": "qt/images/thumb_loading.png", "mode": "pil" - }, - "placeholder_mp4": { - "path": "qt/videos/placeholder.mp4", - "mode": "rb" } } diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index 804f5098..a45c2eac 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -1045,7 +1045,7 @@ class QtDriver(DriverMixin, QObject): for i, tup in enumerate(pending): e_id, f = tup if (origin_path == f) or (not origin_path): - self.preview_panel.thumb.stop_file_use() + self.preview_panel.thumb.media_player.stop() if delete_file(self.lib.library_dir / f): self.main_window.statusbar.showMessage( Translations.format( diff --git a/src/tagstudio/qt/widgets/media_player.py b/src/tagstudio/qt/widgets/media_player.py index 807b6699..9d4eaf7b 100644 --- a/src/tagstudio/qt/widgets/media_player.py +++ b/src/tagstudio/qt/widgets/media_player.py @@ -416,9 +416,9 @@ class MediaPlayer(QGraphicsView): self.scene().removeItem(self.video_preview) def stop(self) -> None: - """Clear the filepath and stop the player.""" + """Clear the filepath, stop the player and release the source.""" self.filepath = None - self.player.stop() + self.player.setSource(QUrl()) def play(self, filepath: Path) -> None: """Set the source of the QMediaPlayer and play.""" diff --git a/src/tagstudio/qt/widgets/preview/preview_thumb.py b/src/tagstudio/qt/widgets/preview/preview_thumb.py index 7440da82..4b397226 100644 --- a/src/tagstudio/qt/widgets/preview/preview_thumb.py +++ b/src/tagstudio/qt/widgets/preview/preview_thumb.py @@ -4,8 +4,8 @@ import io import time -import typing from pathlib import Path +from typing import TYPE_CHECKING, override from warnings import catch_warnings import cv2 @@ -24,12 +24,11 @@ from tagstudio.qt.helpers.file_tester import is_readable_video from tagstudio.qt.helpers.qbutton_wrapper import QPushButtonWrapper from tagstudio.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle from tagstudio.qt.platform_strings import open_file_str, trash_term -from tagstudio.qt.resource_manager import ResourceManager from tagstudio.qt.translations import Translations from tagstudio.qt.widgets.media_player import MediaPlayer from tagstudio.qt.widgets.thumb_renderer import ThumbRenderer -if typing.TYPE_CHECKING: +if TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver logger = structlog.get_logger(__name__) @@ -39,7 +38,7 @@ Image.MAX_IMAGE_PIXELS = None class PreviewThumb(QWidget): """The Preview Panel Widget.""" - def __init__(self, library: Library, driver: "QtDriver"): + def __init__(self, library: Library, driver: "QtDriver") -> None: super().__init__() self.is_connected = False @@ -54,6 +53,7 @@ class PreviewThumb(QWidget): self.image_layout.setStackingMode(QStackedLayout.StackingMode.StackAll) self.image_layout.setContentsMargins(0, 0, 0, 0) + self.opener: FileOpenerHelper | None = None self.open_file_action = QAction(Translations["file.open_file"], self) self.open_explorer_action = QAction(open_file_str(), self) self.delete_action = QAction( @@ -133,17 +133,17 @@ class PreviewThumb(QWidget): def _has_video_changed(self, video: bool) -> None: self.update_image_size((self.size().width(), self.size().height())) - def _stacked_page_setup(self, page: QWidget, widget: QWidget): + def _stacked_page_setup(self, page: QWidget, widget: QWidget) -> None: layout = QHBoxLayout(page) layout.addWidget(widget) layout.setAlignment(widget, Qt.AlignmentFlag.AlignCenter) layout.setContentsMargins(0, 0, 0, 0) page.setLayout(layout) - def set_image_ratio(self, ratio: float): + def set_image_ratio(self, ratio: float) -> None: self.image_ratio = ratio - def update_image_size(self, size: tuple[int, int], ratio: float | None = None): + def update_image_size(self, size: tuple[int, int], ratio: float | None = None) -> None: if ratio: self.set_image_ratio(ratio) @@ -204,7 +204,7 @@ class PreviewThumb(QWidget): self.size().height(), ) - def switch_preview(self, preview: str): + def switch_preview(self, preview: str) -> None: if preview in ["audio", "video"]: self.media_player.show() self.image_layout.setCurrentWidget(self.media_player_page) @@ -229,7 +229,7 @@ class PreviewThumb(QWidget): self.gif_buffer.close() self.preview_gif.hide() - def _display_fallback_image(self, filepath: Path, ext: str) -> dict: + def _display_fallback_image(self, filepath: Path, ext: str) -> dict[str, int]: """Renders the given file as an image, no matter its media type. Useful for fallback scenarios. @@ -244,9 +244,9 @@ class PreviewThumb(QWidget): ) return self._update_image(filepath, ext) - def _update_image(self, filepath: Path, ext: str) -> dict: + def _update_image(self, filepath: Path, ext: str) -> dict[str, int]: """Update the static image preview from a filepath.""" - stats: dict = {} + stats: dict[str, int] = {} self.switch_preview("image") image: Image.Image | None = None @@ -287,9 +287,9 @@ class PreviewThumb(QWidget): return stats - def _update_animation(self, filepath: Path, ext: str) -> dict: + def _update_animation(self, filepath: Path, ext: str) -> dict[str, int]: """Update the animated image preview from a filepath.""" - stats: dict = {} + stats: dict[str, int] = {} # Ensure that any movie and buffer from previous animations are cleared. if self.preview_gif.movie(): @@ -351,8 +351,8 @@ class PreviewThumb(QWidget): image = Image.fromarray(frame) return (success, QSize(image.width, image.height)) - def _update_media(self, filepath: Path, type: MediaType) -> dict: - stats: dict = {} + def _update_media(self, filepath: Path, type: MediaType) -> dict[str, int]: + stats: dict[str, int] = {} self.media_player.play(filepath) @@ -380,9 +380,9 @@ class PreviewThumb(QWidget): stats["duration"] = self.media_player.player.duration() * 1000 return stats - def update_preview(self, filepath: Path, ext: str) -> dict: + def update_preview(self, filepath: Path, ext: str) -> dict[str, int]: """Render a single file preview.""" - stats: dict = {} + stats: dict[str, int] = {} # Video if MediaCategories.is_ext_in_category( @@ -448,17 +448,11 @@ class PreviewThumb(QWidget): return stats - def hide_preview(self): + def hide_preview(self) -> None: """Completely hide the file preview.""" self.switch_preview("") - def stop_file_use(self): - """Stops the use of the currently previewed file. Used to release file permissions.""" - logger.info("[PreviewThumb] Stopping file use in video playback...") - # This swaps the video out for a placeholder so the previous video's file - # is no longer in use by this object. - self.media_player.play(ResourceManager.get_path("placeholder_mp4")) - - def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 + @override + def resizeEvent(self, event: QResizeEvent) -> None: self.update_image_size((self.size().width(), self.size().height())) return super().resizeEvent(event) diff --git a/src/tagstudio/resources/qt/videos/placeholder.mp4 b/src/tagstudio/resources/qt/videos/placeholder.mp4 deleted file mode 100644 index 1e22e4c724aeefa2a44b44f3462f075495373b1e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2590 zcmdT`&1)M+6d$c+MRpZejve9mp}-u$ss+Ac30A({p#*$ z69)o0rq>>d>7mECy@gW9C6~}b4~5WMA(wpgKWHiKAw_v_b~KVVA1#Fj^0YJW^Y`BD z%)UW{5ZdBfp%VulAsRsrLuZLu^TI}r5K?SKwoS;`_nyT#=t$Z`8Ri4+^Q(Km*evE7 z6#N9HLhifi;tsg-o%<^{fY%G-r?kJ=g{R?KbVuL#edoQ?jq{Ef1#!^gbfcm#HRw6t z3@kgMo3+YfrA`p`YhnE1Pv6dbz8$YW`1;n5XSNsJKjO$V>a=6%i%ay1m|J0N=#5IX zTBTvjSYD=X;u;OwG<4ehG=$)G5E{!mb*uuCc}I#=Bht~(sC%s?ZxCakTo2bHEWM67~K6_N2 z6P62T$$ga{dvY~j*ku`47KzI5&7ukuDn{lhyrnD>RhY@5if^kJi7HMji$oOJ&- zRg6SU9#9sEntUgVDjiZW5>+~^ED}{ZmPJh+Q85xVbyQg-YU=$gs(f6U|T!}b>4R4!4(BA%&QH8W;ygU7)snT@!i3p z-SsSoflNK?#ryH`58x%_WXEzNA;FY57F>rz+5|n=V7}$r7^Ag4$Hzjmi6@fEH44Cj zmHOtbbTMc|f2kL_G?lgEF>ksc--&rF4k@*h9lVo4MjFj24Pe>;5eL$95vh4(p6mBP zt3cNE9Ngpm$1lClbLu*uF#|X9P4_C~&k<~9QVv-h0>^?l=3*|+0>o4oTMgR*uY+fV zG>F|chq2SC9gao#MQ)+I9z8GXA|vy#R#QmBwc=fd>*nCQCVj}jt{nuv?|&SycV>Xa zfN%{BV7D`UrH%BJ+_(SMSE5Qfy`rz=N=WYAf9fHbuR7X8vIkYEj~vflhd%+sHX=XE zHY)jP8)59fXdkjI+ozFfpM{Z{xQCL`I|2_EDEsX~oY=z`eGixe2Y%Os?*Ts|Y1?&P zm?Y_0oB{VeTW}D`k Date: Mon, 5 May 2025 14:47:57 -0500 Subject: [PATCH 06/11] chore(pyproject): version bumping/relaxing (#886) * chore(pyproject): version bumping/relaxing * fix(pyproject): remove imaging extra for mkdocs-material * fix: ruff formatting * fix: mypy * fix(pyproject): PySide violates SemVer * chore(pyproject): set Python version, bump pydantic * fix(ci): up Ruff to consistent version * fix(pyproject): fixup requires-python * fix: ruff checks * chore(pyproject): bump dependencies * fix(ci): up Ruff to consistent version * fix(tests): strip out non-reproducible tests --- .github/workflows/ruff.yaml | 8 +- flake.nix | 13 +-- nix/package/default.nix | 91 ++++++++---------- pyproject.toml | 57 +++++------ src/tagstudio/qt/modals/ffmpeg_checker.py | 4 +- src/tagstudio/qt/modals/tag_color_manager.py | 3 +- src/tagstudio/qt/widgets/fields.py | 3 +- src/tagstudio/qt/widgets/migration_modal.py | 2 +- src/tagstudio/qt/widgets/panel.py | 3 +- .../qt/widgets/preview/field_containers.py | 6 +- .../qt/widgets/preview/file_attributes.py | 4 +- src/tagstudio/qt/widgets/progress.py | 2 +- src/tagstudio/qt/widgets/text_line_edit.py | 2 +- src/tagstudio/qt/widgets/thumb_renderer.py | 7 +- tests/fixtures/sample.epub | Bin 4413 -> 0 bytes tests/fixtures/sample.ods | Bin 11271 -> 0 bytes tests/fixtures/sample.odt | Bin 14468 -> 0 bytes tests/fixtures/sample.pdf | Bin 5389 -> 0 bytes tests/fixtures/sample.svg | 8 -- ...review_render[sample.epub-_epub_cover].png | Bin 3721 -> 0 bytes ...iew_render[sample.ods-_open_doc_thumb].png | Bin 5956 -> 0 bytes ...iew_render[sample.odt-_open_doc_thumb].png | Bin 13127 -> 0 bytes ...review_render[sample.pdf-thumbnailer3].png | Bin 1860 -> 0 bytes ...review_render[sample.svg-thumbnailer4].png | Bin 5248 -> 0 bytes tests/qt/test_file_path_options.py | 4 +- tests/qt/test_thumb_renderer.py | 49 ---------- 26 files changed, 100 insertions(+), 166 deletions(-) delete mode 100644 tests/fixtures/sample.epub delete mode 100644 tests/fixtures/sample.ods delete mode 100644 tests/fixtures/sample.odt delete mode 100644 tests/fixtures/sample.pdf delete mode 100644 tests/fixtures/sample.svg delete mode 100644 tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.epub-_epub_cover].png delete mode 100644 tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.ods-_open_doc_thumb].png delete mode 100644 tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.odt-_open_doc_thumb].png delete mode 100644 tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.pdf-thumbnailer3].png delete mode 100644 tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.svg-thumbnailer4].png delete mode 100644 tests/qt/test_thumb_renderer.py diff --git a/.github/workflows/ruff.yaml b/.github/workflows/ruff.yaml index e3dfbf2d..2fc85b7c 100644 --- a/.github/workflows/ruff.yaml +++ b/.github/workflows/ruff.yaml @@ -12,9 +12,9 @@ jobs: uses: actions/checkout@v4 - name: Execute Ruff format - uses: chartboost/ruff-action@v1 + uses: astral-sh/ruff-action@v3 with: - version: 0.8.1 + version: 0.11.0 args: format --check ruff-check: @@ -25,7 +25,7 @@ jobs: uses: actions/checkout@v4 - name: Execute Ruff check - uses: chartboost/ruff-action@v1 + uses: astral-sh/ruff-action@v3 with: - version: 0.8.1 + version: 0.11.8 args: check diff --git a/flake.nix b/flake.nix index 62143cb4..f81c2f35 100644 --- a/flake.nix +++ b/flake.nix @@ -30,21 +30,18 @@ { packages = let - pythonPackages = pkgs.python312Packages; + python3Packages = pkgs.python312Packages; - pillow-jxl-plugin = pythonPackages.callPackage ./nix/package/pillow-jxl-plugin.nix { + pillow-jxl-plugin = python3Packages.callPackage ./nix/package/pillow-jxl-plugin.nix { inherit (pkgs) cmake; inherit pyexiv2; - inherit (pkgs) rustPlatform; }; - pyexiv2 = pythonPackages.callPackage ./nix/package/pyexiv2.nix { inherit (pkgs) exiv2; }; - vtf2img = pythonPackages.callPackage ./nix/package/vtf2img.nix { }; + pyexiv2 = python3Packages.callPackage ./nix/package/pyexiv2.nix { inherit (pkgs) exiv2; }; + vtf2img = python3Packages.callPackage ./nix/package/vtf2img.nix { }; in rec { default = tagstudio; - tagstudio = pythonPackages.callPackage ./nix/package { - inherit pillow-jxl-plugin vtf2img; - }; + tagstudio = pkgs.callPackage ./nix/package { inherit pillow-jxl-plugin vtf2img; }; tagstudio-jxl = tagstudio.override { withJXLSupport = true; }; inherit pillow-jxl-plugin pyexiv2 vtf2img; diff --git a/nix/package/default.nix b/nix/package/default.nix index 952e770e..bcc6f7e3 100644 --- a/nix/package/default.nix +++ b/nix/package/default.nix @@ -1,44 +1,22 @@ { - buildPythonApplication, - chardet, ffmpeg-headless, - ffmpeg-python, - hatchling, - humanfriendly, lib, - mutagen, - numpy, - opencv-python, - pillow, - pillow-heif, - pillow-jxl-plugin, pipewire, - pydantic, - pydub, - pyside6, - pytest-qt, - pytest-xdist, - pytestCheckHook, - pythonRelaxDepsHook, + python3Packages, qt6, - rawpy, - send2trash, - sqlalchemy, stdenv, - structlog, - syrupy, - toml, - ujson, - vtf2img, wrapGAppsHook, + pillow-jxl-plugin, + vtf2img, + withJXLSupport ? false, }: let pyproject = (lib.importTOML ../../pyproject.toml).project; in -buildPythonApplication { +python3Packages.buildPythonApplication { pname = pyproject.name; inherit (pyproject) version; pyproject = true; @@ -46,7 +24,7 @@ buildPythonApplication { src = ../../.; nativeBuildInputs = [ - pythonRelaxDepsHook + python3Packages.pythonRelaxDepsHook qt6.wrapQtAppsHook # INFO: Should be unnecessary once PR is pulled. @@ -59,7 +37,7 @@ buildPythonApplication { qt6.qtmultimedia ]; - nativeCheckInputs = [ + nativeCheckInputs = with python3Packages; [ pytest-qt pytest-xdist pytestCheckHook @@ -80,30 +58,41 @@ buildPythonApplication { lib.makeLibraryPath [ pipewire ] }"; - pythonRemoveDeps = true; + pythonRemoveDeps = lib.optional (!withJXLSupport) [ "pillow_jxl" ]; + pythonRelaxDeps = [ + "numpy" + "pillow" + "pillow-heif" + "pillow-jxl-plugin" + "structlog" + "typing-extensions" + ]; pythonImportsCheck = [ "tagstudio" ]; - build-system = [ hatchling ]; - dependencies = [ - chardet - ffmpeg-python - humanfriendly - mutagen - numpy - opencv-python - pillow - pillow-heif - pydantic - pydub - pyside6 - rawpy - send2trash - sqlalchemy - structlog - toml - ujson - vtf2img - ] ++ lib.optional withJXLSupport pillow-jxl-plugin; + build-system = with python3Packages; [ hatchling ]; + dependencies = + with python3Packages; + [ + chardet + ffmpeg-python + humanfriendly + mutagen + numpy + opencv-python + pillow + pillow-heif + pydantic + pydub + pyside6 + rawpy + send2trash + sqlalchemy + structlog + toml + ujson + vtf2img + ] + ++ lib.optional withJXLSupport pillow-jxl-plugin; disabledTests = [ # INFO: These tests require modifications to a library, which does not work diff --git a/pyproject.toml b/pyproject.toml index 34706916..22503b28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,42 +8,43 @@ description = "A User-Focused Photo & File Management System." version = "9.5.2" license = "GPL-3.0-only" readme = "README.md" +requires-python = ">=3.12,<3.13" dependencies = [ - "chardet==5.2.0", - "ffmpeg-python==0.2.0", - "humanfriendly==10.0", - "mutagen==1.47.0", - "numpy==2.1.0", - "opencv_python==4.10.0.84", - "Pillow==10.3.0", - "pillow-heif==0.16.0", - "pillow-jxl-plugin==1.3.0", - "pydub==0.25.1", - "PySide6==6.8.0.1", - "rawpy==0.22.0", - "Send2Trash==1.8.3", - "SQLAlchemy==2.0.34", - "structlog==24.4.0", - "typing_extensions>=3.10.0.0,<4.11.0", - "ujson>=5.8.0,<5.9.0", - "vtf2img==0.1.0", - "toml==0.10.2", - "pydantic==2.9.2", + "chardet~=5.2", + "ffmpeg-python~=0.2", + "humanfriendly==10.*", + "mutagen~=1.47", + "numpy~=2.2", + "opencv_python~=4.11", + "Pillow~=11.2", + "pillow-heif~=0.22", + "pillow-jxl-plugin~=1.3", + "pydantic~=2.10", + "pydub~=0.25", + "PySide6==6.8.0.*", + "rawpy~=0.24", + "Send2Trash~=1.8", + "SQLAlchemy~=2.0", + "structlog~=25.3", + "toml~=0.10", + "typing_extensions~=4.13", + "ujson~=5.10", + "vtf2img~=0.1", ] [project.optional-dependencies] dev = ["tagstudio[mkdocs,mypy,pre-commit,pyinstaller,pytest,ruff]"] -mkdocs = ["mkdocs-material[imaging]==9.*"] -mypy = ["mypy==1.11.2", "mypy-extensions==1.*", "types-ujson>=5.8.0,<5.9.0"] -pre-commit = ["pre-commit==3.7.0"] -pyinstaller = ["Pyinstaller==6.6.0"] +mkdocs = ["mkdocs-material==9.*"] +mypy = ["mypy==1.15.0", "mypy-extensions==1.*", "types-ujson~=5.10"] +pre-commit = ["pre-commit~=4.2"] +pyinstaller = ["Pyinstaller~=6.13"] pytest = [ - "pytest==8.2.0", - "pytest-cov==5.0.0", + "pytest==8.3.5", + "pytest-cov==6.1.1", "pytest-qt==4.4.0", - "syrupy==4.7.1", + "syrupy==4.9.1", ] -ruff = ["ruff==0.8.1"] +ruff = ["ruff==0.11.8"] [project.gui-scripts] tagstudio = "tagstudio.main:main" diff --git a/src/tagstudio/qt/modals/ffmpeg_checker.py b/src/tagstudio/qt/modals/ffmpeg_checker.py index c87f538a..776032b2 100644 --- a/src/tagstudio/qt/modals/ffmpeg_checker.py +++ b/src/tagstudio/qt/modals/ffmpeg_checker.py @@ -41,7 +41,7 @@ class FfmpegChecker(QMessageBox): red = get_ui_color(ColorType.PRIMARY, UiColor.RED) green = get_ui_color(ColorType.PRIMARY, UiColor.GREEN) - missing = f"{Translations["generic.missing"]}" + missing = f"{Translations['generic.missing']}" found = f"{Translations['about.module.found']}" status = Translations.format( "ffmpeg.missing.status", @@ -50,4 +50,4 @@ class FfmpegChecker(QMessageBox): ffprobe=ffprobe, ffprobe_status=found if which(FFPROBE_CMD) else missing, ) - self.setText(f"{Translations["ffmpeg.missing.description"]}

{status}") + self.setText(f"{Translations['ffmpeg.missing.description']}

{status}") diff --git a/src/tagstudio/qt/modals/tag_color_manager.py b/src/tagstudio/qt/modals/tag_color_manager.py index 3f2fe0af..8a1403dc 100644 --- a/src/tagstudio/qt/modals/tag_color_manager.py +++ b/src/tagstudio/qt/modals/tag_color_manager.py @@ -2,7 +2,8 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -from typing import TYPE_CHECKING, Callable, override +from collections.abc import Callable +from typing import TYPE_CHECKING, override import structlog from PySide6 import QtCore, QtGui diff --git a/src/tagstudio/qt/widgets/fields.py b/src/tagstudio/qt/widgets/fields.py index c787110f..d2678b55 100644 --- a/src/tagstudio/qt/widgets/fields.py +++ b/src/tagstudio/qt/widgets/fields.py @@ -4,8 +4,9 @@ import math +from collections.abc import Callable from pathlib import Path -from typing import Callable, override +from typing import override from warnings import catch_warnings import structlog diff --git a/src/tagstudio/qt/widgets/migration_modal.py b/src/tagstudio/qt/widgets/migration_modal.py index 068334fd..975c738b 100644 --- a/src/tagstudio/qt/widgets/migration_modal.py +++ b/src/tagstudio/qt/widgets/migration_modal.py @@ -616,7 +616,7 @@ class JsonMigrationModal(QObject): logger.info( "[Field Comparison]", - fields="\n".join([str(x) for x in zip(json_fields, sql_fields)]), + fields="\n".join([str(x) for x in zip(json_fields, sql_fields, strict=False)]), ) self.field_parity = True diff --git a/src/tagstudio/qt/widgets/panel.py b/src/tagstudio/qt/widgets/panel.py index cdd59b2c..db923bab 100755 --- a/src/tagstudio/qt/widgets/panel.py +++ b/src/tagstudio/qt/widgets/panel.py @@ -3,7 +3,8 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -from typing import Callable, override +from collections.abc import Callable +from typing import override import structlog from PySide6 import QtCore, QtGui diff --git a/src/tagstudio/qt/widgets/preview/field_containers.py b/src/tagstudio/qt/widgets/preview/field_containers.py index bb366808..f93b5432 100644 --- a/src/tagstudio/qt/widgets/preview/field_containers.py +++ b/src/tagstudio/qt/widgets/preview/field_containers.py @@ -315,7 +315,7 @@ class FieldContainers(QWidget): # Normalize line endings in any text content. if not is_mixed: - assert isinstance(field.value, (str, type(None))) + assert isinstance(field.value, str | type(None)) text = field.value or "" else: text = "Mixed Data" @@ -355,7 +355,7 @@ class FieldContainers(QWidget): container.set_inline(False) # Normalize line endings in any text content. if not is_mixed: - assert isinstance(field.value, (str, type(None))) + assert isinstance(field.value, str | type(None)) text = (field.value or "").replace("\r", "\n") else: text = "Mixed Data" @@ -514,7 +514,7 @@ class FieldContainers(QWidget): """Update a field in all selected Entries, given a field object.""" assert isinstance( field, - (TextField, DatetimeField), + TextField | DatetimeField, ), f"instance: {type(field)}" entry_ids = [e.id for e in self.cached_entries] diff --git a/src/tagstudio/qt/widgets/preview/file_attributes.py b/src/tagstudio/qt/widgets/preview/file_attributes.py index 2ca9010a..268ce7e9 100644 --- a/src/tagstudio/qt/widgets/preview/file_attributes.py +++ b/src/tagstudio/qt/widgets/preview/file_attributes.py @@ -165,11 +165,11 @@ class FileAttributes(QWidget): for i, part in enumerate(display_path.parts): part_ = part.strip(os.path.sep) if i != len(display_path.parts) - 1: - file_str += f"{"\u200b".join(part_)}{separator}" + file_str += f"{'\u200b'.join(part_)}{separator}" else: if file_str != "": file_str += "
" - file_str += f"{"\u200b".join(part_)}" + file_str += f"{'\u200b'.join(part_)}" self.file_label.setText(file_str) self.file_label.setCursor(Qt.CursorShape.PointingHandCursor) self.opener = FileOpenerHelper(filepath) diff --git a/src/tagstudio/qt/widgets/progress.py b/src/tagstudio/qt/widgets/progress.py index dcd1ed16..205aed8a 100644 --- a/src/tagstudio/qt/widgets/progress.py +++ b/src/tagstudio/qt/widgets/progress.py @@ -3,7 +3,7 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -from typing import Callable +from collections.abc import Callable from PySide6.QtCore import Qt, QThreadPool from PySide6.QtWidgets import QProgressDialog, QVBoxLayout, QWidget diff --git a/src/tagstudio/qt/widgets/text_line_edit.py b/src/tagstudio/qt/widgets/text_line_edit.py index 9822abea..1c719fab 100644 --- a/src/tagstudio/qt/widgets/text_line_edit.py +++ b/src/tagstudio/qt/widgets/text_line_edit.py @@ -3,7 +3,7 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -from typing import Callable +from collections.abc import Callable from PySide6.QtWidgets import QLineEdit, QVBoxLayout diff --git a/src/tagstudio/qt/widgets/thumb_renderer.py b/src/tagstudio/qt/widgets/thumb_renderer.py index f0116c68..e23351c3 100644 --- a/src/tagstudio/qt/widgets/thumb_renderer.py +++ b/src/tagstudio/qt/widgets/thumb_renderer.py @@ -11,6 +11,7 @@ import zipfile from copy import deepcopy from io import BytesIO from pathlib import Path +from typing import cast from warnings import catch_warnings import cv2 @@ -754,8 +755,8 @@ class ThumbRenderer(QObject): data = np.asarray(raw.getchannel(0)) m, n = data.shape[:2] - col: np.ndarray = data.any(0) - row: np.ndarray = data.any(1) + col: np.ndarray = cast(np.ndarray, data.any(0)) + row: np.ndarray = cast(np.ndarray, data.any(1)) cropped_data = np.asarray(raw)[ row.argmax() : m - row[::-1].argmax(), col.argmax() : n - col[::-1].argmax(), @@ -802,7 +803,7 @@ class ThumbRenderer(QObject): bg = Image.new("RGBA", (size, size), color="#00000000") draw = ImageDraw.Draw(bg) lines_of_padding = 2 - y_offset = 0 + y_offset = 0.0 for font_size in scaled_sizes: font = ImageFont.truetype(filepath, size=font_size) diff --git a/tests/fixtures/sample.epub b/tests/fixtures/sample.epub deleted file mode 100644 index b625b67b2848ff0c97a18f213ee167ab52ed1326..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4413 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 diff --git a/tests/fixtures/sample.odt b/tests/fixtures/sample.odt deleted file mode 100644 index 4cb6f2f1cc987ab6025a1870454965355fb4605b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14468 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 diff --git a/tests/fixtures/sample.pdf b/tests/fixtures/sample.pdf deleted file mode 100644 index 0293578a2a59d6e36a2b18472ae9d4bf1aaaf635..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5389 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 X*K972P4S25B6QFYb#*g4bI88|aLs^8 diff --git a/tests/fixtures/sample.svg b/tests/fixtures/sample.svg deleted file mode 100644 index 99c924a8..00000000 --- a/tests/fixtures/sample.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.epub-_epub_cover].png b/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.epub-_epub_cover].png deleted file mode 100644 index 2b5a25815abf1350b85f14072dfd6648c3e2d7b8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3721 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 diff --git a/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.odt-_open_doc_thumb].png b/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.odt-_open_doc_thumb].png deleted file mode 100644 index ac2158e7d03c8019a3199e4e9cb7d7fe08549fd5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13127 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 kjPPI0{NK^cIXt0kps*4~Fsk+5i9m diff --git a/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.pdf-thumbnailer3].png b/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.pdf-thumbnailer3].png deleted file mode 100644 index 0ba9ea61890a903765262c53768b037ed6fb49a4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1860 zcmeAS@N?(olHy`uVBq!ia0y~yU~B^7FC1(@5rampPzDBeZci7-kcv5PuW#f%;=seQ zaew>o{K&iqPRv|wYZ5jeoHui(JVWIFw~P#LgqRvE6d5{%N2$>uz?lkmq*pVPKcDlK z^TW=WH|HH_b!QMzXJIIG8l^^qfUc?Fdk+7DytHlV41#iu4s!$;j&P1rqd|Z-73@9A X#QRicv4jb*c4Y8$^>bP0l+XkKSx0;n diff --git a/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.svg-thumbnailer4].png b/tests/qt/__snapshots__/test_thumb_renderer/test_preview_render[sample.svg-thumbnailer4].png deleted file mode 100644 index ebd90431406e2658126b2619644d61f4940091b1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5248 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 diff --git a/tests/qt/test_file_path_options.py b/tests/qt/test_file_path_options.py index 97052e09..40dfeab1 100644 --- a/tests/qt/test_file_path_options.py +++ b/tests/qt/test_file_path_options.py @@ -79,11 +79,11 @@ def test_file_path_display( for i, part in enumerate(display_path.parts): part_ = part.strip(os.path.sep) if i != len(display_path.parts) - 1: - file_str += f"{"\u200b".join(part_)}{separator}" + file_str += f"{'\u200b'.join(part_)}{separator}" else: if file_str != "": file_str += "
" - file_str += f"{"\u200b".join(part_)}" + file_str += f"{'\u200b'.join(part_)}" # Assert the file path is displayed correctly assert panel.file_attrs.file_label.text() == file_str diff --git a/tests/qt/test_thumb_renderer.py b/tests/qt/test_thumb_renderer.py deleted file mode 100644 index 721d0ffc..00000000 --- a/tests/qt/test_thumb_renderer.py +++ /dev/null @@ -1,49 +0,0 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio - -import io -from functools import partial -from pathlib import Path - -import pytest -from PIL import Image -from syrupy.extensions.image import PNGImageSnapshotExtension - -from tagstudio.qt.widgets.thumb_renderer import ThumbRenderer - - -@pytest.mark.parametrize( - ["fixture_file", "thumbnailer"], - [ - ( - "sample.odt", - ThumbRenderer._open_doc_thumb, - ), - ( - "sample.ods", - ThumbRenderer._open_doc_thumb, - ), - ( - "sample.epub", - ThumbRenderer._epub_cover, - ), - ( - "sample.pdf", - partial(ThumbRenderer._pdf_thumb, size=200), - ), - ( - "sample.svg", - partial(ThumbRenderer._image_vector_thumb, size=200), - ), - ], -) -def test_preview_render(cwd, fixture_file, thumbnailer, snapshot): - file_path: Path = cwd / "fixtures" / fixture_file - img: Image.Image = thumbnailer(file_path) - - img_bytes = io.BytesIO() - img.save(img_bytes, format="PNG") - img_bytes.seek(0) - - assert img_bytes.read() == snapshot(extension_class=PNGImageSnapshotExtension) From ee56f3e2bc84224d8666233d94d037c56ccd5ef0 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Mon, 5 May 2025 13:09:26 -0700 Subject: [PATCH 07/11] feat(ui): show stems for extension-less files (#899) * feat(ui): show stems for extension-less files * chore: remove unnecessary call and comment * fix: only access filepath if not none --- src/tagstudio/qt/widgets/item_thumb.py | 12 ++++++------ src/tagstudio/qt/widgets/preview/file_attributes.py | 6 +++--- src/tagstudio/qt/widgets/preview/preview_thumb.py | 12 +++++++----- src/tagstudio/qt/widgets/preview_panel.py | 5 ++--- src/tagstudio/qt/widgets/thumb_renderer.py | 4 +--- 5 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/tagstudio/qt/widgets/item_thumb.py b/src/tagstudio/qt/widgets/item_thumb.py index 42a82926..52719ea4 100644 --- a/src/tagstudio/qt/widgets/item_thumb.py +++ b/src/tagstudio/qt/widgets/item_thumb.py @@ -205,11 +205,11 @@ class ItemThumb(FlowWidget): self.thumb_button = ThumbButton(self.thumb_container, thumb_size) self.renderer = ThumbRenderer(self.lib) self.renderer.updated.connect( - lambda timestamp, image, size, filename, ext: ( + lambda timestamp, image, size, filename: ( self.update_thumb(timestamp, image=image), self.update_size(timestamp, size=size), self.set_filename_text(filename), - self.set_extension(ext), # type: ignore + self.set_extension(filename), ) ) self.thumb_button.setFlat(True) @@ -365,13 +365,13 @@ class ItemThumb(FlowWidget): self.item_type_badge.setHidden(False) self.mode = mode - def set_extension(self, ext: str) -> None: + def set_extension(self, filename: Path) -> None: + ext = filename.suffix if ext and ext.startswith(".") is False: ext = "." + ext media_types: set[MediaType] = MediaCategories.get_types(ext) if ( - ext - and not MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES) + not MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES) or MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_RAW_TYPES) or MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_VECTOR_TYPES) or MediaCategories.is_ext_in_category(ext, MediaCategories.ADOBE_PHOTOSHOP_TYPES) @@ -386,7 +386,7 @@ class ItemThumb(FlowWidget): ] ): self.ext_badge.setHidden(False) - self.ext_badge.setText(ext.upper()[1:]) + self.ext_badge.setText(ext.upper()[1:] or filename.stem.upper()) if MediaType.VIDEO in media_types or MediaType.AUDIO in media_types: self.count_badge.setHidden(False) else: diff --git a/src/tagstudio/qt/widgets/preview/file_attributes.py b/src/tagstudio/qt/widgets/preview/file_attributes.py index 268ce7e9..694ef91b 100644 --- a/src/tagstudio/qt/widgets/preview/file_attributes.py +++ b/src/tagstudio/qt/widgets/preview/file_attributes.py @@ -131,7 +131,7 @@ class FileAttributes(QWidget): self.date_created_label.setHidden(True) self.date_modified_label.setHidden(True) - def update_stats(self, filepath: Path | None = None, ext: str = ".", stats: dict | None = None): + def update_stats(self, filepath: Path | None = None, stats: dict | None = None): """Render the panel widgets with the newest data from the Library.""" if not stats: stats = {} @@ -145,6 +145,7 @@ class FileAttributes(QWidget): self.dimensions_label.setText("") self.dimensions_label.setHidden(True) else: + ext = filepath.suffix.lower() self.library_path = self.library.library_dir display_path = filepath if self.driver.settings.show_filepath == ShowFilepathOption.SHOW_FULL_PATHS: @@ -188,8 +189,7 @@ class FileAttributes(QWidget): height_px_text = stats.get("height", "") duration_text = stats.get("duration", "") font_family = stats.get("font_family", "") - if ext: - ext_display = ext.upper()[1:] + ext_display = ext.upper()[1:] or filepath.stem.upper() if filepath: try: file_size = format_size(filepath.stat().st_size) diff --git a/src/tagstudio/qt/widgets/preview/preview_thumb.py b/src/tagstudio/qt/widgets/preview/preview_thumb.py index 4b397226..53b94a22 100644 --- a/src/tagstudio/qt/widgets/preview/preview_thumb.py +++ b/src/tagstudio/qt/widgets/preview/preview_thumb.py @@ -242,11 +242,12 @@ class PreviewThumb(QWidget): self.devicePixelRatio(), update_on_ratio_change=True, ) - return self._update_image(filepath, ext) + return self._update_image(filepath) - def _update_image(self, filepath: Path, ext: str) -> dict[str, int]: + def _update_image(self, filepath: Path) -> dict[str, int]: """Update the static image preview from a filepath.""" stats: dict[str, int] = {} + ext = filepath.suffix.lower() self.switch_preview("image") image: Image.Image | None = None @@ -380,8 +381,9 @@ class PreviewThumb(QWidget): stats["duration"] = self.media_player.player.duration() * 1000 return stats - def update_preview(self, filepath: Path, ext: str) -> dict[str, int]: + def update_preview(self, filepath: Path) -> dict[str, int]: """Render a single file preview.""" + ext = filepath.suffix.lower() stats: dict[str, int] = {} # Video @@ -394,7 +396,7 @@ class PreviewThumb(QWidget): elif MediaCategories.is_ext_in_category( ext, MediaCategories.AUDIO_TYPES, mime_fallback=True ): - self._update_image(filepath, ext) + self._update_image(filepath) stats = self._update_media(filepath, MediaType.AUDIO) self.thumb_renderer.render( time.time(), @@ -413,7 +415,7 @@ class PreviewThumb(QWidget): # Other Types (Including Images) else: # TODO: Get thumb renderer to return this stuff to pass on - stats = self._update_image(filepath, ext) + stats = self._update_image(filepath) self.thumb_renderer.render( time.time(), diff --git a/src/tagstudio/qt/widgets/preview_panel.py b/src/tagstudio/qt/widgets/preview_panel.py index dffb12f6..ef4676e8 100644 --- a/src/tagstudio/qt/widgets/preview_panel.py +++ b/src/tagstudio/qt/widgets/preview_panel.py @@ -145,11 +145,10 @@ class PreviewPanel(QWidget): entry: Entry = self.lib.get_entry(self.driver.selected[0]) entry_id = self.driver.selected[0] filepath: Path = self.lib.library_dir / entry.path - ext: str = filepath.suffix.lower() if update_preview: - stats: dict = self.thumb.update_preview(filepath, ext) - self.file_attrs.update_stats(filepath, ext, stats) + stats: dict = self.thumb.update_preview(filepath) + self.file_attrs.update_stats(filepath, stats) self.file_attrs.update_date_label(filepath) self.fields.update_from_entry(entry_id) self.update_add_tag_button(entry_id) diff --git a/src/tagstudio/qt/widgets/thumb_renderer.py b/src/tagstudio/qt/widgets/thumb_renderer.py index e23351c3..e4ec1e8d 100644 --- a/src/tagstudio/qt/widgets/thumb_renderer.py +++ b/src/tagstudio/qt/widgets/thumb_renderer.py @@ -89,7 +89,7 @@ class ThumbRenderer(QObject): rm: ResourceManager = ResourceManager() cache: CacheManager = CacheManager() - updated = Signal(float, QPixmap, QSize, Path, str) + updated = Signal(float, QPixmap, QSize, Path) updated_ratio = Signal(float) cached_img_res: int = 256 # TODO: Pull this from config @@ -1300,7 +1300,6 @@ class ThumbRenderer(QObject): math.ceil(image.size[1] / pixel_ratio), ), filepath, - filepath.suffix.lower(), ) else: self.updated.emit( @@ -1308,7 +1307,6 @@ class ThumbRenderer(QObject): QPixmap(), QSize(*base_size), filepath, - filepath.suffix.lower(), ) def _render( From d061e2e86617e573d1bd130a18b3a5d0f530dabb Mon Sep 17 00:00:00 2001 From: Jann Stute <46534683+Computerdores@users.noreply.github.com> Date: Mon, 5 May 2025 23:04:48 +0200 Subject: [PATCH 08/11] fix: tests were overwriting the settings.toml (#928) * fix: tests were overwriting the settings.toml * fix(GlobalSettings): add default value for _loaded_from * fix: a case in read_settings didn't set loaded_from correctly * fix(GlobalSettings): proper serialisation --- src/tagstudio/core/global_settings.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/tagstudio/core/global_settings.py b/src/tagstudio/core/global_settings.py index 4c95ec77..a30fdeec 100644 --- a/src/tagstudio/core/global_settings.py +++ b/src/tagstudio/core/global_settings.py @@ -55,6 +55,8 @@ class GlobalSettings(BaseModel): hour_format: bool = Field(default=True) zero_padding: bool = Field(default=True) + loaded_from: Path = Field(default=DEFAULT_GLOBAL_SETTINGS_PATH, exclude=True) + @staticmethod def read_settings(path: Path = DEFAULT_GLOBAL_SETTINGS_PATH) -> "GlobalSettings": if path.exists(): @@ -63,17 +65,19 @@ class GlobalSettings(BaseModel): if len(filecontents.strip()) != 0: logger.info("[Settings] Reading Global Settings File", path=path) settings_data = toml.loads(filecontents) - settings = GlobalSettings(**settings_data) + settings = GlobalSettings(**settings_data, loaded_from=path) return settings - return GlobalSettings() + return GlobalSettings(loaded_from=path) - def save(self, path: Path = DEFAULT_GLOBAL_SETTINGS_PATH) -> None: + def save(self, path: Path | None = None) -> None: + if path is None: + path = self.loaded_from if not path.parent.exists(): path.parent.mkdir(parents=True, exist_ok=True) with open(path, "w") as f: - toml.dump(dict(self), f, encoder=TomlEnumEncoder()) + toml.dump(self.model_dump(), f, encoder=TomlEnumEncoder()) def format_datetime(self, dt: datetime) -> str: date_format = self.date_format From 1e783a5e3c795e019742ac24c44db87d8774bd02 Mon Sep 17 00:00:00 2001 From: Xarvex <60973030+xarvex@users.noreply.github.com> Date: Tue, 6 May 2025 16:42:21 -0500 Subject: [PATCH 09/11] fix(nix/package): override PySide6 if later version is being used (#917) --- flake.lock | 12 +-- flake.nix | 26 +++++- nix/package/default.nix | 1 + .../shiboken6-fix-include-qt-headers.patch | 81 +++++++++++++++++++ 4 files changed, 113 insertions(+), 7 deletions(-) create mode 100644 nix/package/shiboken6-fix-include-qt-headers.patch diff --git a/flake.lock b/flake.lock index 46071142..c3122303 100644 --- a/flake.lock +++ b/flake.lock @@ -7,11 +7,11 @@ ] }, "locked": { - "lastModified": 1725024810, - "narHash": "sha256-ODYRm8zHfLTH3soTFWE452ydPYz2iTvr9T8ftDMUQ3E=", + "lastModified": 1743550720, + "narHash": "sha256-hIshGgKZCgWh6AYJpJmRgFdR3WUbkY04o82X05xqQiY=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "af510d4a62d071ea13925ce41c95e3dec816c01d", + "rev": "c621e8422220273271f52058f618c94e405bb0f5", "type": "github" }, "original": { @@ -22,11 +22,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1741173522, - "narHash": "sha256-k7VSqvv0r1r53nUI/IfPHCppkUAddeXn843YlAC5DR0=", + "lastModified": 1744932701, + "narHash": "sha256-fusHbZCyv126cyArUwwKrLdCkgVAIaa/fQJYFlCEqiU=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "d69ab0d71b22fa1ce3dbeff666e6deb4917db049", + "rev": "b024ced1aac25639f8ca8fdfc2f8c4fbd66c48ef", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index f81c2f35..3f4a4eda 100644 --- a/flake.nix +++ b/flake.nix @@ -41,7 +41,31 @@ in rec { default = tagstudio; - tagstudio = pkgs.callPackage ./nix/package { inherit pillow-jxl-plugin vtf2img; }; + tagstudio = pkgs.callPackage ./nix/package { + # HACK: Remove when PySide6 is bumped to 6.9.x. + # Sourced from https://github.com/NixOS/nixpkgs/commit/2f9c1ad5e19a6154d541f878774a9aacc27381b7. + pyside6 = + if lib.versionAtLeast python3Packages.pyside6.version "6.9.0" then + (python3Packages.pyside6.override { + shiboken6 = python3Packages.shiboken6.overrideAttrs { + version = "6.8.0.2"; + + src = pkgs.fetchurl { + url = "mirror://qt/official_releases/QtForPython/shiboken6/PySide6-6.8.0.2-src/pyside-setup-everywhere-src-6.8.0.tar.xz"; + hash = "sha256-Ghohmo8yfjQNJYJ1+tOp8mG48EvFcEF0fnPdatJStOE="; + }; + + sourceRoot = "pyside-setup-everywhere-src-6.8.0/sources/shiboken6"; + + patches = [ ./nix/package/shiboken6-fix-include-qt-headers.patch ]; + }; + }).overrideAttrs + { sourceRoot = "pyside-setup-everywhere-src-6.8.0/sources/pyside6"; } + else + python3Packages.pyside6; + + inherit pillow-jxl-plugin vtf2img; + }; tagstudio-jxl = tagstudio.override { withJXLSupport = true; }; inherit pillow-jxl-plugin pyexiv2 vtf2img; diff --git a/nix/package/default.nix b/nix/package/default.nix index bcc6f7e3..a5d62026 100644 --- a/nix/package/default.nix +++ b/nix/package/default.nix @@ -8,6 +8,7 @@ wrapGAppsHook, pillow-jxl-plugin, + pyside6, vtf2img, withJXLSupport ? false, diff --git a/nix/package/shiboken6-fix-include-qt-headers.patch b/nix/package/shiboken6-fix-include-qt-headers.patch new file mode 100644 index 00000000..6ebca006 --- /dev/null +++ b/nix/package/shiboken6-fix-include-qt-headers.patch @@ -0,0 +1,81 @@ +Sourced from https://github.com/NixOS/nixpkgs/blob/5ba0f1ea90b0afa2abc23a43edb63af51d932e6d/pkgs/development/python-modules/shiboken6/fix-include-qt-headers.patch. +--- a/ApiExtractor/clangparser/compilersupport.cpp ++++ b/ApiExtractor/clangparser/compilersupport.cpp +@@ -16,6 +16,7 @@ + #include + #include + #include ++#include + + #include + +@@ -341,6 +342,13 @@ QByteArrayList emulatedCompilerOptions() + { + QByteArrayList result; + HeaderPaths headerPaths; ++ ++ bool isNixDebug = qgetenv("NIX_DEBUG").toInt() > 0; ++ // examples: ++ // /nix/store/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-qtsensors-6.4.2-dev/include ++ // /nix/store/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-qtbase-6.4.2-dev/include ++ QRegularExpression qtHeaderRegex(uR"(/[0-9a-z]{32}-qt[a-z0-9]+-)"_s); ++ + switch (compiler()) { + case Compiler::Msvc: + result.append(QByteArrayLiteral("-fms-compatibility-version=19.26.28806")); +@@ -352,9 +360,30 @@ QByteArrayList emulatedCompilerOptions() + appendClangBuiltinIncludes(&headerPaths); + break; + case Compiler::Clang: +- headerPaths.append(gppInternalIncludePaths(compilerFromCMake(u"clang++"_s))); ++ // fix: error: cannot jump from switch statement to this case label: case Compiler::Gpp ++ // note: jump bypasses variable initialization: const HeaderPaths clangPaths = ++ { ++ //headerPaths.append(gppInternalIncludePaths(compilerFromCMake(u"clang++"_s))); ++ // fix: qt.shiboken: x is specified in typesystem, but not defined. This could potentially lead to compilation errors. ++ // PySide requires that Qt headers are not -isystem ++ // https://bugreports.qt.io/browse/PYSIDE-787 ++ const HeaderPaths clangPaths = gppInternalIncludePaths(compilerFromCMake(u"clang++"_qs)); ++ for (const HeaderPath &h : clangPaths) { ++ auto match = qtHeaderRegex.match(QString::fromUtf8(h.path)); ++ if (!match.hasMatch()) { ++ if (isNixDebug) ++ qDebug() << "shiboken compilersupport.cpp: found non-qt header: " << h.path; ++ // add using -isystem ++ headerPaths.append(h); ++ } else { ++ if (isNixDebug) ++ qDebug() << "shiboken compilersupport.cpp: found qt header: " << h.path; ++ headerPaths.append({h.path, HeaderType::Standard}); ++ } ++ } + result.append(noStandardIncludeOption()); + break; ++ } + case Compiler::Gpp: + if (needsClangBuiltinIncludes()) + appendClangBuiltinIncludes(&headerPaths); +@@ -363,8 +392,20 @@ QByteArrayList emulatedCompilerOptions() + // etc (g++ 11.3). + const HeaderPaths gppPaths = gppInternalIncludePaths(compilerFromCMake(u"g++"_qs)); + for (const HeaderPath &h : gppPaths) { +- if (h.path.contains("c++") || h.path.contains("sysroot")) ++ // fix: qt.shiboken: x is specified in typesystem, but not defined. This could potentially lead to compilation errors. ++ // PySide requires that Qt headers are not -isystem ++ // https://bugreports.qt.io/browse/PYSIDE-787 ++ auto match = qtHeaderRegex.match(QString::fromUtf8(h.path)); ++ if (!match.hasMatch()) { ++ if (isNixDebug) ++ qDebug() << "shiboken compilersupport.cpp: found non-qt header: " << h.path; ++ // add using -isystem + headerPaths.append(h); ++ } else { ++ if (isNixDebug) ++ qDebug() << "shiboken compilersupport.cpp: found qt header: " << h.path; ++ headerPaths.append({h.path, HeaderType::Standard}); ++ } + } + break; + } +-- +2.39.0 From cf6c56c9d25bc0b47db43e5a76f372d975f576d6 Mon Sep 17 00:00:00 2001 From: Jann Stute <46534683+Computerdores@users.noreply.github.com> Date: Wed, 4 Jun 2025 09:29:07 +0200 Subject: [PATCH 10/11] fix: restore page navigation state (#933) * refactor: store browsing history for navigation purposes * refactor: remove page_size from FilterState * refactor: move on from the term "filter" in favor of "BrowsingState" * fix: refactors didn't propagate to the tests * fix: ruff complaints * fix: remaing refactoring errors * fix: navigation works again * fix: also store and restore query --- src/tagstudio/core/library/alchemy/enums.py | 56 +++--- src/tagstudio/core/library/alchemy/library.py | 9 +- src/tagstudio/core/utils/dupe_files.py | 4 +- src/tagstudio/qt/modals/fix_unlinked.py | 4 +- src/tagstudio/qt/modals/tag_search.py | 6 +- src/tagstudio/qt/ts_qt.py | 170 +++++++++++------- src/tagstudio/qt/widgets/tag_box.py | 6 +- tests/macros/test_missing_files.py | 4 +- tests/qt/test_file_path_options.py | 2 +- tests/qt/test_qt_driver.py | 18 +- tests/test_library.py | 66 +++---- tests/test_search.py | 6 +- 12 files changed, 196 insertions(+), 155 deletions(-) diff --git a/src/tagstudio/core/library/alchemy/enums.py b/src/tagstudio/core/library/alchemy/enums.py index ee7f3ca0..980ddd2d 100644 --- a/src/tagstudio/core/library/alchemy/enums.py +++ b/src/tagstudio/core/library/alchemy/enums.py @@ -4,7 +4,7 @@ from pathlib import Path import structlog -from tagstudio.core.query_lang.ast import AST, Constraint, ConstraintType +from tagstudio.core.query_lang.ast import AST from tagstudio.core.query_lang.parser import Parser MAX_SQL_VARIABLES = 32766 # 32766 is the max sql bind parameter count as defined here: https://github.com/sqlite/sqlite/blob/master/src/sqliteLimit.h#L140 @@ -72,59 +72,57 @@ class SortingModeEnum(enum.Enum): @dataclass -class FilterState: +class BrowsingState: """Represent a state of the Library grid view.""" - # these should remain - page_size: int page_index: int = 0 sorting_mode: SortingModeEnum = SortingModeEnum.DATE_ADDED ascending: bool = True - # these should be erased on update + query: str | None = None + # Abstract Syntax Tree Of the current Search Query - ast: AST | None = None - @property - def limit(self): - return self.page_size - - @property - def offset(self): - return self.page_size * self.page_index + def ast(self) -> AST | None: + if self.query is None: + return None + return Parser(self.query).parse() @classmethod - def show_all(cls, page_size: int) -> "FilterState": - return FilterState(page_size=page_size) + def show_all(cls) -> "BrowsingState": + return BrowsingState() @classmethod - def from_search_query(cls, search_query: str, page_size: int) -> "FilterState": - return cls(ast=Parser(search_query).parse(), page_size=page_size) + def from_search_query(cls, search_query: str) -> "BrowsingState": + return cls(query=search_query) @classmethod - def from_tag_id(cls, tag_id: int | str, page_size: int) -> "FilterState": - return cls(ast=Constraint(ConstraintType.TagID, str(tag_id), []), page_size=page_size) + def from_tag_id(cls, tag_id: int | str) -> "BrowsingState": + return cls(query=f"tag_id:{str(tag_id)}") @classmethod - def from_path(cls, path: Path | str, page_size: int) -> "FilterState": - return cls(ast=Constraint(ConstraintType.Path, str(path).strip(), []), page_size=page_size) + def from_path(cls, path: Path | str) -> "BrowsingState": + return cls(query=f'path:"{str(path).strip()}"') @classmethod - def from_mediatype(cls, mediatype: str, page_size: int) -> "FilterState": - return cls(ast=Constraint(ConstraintType.MediaType, mediatype, []), page_size=page_size) + def from_mediatype(cls, mediatype: str) -> "BrowsingState": + return cls(query=f"mediatype:{mediatype}") @classmethod - def from_filetype(cls, filetype: str, page_size: int) -> "FilterState": - return cls(ast=Constraint(ConstraintType.FileType, filetype, []), page_size=page_size) + def from_filetype(cls, filetype: str) -> "BrowsingState": + return cls(query=f"filetype:{filetype}") @classmethod - def from_tag_name(cls, tag_name: str, page_size: int) -> "FilterState": - return cls(ast=Constraint(ConstraintType.Tag, tag_name, []), page_size=page_size) + def from_tag_name(cls, tag_name: str) -> "BrowsingState": + return cls(query=f'tag:"{tag_name}"') - def with_sorting_mode(self, mode: SortingModeEnum) -> "FilterState": + def with_page_index(self, index: int) -> "BrowsingState": + return replace(self, page_index=index) + + def with_sorting_mode(self, mode: SortingModeEnum) -> "BrowsingState": return replace(self, sorting_mode=mode) - def with_sorting_direction(self, ascending: bool) -> "FilterState": + def with_sorting_direction(self, ascending: bool) -> "BrowsingState": return replace(self, ascending=ascending) diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py index 81e259d4..c01c20b7 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -61,8 +61,8 @@ from tagstudio.core.library.alchemy import default_color_groups from tagstudio.core.library.alchemy.db import make_tables from tagstudio.core.library.alchemy.enums import ( MAX_SQL_VARIABLES, + BrowsingState, FieldTypeEnum, - FilterState, SortingModeEnum, ) from tagstudio.core.library.alchemy.fields import ( @@ -857,13 +857,14 @@ class Library: def search_library( self, - search: FilterState, + search: BrowsingState, + page_size: int, ) -> SearchResult: """Filter library by search query. :return: number of entries matching the query and one page of results. """ - assert isinstance(search, FilterState) + assert isinstance(search, BrowsingState) assert self.engine with Session(self.engine, expire_on_commit=False) as session: @@ -902,7 +903,7 @@ class Library: sort_on = func.lower(Entry.path) statement = statement.order_by(asc(sort_on) if search.ascending else desc(sort_on)) - statement = statement.limit(search.limit).offset(search.offset) + statement = statement.limit(page_size).offset(search.page_index * page_size) logger.info( "searching library", diff --git a/src/tagstudio/core/utils/dupe_files.py b/src/tagstudio/core/utils/dupe_files.py index 4ffc08d2..eb5d6a4b 100644 --- a/src/tagstudio/core/utils/dupe_files.py +++ b/src/tagstudio/core/utils/dupe_files.py @@ -4,7 +4,7 @@ from pathlib import Path import structlog -from tagstudio.core.library.alchemy.enums import FilterState +from tagstudio.core.library.alchemy.enums import BrowsingState from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry @@ -52,7 +52,7 @@ class DupeRegistry: continue results = self.library.search_library( - FilterState.from_path(path_relative, page_size=500), + BrowsingState.from_path(path_relative), 500 ) if not results: diff --git a/src/tagstudio/qt/modals/fix_unlinked.py b/src/tagstudio/qt/modals/fix_unlinked.py index 23b7c4d2..89eced5f 100644 --- a/src/tagstudio/qt/modals/fix_unlinked.py +++ b/src/tagstudio/qt/modals/fix_unlinked.py @@ -63,7 +63,7 @@ class FixUnlinkedEntriesModal(QWidget): self.relink_class.done.connect( # refresh the grid lambda: ( - self.driver.filter_items(), + self.driver.update_browsing_state(), self.refresh_missing_files(), ) ) @@ -78,7 +78,7 @@ class FixUnlinkedEntriesModal(QWidget): lambda: ( self.set_missing_count(), # refresh the grid - self.driver.filter_items(), + self.driver.update_browsing_state(), ) ) self.delete_button.clicked.connect(self.delete_modal.show) diff --git a/src/tagstudio/qt/modals/tag_search.py b/src/tagstudio/qt/modals/tag_search.py index 721b55ce..6b2ed4d7 100644 --- a/src/tagstudio/qt/modals/tag_search.py +++ b/src/tagstudio/qt/modals/tag_search.py @@ -24,7 +24,7 @@ from PySide6.QtWidgets import ( ) from tagstudio.core.constants import RESERVED_TAG_END, RESERVED_TAG_START -from tagstudio.core.library.alchemy.enums import FilterState, TagColorEnum +from tagstudio.core.library.alchemy.enums import BrowsingState, TagColorEnum from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Tag from tagstudio.core.palette import ColorType, get_tag_color @@ -292,9 +292,7 @@ class TagSearchPanel(PanelWidget): tag_widget.search_for_tag_action.triggered.connect( lambda checked=False, tag_id=tag.id: ( self.driver.main_window.searchField.setText(f"tag_id:{tag_id}"), - self.driver.filter_items( - FilterState.from_tag_id(tag_id, page_size=self.driver.settings.page_size) - ), + self.driver.update_browsing_state(BrowsingState.from_tag_id(tag_id)), ) ) tag_widget.search_for_tag_action.setEnabled(True) diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index a45c2eac..4ed07a33 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -10,7 +10,6 @@ import contextlib import ctypes -import dataclasses import math import os import platform @@ -21,6 +20,7 @@ from argparse import Namespace from pathlib import Path from queue import Queue from shutil import which +from typing import Generic, TypeVar from warnings import catch_warnings import structlog @@ -60,8 +60,8 @@ from tagstudio.core.driver import DriverMixin from tagstudio.core.enums import MacroID, SettingItems, ShowFilepathOption from tagstudio.core.global_settings import DEFAULT_GLOBAL_SETTINGS_PATH, GlobalSettings, Theme from tagstudio.core.library.alchemy.enums import ( + BrowsingState, FieldTypeEnum, - FilterState, ItemType, SortingModeEnum, ) @@ -121,6 +121,10 @@ else: logger = structlog.get_logger(__name__) +def clamp(value, lower_bound, upper_bound): + return max(lower_bound, min(upper_bound, value)) + + class Consumer(QThread): MARKER_QUIT = "MARKER_QUIT" @@ -139,6 +143,38 @@ class Consumer(QThread): pass +T = TypeVar("T") + + +# Ex. User visits | A ->[B] | +# | A B ->[C]| +# | A [B]<- C | +# |[A]<- B C | Previous routes still exist +# | A ->[D] | Stack is cut from [:A] on new route +class History(Generic[T]): + __history: list[T] + __index: int = 0 + + def __init__(self, initial_value: T): + self.__history = [initial_value] + super().__init__() + + def erase_future(self) -> None: + self.__history = self.__history[: self.__index + 1] + + def push(self, value: T) -> None: + self.erase_future() + self.__history.append(value) + self.__index = len(self.__history) - 1 + + def move(self, delta: int): + self.__index = clamp(self.__index + delta, 0, len(self.__history) - 1) + + @property + def current(self) -> T: + return self.__history[self.__index] + + class QtDriver(DriverMixin, QObject): """A Qt GUI frontend driver for TagStudio.""" @@ -154,10 +190,13 @@ class QtDriver(DriverMixin, QObject): about_modal: AboutModal unlinked_modal: FixUnlinkedEntriesModal dupe_modal: FixDupeFilesModal + applied_theme: Theme lib: Library + browsing_history: History[BrowsingState] + def __init__(self, args: Namespace): super().__init__() # prevent recursive badges update when multiple items selected @@ -167,7 +206,6 @@ class QtDriver(DriverMixin, QObject): self.args = args self.frame_content: list[int] = [] # List of Entry IDs on the current page self.pages_count = 0 - self.applied_theme = None self.scrollbar_pos = 0 self.thumb_size = 128 @@ -195,7 +233,9 @@ class QtDriver(DriverMixin, QObject): "[Settings] Global Settings File does not exist creating", path=self.global_settings_path, ) - self.filter = FilterState.show_all(page_size=self.settings.page_size) + self.applied_theme = self.settings.theme + + self.__reset_navigation() if self.args.cache_file: path = Path(self.args.cache_file) @@ -237,6 +277,9 @@ class QtDriver(DriverMixin, QObject): self.add_tag_to_selected_action: QAction | None = None + def __reset_navigation(self) -> None: + self.browsing_history = History(BrowsingState.show_all()) + def init_workers(self): """Init workers for rendering thumbnails.""" if not self.thumb_threads: @@ -281,7 +324,6 @@ class QtDriver(DriverMixin, QObject): self.app.styleHints().setColorScheme( Qt.ColorScheme.Dark if self.settings.theme == Theme.DARK else Qt.ColorScheme.Light ) - self.applied_theme = self.settings.theme if ( platform.system() == "Darwin" or platform.system() == "Windows" @@ -700,7 +742,6 @@ class QtDriver(DriverMixin, QObject): ] self.item_thumbs: list[ItemThumb] = [] self.thumb_renderers: list[ThumbRenderer] = [] - self.filter = FilterState.show_all(page_size=self.settings.page_size) self.init_library_window() self.migration_modal: JsonMigrationModal = None @@ -744,12 +785,10 @@ class QtDriver(DriverMixin, QObject): # in a global dict for methods to access for different DPIs. # adj_font_size = math.floor(12 * self.main_window.devicePixelRatio()) - def _filter_items(): + def _update_browsing_state(): try: - self.filter_items( - FilterState.from_search_query( - self.main_window.searchField.text(), page_size=self.settings.page_size - ) + self.update_browsing_state( + BrowsingState.from_search_query(self.main_window.searchField.text()) .with_sorting_mode(self.sorting_mode) .with_sorting_direction(self.sorting_direction) ) @@ -758,21 +797,21 @@ class QtDriver(DriverMixin, QObject): f"{Translations['status.results.invalid_syntax']} " f'"{self.main_window.searchField.text()}"' ) - logger.error("[QtDriver] Could not filter items", error=e) + logger.error("[QtDriver] Could not update BrowsingState", error=e) # Search Button search_button: QPushButton = self.main_window.searchButton - search_button.clicked.connect(_filter_items) + search_button.clicked.connect(_update_browsing_state) # Search Field search_field: QLineEdit = self.main_window.searchField - search_field.returnPressed.connect(_filter_items) + search_field.returnPressed.connect(_update_browsing_state) # Sorting Dropdowns sort_mode_dropdown: QComboBox = self.main_window.sorting_mode_combobox for sort_mode in SortingModeEnum: sort_mode_dropdown.addItem(Translations[sort_mode.value], sort_mode) sort_mode_dropdown.setCurrentIndex( - list(SortingModeEnum).index(self.filter.sorting_mode) - ) # set according to self.filter + list(SortingModeEnum).index(self.browsing_history.current.sorting_mode) + ) # set according to navigation state sort_mode_dropdown.currentIndexChanged.connect(self.sorting_mode_callback) sort_dir_dropdown: QComboBox = self.main_window.sorting_direction_combobox @@ -794,9 +833,9 @@ class QtDriver(DriverMixin, QObject): self._init_thumb_grid() back_button: QPushButton = self.main_window.backButton - back_button.clicked.connect(lambda: self.page_move(-1)) + back_button.clicked.connect(lambda: self.navigation_callback(-1)) forward_button: QPushButton = self.main_window.forwardButton - forward_button.clicked.connect(lambda: self.page_move(1)) + forward_button.clicked.connect(lambda: self.navigation_callback(1)) # NOTE: Putting this early will result in a white non-responsive # window until everything is loaded. Consider adding a splash screen @@ -805,7 +844,7 @@ class QtDriver(DriverMixin, QObject): self.main_window.activateWindow() self.main_window.toggle_landing_page(enabled=True) - self.main_window.pagination.index.connect(lambda i: self.page_move(page_id=i)) + self.main_window.pagination.index.connect(lambda i: self.page_move(i, absolute=True)) self.splash.finish(self.main_window) @@ -825,7 +864,9 @@ class QtDriver(DriverMixin, QObject): ) self.file_extension_panel.setTitle(Translations["ignore_list.title"]) self.file_extension_panel.setWindowTitle(Translations["ignore_list.title"]) - self.file_extension_panel.saved.connect(lambda: (panel.save(), self.filter_items())) + self.file_extension_panel.saved.connect( + lambda: (panel.save(), self.update_browsing_state()) + ) self.manage_file_ext_action.triggered.connect(self.file_extension_panel.show) def show_grid_filenames(self, value: bool): @@ -871,7 +912,7 @@ class QtDriver(DriverMixin, QObject): self.main_window.searchField.setText("") scrollbar: QScrollArea = self.main_window.scrollArea scrollbar.verticalScrollBar().setValue(0) - self.filter = FilterState.show_all(page_size=self.settings.page_size) + self.__reset_navigation() self.lib.close() @@ -1059,7 +1100,7 @@ class QtDriver(DriverMixin, QObject): self.selected.clear() if deleted_count > 0: - self.filter_items() + self.update_browsing_state() self.preview_panel.update_widgets() if len(self.selected) <= 1 and deleted_count == 0: @@ -1211,7 +1252,7 @@ class QtDriver(DriverMixin, QObject): pw.hide(), pw.deleteLater(), # refresh the library only when new items are added - files_count and self.filter_items(), # type: ignore + files_count and self.update_browsing_state(), # type: ignore ) ) QThreadPool.globalInstance().start(r) @@ -1282,7 +1323,9 @@ class QtDriver(DriverMixin, QObject): def sorting_direction_callback(self): logger.info("Sorting Direction Changed", ascending=self.sorting_direction) - self.filter_items() + self.update_browsing_state( + self.browsing_history.current.with_sorting_direction(self.sorting_direction) + ) @property def sorting_mode(self) -> SortingModeEnum: @@ -1291,7 +1334,9 @@ class QtDriver(DriverMixin, QObject): def sorting_mode_callback(self): logger.info("Sorting Mode Changed", mode=self.sorting_mode) - self.filter_items() + self.update_browsing_state( + self.browsing_history.current.with_sorting_mode(self.sorting_mode) + ) def thumb_size_callback(self, index: int): """Perform actions needed when the thumbnail size selection is changed. @@ -1324,41 +1369,42 @@ class QtDriver(DriverMixin, QObject): def mouse_navigation(self, event: QMouseEvent): # print(event.button()) if event.button() == Qt.MouseButton.ForwardButton: - self.page_move(1) + self.navigation_callback(1) elif event.button() == Qt.MouseButton.BackButton: - self.page_move(-1) + self.navigation_callback(-1) - def page_move(self, delta: int = None, page_id: int = None) -> None: - """Navigate a step further into the navigation stack.""" - logger.info( - "page_move", - delta=delta, - page_id=page_id, + def page_move(self, value: int, absolute=False) -> None: + logger.info("page_move", value=value, absolute=absolute) + + if not absolute: + value += self.browsing_history.current.page_index + + self.browsing_history.push( + self.browsing_history.current.with_page_index(clamp(value, 0, self.pages_count - 1)) ) - # Ex. User visits | A ->[B] | - # | A B ->[C]| - # | A [B]<- C | - # |[A]<- B C | Previous routes still exist - # | A ->[D] | Stack is cut from [:A] on new route - - # sb: QScrollArea = self.main_window.scrollArea - # sb_pos = sb.verticalScrollBar().value() - - page_index = page_id if page_id is not None else self.filter.page_index + delta - page_index = max(0, min(page_index, self.pages_count - 1)) - - self.filter.page_index = page_index # TODO: Re-allow selecting entries across multiple pages at once. # This works fine with additive selection but becomes a nightmare with bridging. - self.filter_items() + + self.update_browsing_state() + + def navigation_callback(self, delta: int) -> None: + """Callback for the Forwads and Backwards Navigation Buttons next to the search bar.""" + logger.info( + "navigation_callback", + delta=delta, + ) + + self.browsing_history.move(delta) + + self.update_browsing_state() def remove_grid_item(self, grid_idx: int): self.frame_content[grid_idx] = None self.item_thumbs[grid_idx].hide() def _update_thumb_count(self): - missing_count = max(0, self.filter.page_size - len(self.item_thumbs)) + missing_count = max(0, self.settings.page_size - len(self.item_thumbs)) layout = self.flow_container.layout() for _ in range(missing_count): item_thumb = ItemThumb( @@ -1742,17 +1788,17 @@ class QtDriver(DriverMixin, QObject): pending_entries.get(badge_type, []), BADGE_TAGS[badge_type] ) - def filter_items(self, filter: FilterState | None = None) -> None: + def update_browsing_state(self, state: BrowsingState | None = None) -> None: + """Navigates to a new BrowsingState when state is given, otherwise updates the results.""" if not self.lib.library_dir: logger.info("Library not loaded") return assert self.lib.engine - if filter: - self.filter = dataclasses.replace(self.filter, **dataclasses.asdict(filter)) - else: - self.filter.sorting_mode = self.sorting_mode - self.filter.ascending = self.sorting_direction + if state: + self.browsing_history.push(state) + + self.main_window.searchField.setText(self.browsing_history.current.query or "") # inform user about running search self.main_window.statusbar.showMessage(Translations["status.library_search_query"]) @@ -1760,7 +1806,7 @@ class QtDriver(DriverMixin, QObject): # search the library start_time = time.time() - results = self.lib.search_library(self.filter) + results = self.lib.search_library(self.browsing_history.current, self.settings.page_size) logger.info("items to render", count=len(results)) end_time = time.time() @@ -1778,9 +1824,9 @@ class QtDriver(DriverMixin, QObject): self.update_thumbs() # update pagination - self.pages_count = math.ceil(results.total_count / self.filter.page_size) + self.pages_count = math.ceil(results.total_count / self.settings.page_size) self.main_window.pagination.update_buttons( - self.pages_count, self.filter.page_index, emit=False + self.pages_count, self.browsing_history.current.page_index, emit=False ) def remove_recent_library(self, item_key: str): @@ -1915,14 +1961,14 @@ class QtDriver(DriverMixin, QObject): if open_status.json_migration_req: self.migration_modal = JsonMigrationModal(path) self.migration_modal.migration_finished.connect( - lambda: self.init_library(path, self.lib.open_library(path)) + lambda: self._init_library(path, self.lib.open_library(path)) ) self.main_window.landing_widget.set_status_label("") self.migration_modal.paged_panel.show() else: - self.init_library(path, open_status) + self._init_library(path, open_status) - def init_library(self, path: Path, open_status: LibraryStatus): + def _init_library(self, path: Path, open_status: LibraryStatus): if not open_status.success: self.show_error_message( error_name=open_status.message @@ -1933,7 +1979,7 @@ class QtDriver(DriverMixin, QObject): self.init_workers() - self.filter.page_size = self.settings.page_size + self.__reset_navigation() # TODO - make this call optional if self.lib.entries_count < 10000: @@ -1973,7 +2019,7 @@ class QtDriver(DriverMixin, QObject): self.preview_panel.update_widgets() # page (re)rendering, extract eventually - self.filter_items() + self.update_browsing_state() self.main_window.toggle_landing_page(enabled=False) return open_status diff --git a/src/tagstudio/qt/widgets/tag_box.py b/src/tagstudio/qt/widgets/tag_box.py index 68ac0fc2..dbeea052 100644 --- a/src/tagstudio/qt/widgets/tag_box.py +++ b/src/tagstudio/qt/widgets/tag_box.py @@ -8,7 +8,7 @@ import typing import structlog from PySide6.QtCore import Signal -from tagstudio.core.library.alchemy.enums import FilterState +from tagstudio.core.library.alchemy.enums import BrowsingState from tagstudio.core.library.alchemy.models import Tag from tagstudio.qt.flowlayout import FlowLayout from tagstudio.qt.modals.build_tag import BuildTagPanel @@ -67,9 +67,7 @@ class TagBoxWidget(FieldWidget): tag_widget.search_for_tag_action.triggered.connect( lambda checked=False, tag_id=tag.id: ( self.driver.main_window.searchField.setText(f"tag_id:{tag_id}"), - self.driver.filter_items( - FilterState.from_tag_id(tag_id, page_size=self.driver.settings.page_size) - ), + self.driver.update_browsing_state(BrowsingState.from_tag_id(tag_id)), ) ) diff --git a/tests/macros/test_missing_files.py b/tests/macros/test_missing_files.py index bfbe0667..ba795db1 100644 --- a/tests/macros/test_missing_files.py +++ b/tests/macros/test_missing_files.py @@ -3,7 +3,7 @@ from tempfile import TemporaryDirectory import pytest -from tagstudio.core.library.alchemy.enums import FilterState +from tagstudio.core.library.alchemy.enums import BrowsingState from tagstudio.core.library.alchemy.library import Library from tagstudio.core.utils.missing_files import MissingRegistry @@ -28,5 +28,5 @@ def test_refresh_missing_files(library: Library): assert list(registry.fix_unlinked_entries()) == [0, 1] # `bar.md` should be relinked to new correct path - results = library.search_library(FilterState.from_path("bar.md", page_size=500)) + results = library.search_library(BrowsingState.from_path("bar.md"), page_size=500) assert results[0].path == Path("bar.md") diff --git a/tests/qt/test_file_path_options.py b/tests/qt/test_file_path_options.py index be78224f..c903b7f6 100644 --- a/tests/qt/test_file_path_options.py +++ b/tests/qt/test_file_path_options.py @@ -135,7 +135,7 @@ def test_title_update( qt_driver.folders_to_tags_action = QAction(menu_bar) # Trigger the update - qt_driver.init_library(library_dir, open_status) + qt_driver._init_library(library_dir, open_status) # Assert the title is updated correctly qt_driver.main_window.setWindowTitle.assert_called_with(expected_title(library_dir, base_title)) diff --git a/tests/qt/test_qt_driver.py b/tests/qt/test_qt_driver.py index 6081450b..2dcc363a 100644 --- a/tests/qt/test_qt_driver.py +++ b/tests/qt/test_qt_driver.py @@ -1,6 +1,6 @@ from typing import TYPE_CHECKING -from tagstudio.core.library.alchemy.enums import FilterState +from tagstudio.core.library.alchemy.enums import BrowsingState from tagstudio.core.library.json.library import ItemType from tagstudio.qt.widgets.item_thumb import ItemThumb @@ -66,7 +66,7 @@ if TYPE_CHECKING: # assert qt_driver.selected == [0, 1, 2] -def test_library_state_update(qt_driver: "QtDriver"): +def test_browsing_state_update(qt_driver: "QtDriver"): # Given for entry in qt_driver.lib.get_entries(with_joins=True): thumb = ItemThumb(ItemType.ENTRY, qt_driver.lib, qt_driver, (100, 100)) @@ -74,27 +74,25 @@ def test_library_state_update(qt_driver: "QtDriver"): qt_driver.frame_content.append(entry) # no filter, both items are returned - qt_driver.filter_items() + qt_driver.update_browsing_state() assert len(qt_driver.frame_content) == 2 # filter by tag - state = FilterState.from_tag_name("foo", page_size=10) - qt_driver.filter_items(state) - assert qt_driver.filter.page_size == 10 + state = BrowsingState.from_tag_name("foo") + qt_driver.update_browsing_state(state) assert len(qt_driver.frame_content) == 1 entry = qt_driver.lib.get_entry_full(qt_driver.frame_content[0]) assert list(entry.tags)[0].name == "foo" # When state is not changed, previous one is still applied - qt_driver.filter_items() - assert qt_driver.filter.page_size == 10 + qt_driver.update_browsing_state() assert len(qt_driver.frame_content) == 1 entry = qt_driver.lib.get_entry_full(qt_driver.frame_content[0]) assert list(entry.tags)[0].name == "foo" # When state property is changed, previous one is overwritten - state = FilterState.from_path("*bar.md", page_size=qt_driver.settings.page_size) - qt_driver.filter_items(state) + state = BrowsingState.from_path("*bar.md") + qt_driver.update_browsing_state(state) assert len(qt_driver.frame_content) == 1 entry = qt_driver.lib.get_entry_full(qt_driver.frame_content[0]) assert list(entry.tags)[0].name == "bar" diff --git a/tests/test_library.py b/tests/test_library.py index 498db3d0..f609a4ec 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -4,7 +4,7 @@ from tempfile import TemporaryDirectory import pytest from tagstudio.core.enums import DefaultEnum, LibraryPrefs -from tagstudio.core.library.alchemy.enums import FilterState +from tagstudio.core.library.alchemy.enums import BrowsingState from tagstudio.core.library.alchemy.fields import TextField, _FieldID from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry, Tag @@ -123,7 +123,8 @@ def test_library_search(library: Library, generate_tag, entry_full): tag = list(entry_full.tags)[0] results = library.search_library( - FilterState.from_tag_name(tag.name, page_size=500), + BrowsingState.from_tag_name(tag.name), + page_size=500, ) assert results.total_count == 1 @@ -152,7 +153,7 @@ def test_entries_count(library: Library): new_ids = library.add_entries(entries) assert len(new_ids) == 10 - results = library.search_library(FilterState.show_all(page_size=5)) + results = library.search_library(BrowsingState.show_all(), page_size=5) assert results.total_count == 12 assert len(results) == 5 @@ -199,9 +200,7 @@ def test_search_filter_extensions(library: Library, is_exclude: bool): library.set_prefs(LibraryPrefs.EXTENSION_LIST, ["md"]) # When - results = library.search_library( - FilterState.show_all(page_size=500), - ) + results = library.search_library(BrowsingState.show_all(), page_size=500) # Then assert results.total_count == 1 @@ -221,7 +220,8 @@ def test_search_library_case_insensitive(library: Library): # When results = library.search_library( - FilterState.from_tag_name(tag.name.upper(), page_size=500), + BrowsingState.from_tag_name(tag.name.upper()), + page_size=500, ) # Then @@ -443,100 +443,102 @@ def test_library_prefs_multiple_identical_vals(): def test_path_search_ilike(library: Library): - results = library.search_library(FilterState.from_path("bar.md", page_size=500)) + results = library.search_library(BrowsingState.from_path("bar.md"), page_size=500) assert results.total_count == 1 assert len(results.items) == 1 def test_path_search_like(library: Library): - results = library.search_library(FilterState.from_path("BAR.MD", page_size=500)) + results = library.search_library(BrowsingState.from_path("BAR.MD"), page_size=500) assert results.total_count == 0 assert len(results.items) == 0 def test_path_search_default_with_sep(library: Library): - results = library.search_library(FilterState.from_path("one/two", page_size=500)) + results = library.search_library(BrowsingState.from_path("one/two"), page_size=500) assert results.total_count == 1 assert len(results.items) == 1 def test_path_search_glob_after(library: Library): - results = library.search_library(FilterState.from_path("foo*", page_size=500)) + results = library.search_library(BrowsingState.from_path("foo*"), page_size=500) assert results.total_count == 1 assert len(results.items) == 1 def test_path_search_glob_in_front(library: Library): - results = library.search_library(FilterState.from_path("*bar.md", page_size=500)) + results = library.search_library(BrowsingState.from_path("*bar.md"), page_size=500) assert results.total_count == 1 assert len(results.items) == 1 def test_path_search_glob_both_sides(library: Library): - results = library.search_library(FilterState.from_path("*one/two*", page_size=500)) + results = library.search_library(BrowsingState.from_path("*one/two*"), page_size=500) assert results.total_count == 1 assert len(results.items) == 1 +# TODO: deduplicate this code with pytest parametrisation or a for loop def test_path_search_ilike_glob_equality(library: Library): - results_ilike = library.search_library(FilterState.from_path("one/two", page_size=500)) - results_glob = library.search_library(FilterState.from_path("*one/two*", page_size=500)) + results_ilike = library.search_library(BrowsingState.from_path("one/two"), page_size=500) + results_glob = library.search_library(BrowsingState.from_path("*one/two*"), page_size=500) assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items] results_ilike, results_glob = None, None - results_ilike = library.search_library(FilterState.from_path("bar.md", page_size=500)) - results_glob = library.search_library(FilterState.from_path("*bar.md*", page_size=500)) + results_ilike = library.search_library(BrowsingState.from_path("bar.md"), page_size=500) + results_glob = library.search_library(BrowsingState.from_path("*bar.md*"), page_size=500) assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items] results_ilike, results_glob = None, None - results_ilike = library.search_library(FilterState.from_path("bar", page_size=500)) - results_glob = library.search_library(FilterState.from_path("*bar*", page_size=500)) + results_ilike = library.search_library(BrowsingState.from_path("bar"), page_size=500) + results_glob = library.search_library(BrowsingState.from_path("*bar*"), page_size=500) assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items] results_ilike, results_glob = None, None - results_ilike = library.search_library(FilterState.from_path("bar.md", page_size=500)) - results_glob = library.search_library(FilterState.from_path("*bar.md*", page_size=500)) + results_ilike = library.search_library(BrowsingState.from_path("bar.md"), page_size=500) + results_glob = library.search_library(BrowsingState.from_path("*bar.md*"), page_size=500) assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items] results_ilike, results_glob = None, None +# TODO: isn't this the exact same as the one before? def test_path_search_like_glob_equality(library: Library): - results_ilike = library.search_library(FilterState.from_path("ONE/two", page_size=500)) - results_glob = library.search_library(FilterState.from_path("*ONE/two*", page_size=500)) + results_ilike = library.search_library(BrowsingState.from_path("ONE/two"), page_size=500) + results_glob = library.search_library(BrowsingState.from_path("*ONE/two*"), page_size=500) assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items] results_ilike, results_glob = None, None - results_ilike = library.search_library(FilterState.from_path("BAR.MD", page_size=500)) - results_glob = library.search_library(FilterState.from_path("*BAR.MD*", page_size=500)) + results_ilike = library.search_library(BrowsingState.from_path("BAR.MD"), page_size=500) + results_glob = library.search_library(BrowsingState.from_path("*BAR.MD*"), page_size=500) assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items] results_ilike, results_glob = None, None - results_ilike = library.search_library(FilterState.from_path("BAR.MD", page_size=500)) - results_glob = library.search_library(FilterState.from_path("*bar.md*", page_size=500)) + results_ilike = library.search_library(BrowsingState.from_path("BAR.MD"), page_size=500) + results_glob = library.search_library(BrowsingState.from_path("*bar.md*"), page_size=500) assert [e.id for e in results_ilike.items] != [e.id for e in results_glob.items] results_ilike, results_glob = None, None - results_ilike = library.search_library(FilterState.from_path("bar.md", page_size=500)) - results_glob = library.search_library(FilterState.from_path("*BAR.MD*", page_size=500)) + results_ilike = library.search_library(BrowsingState.from_path("bar.md"), page_size=500) + results_glob = library.search_library(BrowsingState.from_path("*BAR.MD*"), page_size=500) assert [e.id for e in results_ilike.items] != [e.id for e in results_glob.items] results_ilike, results_glob = None, None @pytest.mark.parametrize(["filetype", "num_of_filetype"], [("md", 1), ("txt", 1), ("png", 0)]) def test_filetype_search(library: Library, filetype, num_of_filetype): - results = library.search_library(FilterState.from_filetype(filetype, page_size=500)) + results = library.search_library(BrowsingState.from_filetype(filetype), page_size=500) assert len(results.items) == num_of_filetype @pytest.mark.parametrize(["filetype", "num_of_filetype"], [("png", 2), ("apng", 1), ("ng", 0)]) def test_filetype_return_one_filetype(file_mediatypes_library: Library, filetype, num_of_filetype): results = file_mediatypes_library.search_library( - FilterState.from_filetype(filetype, page_size=500) + BrowsingState.from_filetype(filetype), page_size=500 ) assert len(results.items) == num_of_filetype @pytest.mark.parametrize(["mediatype", "num_of_mediatype"], [("plaintext", 2), ("image", 0)]) def test_mediatype_search(library: Library, mediatype, num_of_mediatype): - results = library.search_library(FilterState.from_mediatype(mediatype, page_size=500)) + results = library.search_library(BrowsingState.from_mediatype(mediatype), page_size=500) assert len(results.items) == num_of_mediatype diff --git a/tests/test_search.py b/tests/test_search.py index f8828819..bdd94834 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -1,12 +1,12 @@ import pytest -from tagstudio.core.library.alchemy.enums import FilterState +from tagstudio.core.library.alchemy.enums import BrowsingState from tagstudio.core.library.alchemy.library import Library from tagstudio.core.query_lang.util import ParsingError def verify_count(lib: Library, query: str, count: int): - results = lib.search_library(FilterState.from_search_query(query, page_size=500)) + results = lib.search_library(BrowsingState.from_search_query(query), page_size=500) assert results.total_count == count assert len(results.items) == count @@ -136,4 +136,4 @@ def test_parent_tags(search_library: Library, query: str, count: int): ) def test_syntax(search_library: Library, invalid_query: str): with pytest.raises(ParsingError) as e_info: # noqa: F841 - search_library.search_library(FilterState.from_search_query(invalid_query, page_size=500)) + search_library.search_library(BrowsingState.from_search_query(invalid_query), page_size=500) From 25fb6883c1cf872c58a7a60d12691b5d16ff1f2c Mon Sep 17 00:00:00 2001 From: Jann Stute <46534683+Computerdores@users.noreply.github.com> Date: Wed, 4 Jun 2025 09:33:33 +0200 Subject: [PATCH 11/11] fix: proper error on unterminated quoted string (#936) * fix: minor warnings * fix: return ParsingError on unterminated quoted string --- src/tagstudio/core/library/alchemy/visitors.py | 2 +- src/tagstudio/core/query_lang/ast.py | 6 +++--- src/tagstudio/core/query_lang/tokenizer.py | 14 ++++++++------ 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/tagstudio/core/library/alchemy/visitors.py b/src/tagstudio/core/library/alchemy/visitors.py index 31a10d76..b3d173e3 100644 --- a/src/tagstudio/core/library/alchemy/visitors.py +++ b/src/tagstudio/core/library/alchemy/visitors.py @@ -147,7 +147,7 @@ class SQLBoolExpressionBuilder(BaseVisitor[ColumnElement[bool]]): # raise exception if Constraint stays unhandled raise NotImplementedError("This type of constraint is not implemented yet") - def visit_property(self, node: Property) -> None: + def visit_property(self, node: Property) -> ColumnElement[bool]: raise NotImplementedError("This should never be reached!") def visit_not(self, node: Not) -> ColumnElement[bool]: diff --git a/src/tagstudio/core/query_lang/ast.py b/src/tagstudio/core/query_lang/ast.py index 9ebab448..102203ed 100644 --- a/src/tagstudio/core/query_lang/ast.py +++ b/src/tagstudio/core/query_lang/ast.py @@ -1,6 +1,6 @@ from abc import ABC, abstractmethod from enum import Enum -from typing import Generic, TypeVar +from typing import Generic, TypeVar, Union class ConstraintType(Enum): @@ -12,7 +12,7 @@ class ConstraintType(Enum): Special = 5 @staticmethod - def from_string(text: str) -> "ConstraintType": + def from_string(text: str) -> Union["ConstraintType", None]: return { "tag": ConstraintType.Tag, "tag_id": ConstraintType.TagID, @@ -24,7 +24,7 @@ class ConstraintType(Enum): class AST: - parent: "AST" = None + parent: Union["AST", None] = None def __str__(self): class_name = self.__class__.__name__ diff --git a/src/tagstudio/core/query_lang/tokenizer.py b/src/tagstudio/core/query_lang/tokenizer.py index 07c40e7d..4970a5fe 100644 --- a/src/tagstudio/core/query_lang/tokenizer.py +++ b/src/tagstudio/core/query_lang/tokenizer.py @@ -26,19 +26,19 @@ class Token: start: int end: int - def __init__(self, type: TokenType, value: Any, start: int = None, end: int = None) -> None: + def __init__(self, type: TokenType, value: Any, start: int, end: int) -> None: self.type = type self.value = value self.start = start self.end = end @staticmethod - def from_type(type: TokenType, pos: int = None) -> "Token": + def from_type(type: TokenType, pos: int) -> "Token": return Token(type, None, pos, pos) @staticmethod - def EOF() -> "Token": # noqa: N802 - return Token.from_type(TokenType.EOF) + def EOF(pos: int) -> "Token": # noqa: N802 + return Token.from_type(TokenType.EOF, pos) def __str__(self) -> str: return f"Token({self.type}, {self.value}, {self.start}, {self.end})" # pragma: nocover @@ -50,7 +50,7 @@ class Token: class Tokenizer: text: str pos: int - current_char: str + current_char: str | None ESCAPABLE_CHARS = ["\\", '"', '"'] NOT_IN_ULITERAL = [":", " ", "[", "]", "(", ")", "=", ","] @@ -63,7 +63,7 @@ class Tokenizer: def get_next_token(self) -> Token: self.__skip_whitespace() if self.current_char is None: - return Token.EOF() + return Token.EOF(self.pos) if self.current_char in ("'", '"'): return self.__quoted_string() @@ -119,6 +119,8 @@ class Tokenizer: out = "" while escape or self.current_char != quote: + if self.current_char is None: + raise ParsingError(start, self.pos, "Unterminated quoted string") if escape: escape = False if self.current_char not in Tokenizer.ESCAPABLE_CHARS: