diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a30593af..97cc8dc1 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -6,6 +6,8 @@ body: - type: markdown attributes: value: | + *Please add an appropriate title for this issue.* + Before reporting, read the [documentation](https://github.com/TagStudioDev/TagStudio/blob/main/doc/index.md) and search existing [issues](https://github.com/TagStudioDev/TagStudio/issues). Validate that you are using an up-to-date version[^1], your issue might already be fixed! Questions, guidance, and usage goes in [discussions](https://github.com/TagStudioDev/TagStudio/discussions). Invalid issues will be closed. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 916fd5d9..3f98ccf0 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -6,6 +6,8 @@ body: - type: markdown attributes: value: | + *Please add an appropriate title for this feature request.* + Before suggesting, read the [documentation](https://github.com/TagStudioDev/TagStudio/blob/main/doc/index.md) and search existing [issues](https://github.com/TagStudioDev/TagStudio/issues). Validate that you are using an up-to-date version[^1], your feature might already be implemented! Questions, guidance, and usage goes in [discussions](https://github.com/TagStudioDev/TagStudio/discussions). Invalid issues will be closed. diff --git a/.github/workflows/mypy.yaml b/.github/workflows/mypy.yaml index 61e2a9bc..374a1c5e 100644 --- a/.github/workflows/mypy.yaml +++ b/.github/workflows/mypy.yaml @@ -23,8 +23,6 @@ jobs: - name: Install dependencies run: | - # pyside 6.6.3 has some issue in their .pyi files - pip install PySide6==6.6.2 pip install -r requirements.txt pip install mypy==1.10.0 mkdir tagstudio/.mypy_cache diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml new file mode 100644 index 00000000..e8a458aa --- /dev/null +++ b/.github/workflows/pytest.yaml @@ -0,0 +1,22 @@ +name: pytest + +on: [push, pull_request] + +jobs: + pytest: + name: Run tests + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-dev.txt + + - name: Run tests + run: | + pytest tagstudio/tests/ diff --git a/.gitignore b/.gitignore index f20f8b1c..4c9ce401 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ coverage.xml .hypothesis/ .pytest_cache/ cover/ +tagstudio/tests/fixtures/library/* # Translations *.mo diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 669ed460..942aa3eb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,8 +25,9 @@ Thank you so much for showing interest in contributing to TagStudio! Here are a ### Prerequisites - [Python](https://www.python.org/downloads/) 3.12 -- [Ruff](https://github.com/astral-sh/ruff) _(Included in `requirements-dev.txt`)_ -- [Mypy](https://github.com/python/mypy) _(Included in `requirements-dev.txt`)_ +- [Ruff](https://github.com/astral-sh/ruff) (Included in `requirements-dev.txt`) +- [Mypy](https://github.com/python/mypy) (Included in `requirements-dev.txt`) +- [PyTest](https://docs.pytest.org) (Included in `requirements-dev.txt`) ### Creating a Python Virtual Environment @@ -85,7 +86,7 @@ A Python linter and code formatter. Ruff uses the `pyproject.toml` as its config #### Running Locally -- Lint code with by moving into the `/tagstudio` directory with `cd tagstudio` and running `ruff --config ../pyproject.toml ` +- Lint code with by moving into the `/tagstudio` directory with `cd tagstudio` and running `ruff --config ../pyproject.toml`. - Format code with `ruff format` inside the repository directory Ruff is also available as a VS Code [extension](https://marketplace.visualstudio.com/items?itemName=charliermarsh.ruff), PyCharm [plugin](https://plugins.jetbrains.com/plugin/20574-ruff), and [more](https://docs.astral.sh/ruff/integrations/). @@ -99,19 +100,16 @@ Mypy is a static type checker for Python. It sure has a lot to say sometimes, bu - **First time only:** Move into the `/tagstudio` directory with `cd tagstudio` and run the following: - `mkdir -p .mypy_cache` - `mypy --install-types --non-interactive` -- Check code by moving into the `/tagstudio` directory with `cd tagstudio` _(if you aren't already inside)_ and running `mypy --config-file ../pyproject.toml .` _(Don't forget the `.` at the end!)_ +- Check code by moving into the `/tagstudio` directory with `cd tagstudio` _(if you aren't already inside)_ and running `mypy --config-file ../pyproject.toml .`. _(Don't forget the `.` at the end!)_ > [!CAUTION] > There's a known issue between PySide v6.6.3 and Mypy where Mypy will detect issues with the `.pyi` files inside of PySide and prematurely stop checking files. This issue is not present in PySide v6.6.2, which _should_ be compatible with everything else if you wish to try using that version in the meantime. Mypy is also available as a VS Code [extension](https://marketplace.visualstudio.com/items?itemName=matangover.mypy), PyCharm [plugin](https://plugins.jetbrains.com/plugin/11086-mypy), and [more](https://plugins.jetbrains.com/plugin/11086-mypy). -### PyTest _(Work in Progress)_ +### PyTest -> [!IMPORTANT] -> Tests are not currently run as part of any automated workflow. - -To run all the tests use `python -m pytest tests/` from the `tagstudio` folder. +- Run all tests by moving into the `/tagstudio` directory with `cd tagstudio` and running `pytest tests/`. ## Code Guidelines diff --git a/README.md b/README.md index 9378827f..592332e3 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,7 @@ As of writing (Alpha v9.3.0) the project is in a useable state, however it lacks ### What Features Are You Planning on Adding? > [!IMPORTANT] -> See the [Planned Features](/doc/planned_features.md) documentation for the latest feature lists. The lists here are currently being migrated over there with individual pages for larger features. +> See the [Planned Features](/doc/updates/planned_features.md) documentation for the latest feature lists. The lists here are currently being migrated over there with individual pages for larger features. Of the several features I have planned for the project, these are broken up into “priority” features and “future” features. Priority features were originally intended for the first public release, however are currently absent from the Alpha v9.x.x builds. diff --git a/pyproject.toml b/pyproject.toml index 1cd4b4c8..d60908da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,3 +6,4 @@ strict_optional = false disable_error_code = ["union-attr", "annotation-unchecked", "import-untyped"] explicit_package_bases = true warn_unused_ignores = true +exclude = ['tests'] diff --git a/requirements-dev.txt b/requirements-dev.txt index 7b90e16c..81de1b33 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,3 +3,4 @@ pre-commit==3.7.0 pytest==8.2.0 Pyinstaller==6.6.0 mypy==1.10.0 +syrupy==4.6.1 diff --git a/requirements.txt b/requirements.txt index 7a1b5d53..a353c70c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,12 @@ humanfriendly==10.0 opencv_python>=4.8.0.74,<=4.9.0.80 Pillow==10.3.0 -PySide6>=6.5.1.1,<=6.6.3.1 -PySide6_Addons>=6.5.1.1,<=6.6.3.1 -PySide6_Essentials>=6.5.1.1,<=6.6.3.1 +PySide6==6.7.1 +PySide6_Addons==6.7.1 +PySide6_Essentials==6.7.1 typing_extensions>=3.10.0.0,<=4.11.0 ujson>=5.8.0,<=5.9.0 +numpy==1.26.4 rawpy==0.21.0 pillow-heif==0.16.0 chardet==5.2.0 diff --git a/tagstudio/src/core/constants.py b/tagstudio/src/core/constants.py index d9a3f1d9..d07489aa 100644 --- a/tagstudio/src/core/constants.py +++ b/tagstudio/src/core/constants.py @@ -163,3 +163,6 @@ TAG_COLORS = [ "cool gray", "olive", ] + +TAG_FAVORITE = 1 +TAG_ARCHIVED = 0 diff --git a/tagstudio/src/core/enums.py b/tagstudio/src/core/enums.py index d6b274eb..7610a2cc 100644 --- a/tagstudio/src/core/enums.py +++ b/tagstudio/src/core/enums.py @@ -24,3 +24,16 @@ class SearchMode(int, enum.Enum): AND = 0 OR = 1 + + +class FieldID(int, enum.Enum): + TITLE = 0 + AUTHOR = 1 + ARTIST = 2 + DESCRIPTION = 4 + NOTES = 5 + TAGS = 6 + CONTENT_TAGS = 7 + META_TAGS = 8 + DATE_PUBLISHED = 14 + SOURCE = 21 diff --git a/tagstudio/src/core/library.py b/tagstudio/src/core/library.py index 59f49401..107b88cf 100644 --- a/tagstudio/src/core/library.py +++ b/tagstudio/src/core/library.py @@ -5,7 +5,6 @@ """The Library object and related methods for TagStudio.""" import datetime -import json import logging import os import time @@ -18,6 +17,7 @@ from pathlib import Path from typing import cast, Generator from typing_extensions import Self +from src.core.enums import FieldID from src.core.json_typing import JsonCollation, JsonEntry, JsonLibary, JsonTag from src.core.utils.str import strip_punctuation from src.core.utils.web import strip_web_protocol @@ -80,7 +80,7 @@ class Entry: # self.word_count: int = None def __str__(self) -> str: - return f"\n{self.compressed_dict()}\n" + return str(self.compressed_dict()) def __repr__(self) -> str: return self.__str__() @@ -889,12 +889,12 @@ class Library: and "tagstudio_thumbs" not in f.parts and not f.is_dir() ): - if f.suffix not in self.ext_list and self.is_exclude_list: + if f.suffix.lower() not in self.ext_list and self.is_exclude_list: self.dir_file_count += 1 file = f.relative_to(self.library_dir) if file not in self.filename_to_entry_id_map: self.files_not_in_library.append(file) - elif f.suffix in self.ext_list and not self.is_exclude_list: + elif f.suffix.lower() in self.ext_list and not self.is_exclude_list: self.dir_file_count += 1 file = f.relative_to(self.library_dir) try: @@ -1382,7 +1382,7 @@ class Library: # non_entry_count = 0 # Iterate over all Entries ============================================================= for entry in self.entries: - allowed_ext: bool = entry.filename.suffix not in self.ext_list + allowed_ext: bool = entry.filename.suffix.lower() not in self.ext_list # try: # entry: Entry = self.entries[self.file_to_library_index_map[self._source_filenames[i]]] # print(f'{entry}') @@ -1539,7 +1539,7 @@ class Library: else: for entry in self.entries: added = False - allowed_ext = entry.filename.suffix not in self.ext_list + allowed_ext = entry.filename.suffix.lower() not in self.ext_list if allowed_ext == self.is_exclude_list: for f in entry.fields: if self.get_field_attr(f, "type") == "collation": @@ -1948,48 +1948,44 @@ class Library: if data: # Add a Title Field if the data doesn't already exist. if data.get("title"): - field_id = 0 # Title Field ID - if not self.does_field_content_exist(entry_id, field_id, data["title"]): - self.add_field_to_entry(entry_id, field_id) + if not self.does_field_content_exist( + entry_id, FieldID.TITLE, data["title"] + ): + self.add_field_to_entry(entry_id, FieldID.TITLE) self.update_entry_field(entry_id, -1, data["title"], "replace") # Add an Author Field if the data doesn't already exist. if data.get("author"): - field_id = 1 # Author Field ID if not self.does_field_content_exist( - entry_id, field_id, data["author"] + entry_id, FieldID.AUTHOR, data["author"] ): - self.add_field_to_entry(entry_id, field_id) + self.add_field_to_entry(entry_id, FieldID.AUTHOR) self.update_entry_field(entry_id, -1, data["author"], "replace") # Add an Artist Field if the data doesn't already exist. if data.get("artist"): - field_id = 2 # Artist Field ID if not self.does_field_content_exist( - entry_id, field_id, data["artist"] + entry_id, FieldID.ARTIST, data["artist"] ): - self.add_field_to_entry(entry_id, field_id) + self.add_field_to_entry(entry_id, FieldID.ARTIST) self.update_entry_field(entry_id, -1, data["artist"], "replace") # Add a Date Published Field if the data doesn't already exist. if data.get("date_published"): - field_id = 14 # Date Published Field ID date = str( datetime.datetime.strptime( data["date_published"], "%Y-%m-%d %H:%M:%S" ) ) - if not self.does_field_content_exist(entry_id, field_id, date): - self.add_field_to_entry(entry_id, field_id) + if not self.does_field_content_exist( + entry_id, FieldID.DATE_PUBLISHED, date + ): + self.add_field_to_entry(entry_id, FieldID.DATE_PUBLISHED) # entry = self.entries[entry_id] self.update_entry_field(entry_id, -1, date, "replace") # Process String Tags if the data doesn't already exist. if data.get("tags"): - tags_field_id = 6 # Tags Field ID - content_tags_field_id = 7 # Content Tags Field ID - meta_tags_field_id = 8 # Meta Tags Field ID - notes_field_id = 5 # Notes Field ID tags: list[str] = data["tags"] # extra: list[str] = [] # for tag in tags: @@ -2038,7 +2034,7 @@ class Library: # tag_field_indices = self.get_field_index_in_entry( # entry_index, tags_field_id) content_tags_field_indices = self.get_field_index_in_entry( - self.get_entry(entry_id), content_tags_field_id + self.get_entry(entry_id), FieldID.CONTENT_TAGS ) # meta_tags_field_indices = self.get_field_index_in_entry( # entry_index, meta_tags_field_id) @@ -2055,45 +2051,40 @@ class Library: entry_id, priority_field_index, [matching[0]], "append" ) else: - self.add_field_to_entry(entry_id, content_tags_field_id) + self.add_field_to_entry(entry_id, FieldID.CONTENT_TAGS) self.update_entry_field( entry_id, -1, [matching[0]], "append" ) # Add all original string tags as a note. str_tags = f"Original Tags: {tags}" - if not self.does_field_content_exist( - entry_id, notes_field_id, str_tags - ): - self.add_field_to_entry(entry_id, notes_field_id) + if not self.does_field_content_exist(entry_id, FieldID.NOTES, str_tags): + self.add_field_to_entry(entry_id, FieldID.NOTES) self.update_entry_field(entry_id, -1, str_tags, "replace") # Add a Description Field if the data doesn't already exist. - if "description" in data.keys() and data["description"]: - field_id = 4 # Description Field ID + if data.get("description"): if not self.does_field_content_exist( - entry_id, field_id, data["description"] + entry_id, FieldID.DESCRIPTION, data["description"] ): - self.add_field_to_entry(entry_id, field_id) + self.add_field_to_entry(entry_id, FieldID.DESCRIPTION) self.update_entry_field( entry_id, -1, data["description"], "replace" ) - if "content" in data.keys() and data["content"]: - field_id = 4 # Description Field ID + if data.get("content"): if not self.does_field_content_exist( - entry_id, field_id, data["content"] + entry_id, FieldID.DESCRIPTION, data["content"] ): - self.add_field_to_entry(entry_id, field_id) + self.add_field_to_entry(entry_id, FieldID.DESCRIPTION) self.update_entry_field(entry_id, -1, data["content"], "replace") - if "source" in data.keys() and data["source"]: - field_id = 21 # Source Field ID + if data.get("source"): for source in data["source"].split(" "): if source and source != " ": source = strip_web_protocol(string=source) if not self.does_field_content_exist( - entry_id, field_id, source + entry_id, FieldID.SOURCE, source ): - self.add_field_to_entry(entry_id, field_id) + self.add_field_to_entry(entry_id, FieldID.SOURCE) self.update_entry_field(entry_id, -1, source, "replace") def add_field_to_entry(self, entry_id: int, field_id: int) -> None: diff --git a/tagstudio/src/core/ts_core.py b/tagstudio/src/core/ts_core.py index 2644c9fd..63ac30e6 100644 --- a/tagstudio/src/core/ts_core.py +++ b/tagstudio/src/core/ts_core.py @@ -7,6 +7,7 @@ import json import os from pathlib import Path +from enum import Enum from src.core.library import Entry, Library from src.core.constants import TS_FOLDER_NAME, TEXT_FIELDS diff --git a/tagstudio/src/qt/helpers/qbutton_wrapper.py b/tagstudio/src/qt/helpers/qbutton_wrapper.py new file mode 100644 index 00000000..ea554446 --- /dev/null +++ b/tagstudio/src/qt/helpers/qbutton_wrapper.py @@ -0,0 +1,16 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +from PySide6.QtWidgets import QPushButton + + +class QPushButtonWrapper(QPushButton): + """ + This is a customized implementation of the PySide6 QPushButton that allows to suppress the warning that is triggered + by disconnecting a signal that is not currently connected. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.is_connected = False diff --git a/tagstudio/src/qt/modals/add_field.py b/tagstudio/src/qt/modals/add_field.py index f315b86e..c0137da2 100644 --- a/tagstudio/src/qt/modals/add_field.py +++ b/tagstudio/src/qt/modals/add_field.py @@ -24,6 +24,7 @@ class AddFieldModal(QWidget): # - OR - # [Cancel] [Save] super().__init__() + self.is_connected = False self.lib = library self.setWindowTitle(f"Add Field") self.setWindowModality(Qt.WindowModality.ApplicationModal) diff --git a/tagstudio/src/qt/modals/file_extension.py b/tagstudio/src/qt/modals/file_extension.py index 00fc2607..c95b520b 100644 --- a/tagstudio/src/qt/modals/file_extension.py +++ b/tagstudio/src/qt/modals/file_extension.py @@ -110,4 +110,4 @@ class FileExtensionModal(PanelWidget): for i in range(self.table.rowCount()): ext = self.table.item(i, 0) if ext and ext.text(): - self.lib.ext_list.append(ext.text()) + self.lib.ext_list.append(ext.text().lower()) diff --git a/tagstudio/src/qt/modals/folders_to_tags.py b/tagstudio/src/qt/modals/folders_to_tags.py index a6e2c3f8..7021d59d 100644 --- a/tagstudio/src/qt/modals/folders_to_tags.py +++ b/tagstudio/src/qt/modals/folders_to_tags.py @@ -18,6 +18,7 @@ from PySide6.QtWidgets import ( QFrame, ) +from src.core.enums import FieldID from src.core.library import Library, Tag from src.core.palette import ColorType, get_tag_color from src.qt.flowlayout import FlowLayout @@ -73,13 +74,13 @@ def folders_to_tags(library: Library): tag = add_folders_to_tree(folders) if tag: if not entry.has_tag(library, tag.id): - entry.add_tag(library, tag.id, 6) + entry.add_tag(library, tag.id, FieldID.TAGS) logging.info("Done") def reverse_tag(library: Library, tag: Tag, list: list[Tag]) -> list[Tag]: - if list != None: + if list is not None: list.append(tag) else: list = [tag] @@ -144,7 +145,7 @@ def generate_preview_data(library: Library): if cut: branch["dirs"].pop(folder) - if not "tag" in branch: + if "tag" not in branch: return if branch["tag"].id == -1 or len(branch["files"]) > 0: # Needs to be first return False @@ -289,7 +290,7 @@ class TreeItem(QWidget): self.children_layout.addWidget(item) for file in data["files"]: label = QLabel() - label.setText(" -> " + file) + label.setText(" -> " + str(file)) self.children_layout.addWidget(label) if len(data["files"]) == 0 and len(data["dirs"].values()) == 0: @@ -321,7 +322,7 @@ class ModifiedTagWidget( self.bg_button = QPushButton(self) self.bg_button.setFlat(True) - if parentTag != None: + if parentTag is not None: text = f"{tag.name} ({parentTag.name})".replace("&", "&&") else: text = tag.name.replace("&", "&&") diff --git a/tagstudio/src/qt/pagination.py b/tagstudio/src/qt/pagination.py index 8d46b8ee..904dd19f 100644 --- a/tagstudio/src/qt/pagination.py +++ b/tagstudio/src/qt/pagination.py @@ -15,7 +15,7 @@ from PySide6.QtWidgets import ( QLineEdit, QSizePolicy, ) - +from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper # class NumberEdit(QLineEdit): # def __init__(self, parent=None) -> None: @@ -50,13 +50,13 @@ class Pagination(QWidget, QObject): # self.setMinimumHeight(32) # [<] ---------------------------------- - self.prev_button = QPushButton() + self.prev_button = QPushButtonWrapper() self.prev_button.setText("<") self.prev_button.setMinimumSize(self.button_size) self.prev_button.setMaximumSize(self.button_size) # --- [1] ------------------------------ - self.start_button = QPushButton() + self.start_button = QPushButtonWrapper() self.start_button.setMinimumSize(self.button_size) self.start_button.setMaximumSize(self.button_size) # self.start_button.setStyleSheet('background:cyan;') @@ -104,14 +104,14 @@ class Pagination(QWidget, QObject): self.end_ellipses.setText(". . .") # ----------------------------- [42] --- - self.end_button = QPushButton() + self.end_button = QPushButtonWrapper() self.end_button.setMinimumSize(self.button_size) self.end_button.setMaximumSize(self.button_size) # self.end_button.setMaximumHeight(self.button_size.height()) # self.end_button.setStyleSheet('background:red;') # ---------------------------------- [>] - self.next_button = QPushButton() + self.next_button = QPushButtonWrapper() self.next_button.setText(">") self.next_button.setMinimumSize(self.button_size) self.next_button.setMaximumSize(self.button_size) @@ -428,16 +428,15 @@ class Pagination(QWidget, QObject): # print(f'GOTO PAGE: {index}') self.update_buttons(self.page_count, index) - def _assign_click(self, button: QPushButton, index): - try: + def _assign_click(self, button: QPushButtonWrapper, index): + if button.is_connected: button.clicked.disconnect() - except RuntimeError: - pass button.clicked.connect(lambda checked=False, i=index: self._goto_page(i)) + button.is_connected = True def _populate_buffer_buttons(self): for i in range(max(self.buffer_page_count * 2, 5)): - button = QPushButton() + button = QPushButtonWrapper() button.setMinimumSize(self.button_size) button.setMaximumSize(self.button_size) button.setHidden(True) @@ -445,7 +444,7 @@ class Pagination(QWidget, QObject): self.start_buffer_layout.addWidget(button) for i in range(max(self.buffer_page_count * 2, 5)): - button = QPushButton() + button = QPushButtonWrapper() button.setMinimumSize(self.button_size) button.setMaximumSize(self.button_size) button.setHidden(True) diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 1bc0f721..f4357b04 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -64,6 +64,8 @@ from src.core.constants import ( TS_FOLDER_NAME, VERSION_BRANCH, VERSION, + TAG_FAVORITE, + TAG_ARCHIVED, ) from src.core.utils.web import strip_web_protocol from src.qt.flowlayout import FlowLayout @@ -1260,8 +1262,8 @@ class QtDriver(QObject): filepath = self.lib.library_dir / entry.path / entry.filename item_thumb.set_item_id(entry.id) - item_thumb.assign_archived(entry.has_tag(self.lib, 0)) - item_thumb.assign_favorite(entry.has_tag(self.lib, 1)) + item_thumb.assign_archived(entry.has_tag(self.lib, TAG_ARCHIVED)) + item_thumb.assign_favorite(entry.has_tag(self.lib, TAG_FAVORITE)) # ctrl_down = True if QGuiApplication.keyboardModifiers() else False # TODO: Change how this works. The click function # for collations a few lines down should NOT be allowed during modifier keys. diff --git a/tagstudio/src/qt/widgets/fields.py b/tagstudio/src/qt/widgets/fields.py index dfef94e2..355a0fa9 100644 --- a/tagstudio/src/qt/widgets/fields.py +++ b/tagstudio/src/qt/widgets/fields.py @@ -13,6 +13,7 @@ from PIL import Image, ImageQt from PySide6.QtCore import Qt, QEvent from PySide6.QtGui import QPixmap, QEnterEvent from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton +from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper class FieldContainer(QWidget): @@ -81,7 +82,7 @@ class FieldContainer(QWidget): self.title_layout.addStretch(2) - self.copy_button = QPushButton() + self.copy_button = QPushButtonWrapper() self.copy_button.setMinimumSize(button_size, button_size) self.copy_button.setMaximumSize(button_size, button_size) self.copy_button.setFlat(True) @@ -92,7 +93,7 @@ class FieldContainer(QWidget): self.title_layout.addWidget(self.copy_button) self.copy_button.setHidden(True) - self.edit_button = QPushButton() + self.edit_button = QPushButtonWrapper() self.edit_button.setMinimumSize(button_size, button_size) self.edit_button.setMaximumSize(button_size, button_size) self.edit_button.setFlat(True) @@ -101,7 +102,7 @@ class FieldContainer(QWidget): self.title_layout.addWidget(self.edit_button) self.edit_button.setHidden(True) - self.remove_button = QPushButton() + self.remove_button = QPushButtonWrapper() self.remove_button.setMinimumSize(button_size, button_size) self.remove_button.setMaximumSize(button_size, button_size) self.remove_button.setFlat(True) @@ -124,31 +125,30 @@ class FieldContainer(QWidget): # self.set_inner_widget(mode) def set_copy_callback(self, callback: Optional[MethodType]): - try: + if self.copy_button.is_connected: self.copy_button.clicked.disconnect() - except RuntimeError: - pass self.copy_callback = callback self.copy_button.clicked.connect(callback) + if callback is not None: + self.copy_button.is_connected = True def set_edit_callback(self, callback: Optional[MethodType]): - try: + if self.edit_button.is_connected: self.edit_button.clicked.disconnect() - except RuntimeError: - pass self.edit_callback = callback self.edit_button.clicked.connect(callback) + if callback is not None: + self.edit_button.is_connected = True def set_remove_callback(self, callback: Optional[Callable]): - try: + if self.remove_button.is_connected: self.remove_button.clicked.disconnect() - except RuntimeError: - pass self.remove_callback = callback self.remove_button.clicked.connect(callback) + self.remove_button.is_connected = True def set_inner_widget(self, widget: "FieldWidget"): # widget.setStyleSheet('background-color:green;') diff --git a/tagstudio/src/qt/widgets/item_thumb.py b/tagstudio/src/qt/widgets/item_thumb.py index effd9680..0adcb644 100644 --- a/tagstudio/src/qt/widgets/item_thumb.py +++ b/tagstudio/src/qt/widgets/item_thumb.py @@ -1,13 +1,11 @@ # Copyright (C) 2024 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio - - +import contextlib import logging import os import time import typing -from types import FunctionType from pathlib import Path from typing import Optional @@ -23,9 +21,15 @@ from PySide6.QtWidgets import ( QCheckBox, ) - +from src.core.enums import FieldID from src.core.library import ItemType, Library, Entry -from src.core.constants import AUDIO_TYPES, VIDEO_TYPES, IMAGE_TYPES +from src.core.constants import ( + AUDIO_TYPES, + VIDEO_TYPES, + IMAGE_TYPES, + TAG_FAVORITE, + TAG_ARCHIVED, +) from src.qt.flowlayout import FlowWidget from src.qt.helpers.file_opener import FileOpenerHelper from src.qt.widgets.thumb_renderer import ThumbRenderer @@ -38,9 +42,6 @@ ERROR = f"[ERROR]" WARNING = f"[WARNING]" INFO = f"[INFO]" -DEFAULT_META_TAG_FIELD = 8 -TAG_FAVORITE = 1 -TAG_ARCHIVED = 0 logging.basicConfig(format="%(message)s", level=logging.INFO) @@ -395,19 +396,22 @@ class ItemThumb(FlowWidget): def update_clickable(self, clickable: typing.Callable): """Updates attributes of a thumbnail element.""" # logging.info(f'[GUI] Updating Click Event for element {id(element)}: {id(clickable) if clickable else None}') - try: + if self.thumb_button.is_connected: self.thumb_button.clicked.disconnect() - except RuntimeError: - pass if clickable: self.thumb_button.clicked.connect(clickable) + self.thumb_button.is_connected = True def update_badges(self): if self.mode == ItemType.ENTRY: # logging.info(f'[UPDATE BADGES] ENTRY: {self.lib.get_entry(self.item_id)}') # logging.info(f'[UPDATE BADGES] ARCH: {self.lib.get_entry(self.item_id).has_tag(self.lib, 0)}, FAV: {self.lib.get_entry(self.item_id).has_tag(self.lib, 1)}') - self.assign_archived(self.lib.get_entry(self.item_id).has_tag(self.lib, 0)) - self.assign_favorite(self.lib.get_entry(self.item_id).has_tag(self.lib, 1)) + self.assign_archived( + self.lib.get_entry(self.item_id).has_tag(self.lib, TAG_ARCHIVED) + ) + self.assign_favorite( + self.lib.get_entry(self.item_id).has_tag(self.lib, TAG_FAVORITE) + ) def set_item_id(self, id: int): """ @@ -476,7 +480,7 @@ class ItemThumb(FlowWidget): entry.add_tag( self.panel.driver.lib, tag_id, - field_id=DEFAULT_META_TAG_FIELD, + field_id=FieldID.META_TAGS, field_index=-1, ) else: diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index 18289c49..4096c0d7 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -40,6 +40,7 @@ from src.qt.widgets.text import TextWidget from src.qt.widgets.panel import PanelModal from src.qt.widgets.text_box_edit import EditTextBox from src.qt.widgets.text_line_edit import EditTextLine +from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper from src.qt.widgets.video_player import VideoPlayer @@ -61,6 +62,7 @@ class PreviewPanel(QWidget): def __init__(self, library: Library, driver: "QtDriver"): super().__init__() + self.is_connected = False self.lib = library self.driver: QtDriver = driver self.initialized = False @@ -83,7 +85,7 @@ class PreviewPanel(QWidget): self.open_file_action = QAction("Open file", self) self.open_explorer_action = QAction("Open file in explorer", self) - self.preview_img = QPushButton() + self.preview_img = QPushButtonWrapper() self.preview_img.setMinimumSize(*self.img_button_size) self.preview_img.setFlat(True) self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) @@ -218,7 +220,7 @@ class PreviewPanel(QWidget): self.afb_layout = QVBoxLayout(self.afb_container) self.afb_layout.setContentsMargins(0, 12, 0, 0) - self.add_field_button = QPushButton() + self.add_field_button = QPushButtonWrapper() self.add_field_button.setCursor(Qt.CursorShape.PointingHandCursor) self.add_field_button.setMinimumSize(96, 28) self.add_field_button.setMaximumSize(96, 28) @@ -279,7 +281,9 @@ class PreviewPanel(QWidget): row_layout.addWidget(label) layout.addLayout(row_layout) - def set_button_style(btn: QPushButton, extras: list[str] | None = None): + def set_button_style( + btn: QPushButtonWrapper | QPushButton, extras: list[str] | None = None + ): base_style = [ f"background-color:{Theme.COLOR_BG.value};", "border-radius:6px;", @@ -317,7 +321,6 @@ class PreviewPanel(QWidget): button.clicked.connect(open_library_button_clicked(full_val)) set_button_style(button) - button_remove = QPushButton("➖") button_remove.setCursor(Qt.CursorShape.PointingHandCursor) button_remove.setFixedWidth(30) @@ -411,16 +414,16 @@ class PreviewPanel(QWidget): self.afb_container, Qt.AlignmentFlag.AlignHCenter ) - try: + if self.afm.is_connected: self.afm.done.disconnect() + if self.add_field_button.is_connected: self.add_field_button.clicked.disconnect() - except RuntimeError: - pass # self.afm.done.connect(lambda f: (self.lib.add_field_to_entry(self.selected[0][1], f), self.update_widgets())) self.afm.done.connect( lambda f: (self.add_field_to_selected(f), self.update_widgets()) ) + self.afm.is_connected = True self.add_field_button.clicked.connect(self.afm.show) def add_field_to_selected(self, field_id: int): @@ -466,10 +469,8 @@ class PreviewPanel(QWidget): True, update_on_ratio_change=True, ) - try: + if self.preview_img.is_connected: self.preview_img.clicked.disconnect() - except RuntimeError: - pass for i, c in enumerate(self.containers): c.setHidden(True) self.preview_img.show() @@ -588,14 +589,12 @@ class PreviewPanel(QWidget): f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})" ) - try: + if self.preview_img.is_connected: self.preview_img.clicked.disconnect() - except RuntimeError: - pass self.preview_img.clicked.connect( lambda checked=False, filepath=filepath: open_file(filepath) ) - + self.preview_img.is_connected = True self.selected = list(self.driver.selected) for i, f in enumerate(item.fields): self.write_container(i, f) @@ -641,10 +640,8 @@ class PreviewPanel(QWidget): True, update_on_ratio_change=True, ) - try: + if self.preview_img.is_connected: self.preview_img.clicked.disconnect() - except RuntimeError: - pass self.common_fields = [] self.mixed_fields = [] @@ -773,12 +770,12 @@ class PreviewPanel(QWidget): """ Replacement for tag_callback. """ - try: + if self.is_connected: self.tags_updated.disconnect() - except RuntimeError: - pass + logging.info("[UPDATE CONTAINER] Setting tags updated slot") self.tags_updated.connect(slot) + self.is_connected = True # def write_container(self, item:Union[Entry, Collation, Tag], index, field): def write_container(self, index, field, mixed=False): @@ -1067,7 +1064,8 @@ class PreviewPanel(QWidget): ) # remove_mb.setStandardButtons(QMessageBox.StandardButton.Cancel) remove_mb.setDefaultButton(cancel_button) + remove_mb.setEscapeButton(cancel_button) result = remove_mb.exec_() # logging.info(result) - if result == 1: + if result == 3: callback() diff --git a/tagstudio/src/qt/widgets/tag_box.py b/tagstudio/src/qt/widgets/tag_box.py index bcc551a8..06b8b1fe 100644 --- a/tagstudio/src/qt/widgets/tag_box.py +++ b/tagstudio/src/qt/widgets/tag_box.py @@ -10,6 +10,7 @@ import typing from PySide6.QtCore import Signal, Qt from PySide6.QtWidgets import QPushButton +from src.core.constants import TAG_FAVORITE, TAG_ARCHIVED from src.core.library import Library, Tag from src.qt.flowlayout import FlowLayout from src.qt.widgets.fields import FieldWidget @@ -141,7 +142,7 @@ class TagBoxWidget(FieldWidget): # panel.tag_updated.connect(lambda tag: self.lib.update_tag(tag)) self.edit_modal.show() - def add_tag_callback(self, tag_id): + def add_tag_callback(self, tag_id: int): # self.base_layout.addWidget(TagWidget(self.lib, self.lib.get_tag(tag), True)) # self.tags.append(tag) logging.info( @@ -154,7 +155,7 @@ class TagBoxWidget(FieldWidget): self.driver.lib, tag_id, field_id=id, field_index=-1 ) self.updated.emit() - if tag_id == 0 or tag_id == 1: + if tag_id in (TAG_FAVORITE, TAG_ARCHIVED): self.driver.update_badges() # if type((x[0]) == ThumbButton): @@ -180,7 +181,7 @@ class TagBoxWidget(FieldWidget): self.driver.lib, tag_id, field_index=index[0] ) self.updated.emit() - if tag_id == 0 or tag_id == 1: + if tag_id in (TAG_FAVORITE, TAG_ARCHIVED): self.driver.update_badges() # def show_add_button(self, value:bool): diff --git a/tagstudio/src/qt/widgets/thumb_button.py b/tagstudio/src/qt/widgets/thumb_button.py index 7878259c..179efaec 100644 --- a/tagstudio/src/qt/widgets/thumb_button.py +++ b/tagstudio/src/qt/widgets/thumb_button.py @@ -6,10 +6,11 @@ from PySide6 import QtCore from PySide6.QtCore import QEvent from PySide6.QtGui import QEnterEvent, QPainter, QColor, QPen, QPainterPath, QPaintEvent -from PySide6.QtWidgets import QWidget, QPushButton +from PySide6.QtWidgets import QWidget +from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper -class ThumbButton(QPushButton): +class ThumbButton(QPushButtonWrapper): def __init__(self, parent: QWidget, thumb_size: tuple[int, int]) -> None: super().__init__(parent) self.thumb_size: tuple[int, int] = thumb_size diff --git a/tagstudio/tests/conftest.py b/tagstudio/tests/conftest.py new file mode 100644 index 00000000..2c5cd225 --- /dev/null +++ b/tagstudio/tests/conftest.py @@ -0,0 +1,42 @@ +import sys +import pathlib + +import pytest +from syrupy.extensions.json import JSONSnapshotExtension + +CWD = pathlib.Path(__file__).parent + +sys.path.insert(0, str(CWD.parent)) + +from src.core.library import Tag, Library + + +@pytest.fixture +def test_tag(): + yield Tag( + id=1, + name="Tag Name", + shorthand="TN", + aliases=["First A", "Second A"], + subtags_ids=[2, 3, 4], + color="", + ) + + +@pytest.fixture +def test_library(): + lib_dir = CWD / "fixtures" / "library" + + lib = Library() + ret_code = lib.open_library(lib_dir) + assert ret_code == 1 + # create files for the entries + for entry in lib.entries: + (lib_dir / entry.filename).touch() + + yield lib + + +@pytest.fixture +def snapshot_json(snapshot): + return snapshot.with_defaults(extension_class=JSONSnapshotExtension) diff --git a/tagstudio/tests/core/__snapshots__/test_lib/test_library_search[--nomatch--].json b/tagstudio/tests/core/__snapshots__/test_lib/test_library_search[--nomatch--].json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/tagstudio/tests/core/__snapshots__/test_lib/test_library_search[--nomatch--].json @@ -0,0 +1 @@ +[] diff --git a/tagstudio/tests/core/__snapshots__/test_lib/test_library_search[First].json b/tagstudio/tests/core/__snapshots__/test_lib/test_library_search[First].json new file mode 100644 index 00000000..e4e6902c --- /dev/null +++ b/tagstudio/tests/core/__snapshots__/test_lib/test_library_search[First].json @@ -0,0 +1,6 @@ +[ + [ + "", + 2 + ] +] diff --git a/tagstudio/tests/core/__snapshots__/test_lib/test_library_search[Second].json b/tagstudio/tests/core/__snapshots__/test_lib/test_library_search[Second].json new file mode 100644 index 00000000..920ff495 --- /dev/null +++ b/tagstudio/tests/core/__snapshots__/test_lib/test_library_search[Second].json @@ -0,0 +1,6 @@ +[ + [ + "", + 1 + ] +] diff --git a/tagstudio/tests/core/__snapshots__/test_lib/test_open_library.json b/tagstudio/tests/core/__snapshots__/test_lib/test_open_library.json new file mode 100644 index 00000000..a576676f --- /dev/null +++ b/tagstudio/tests/core/__snapshots__/test_lib/test_open_library.json @@ -0,0 +1,4 @@ +[ + "{'id': 1, 'filename': 'foo.txt', 'path': '.', 'fields': [{6: [1001]}]}", + "{'id': 2, 'filename': 'bar.txt', 'path': '.', 'fields': [{6: [1000]}]}" +] diff --git a/tagstudio/tests/core/test_lib.py b/tagstudio/tests/core/test_lib.py new file mode 100644 index 00000000..997598f2 --- /dev/null +++ b/tagstudio/tests/core/test_lib.py @@ -0,0 +1,18 @@ +import pytest + + +def test_open_library(test_library, snapshot_json): + assert test_library.entries == snapshot_json + + +@pytest.mark.parametrize( + ["query"], + [ + ("First",), + ("Second",), + ("--nomatch--",), + ], +) +def test_library_search(test_library, query, snapshot_json): + res = test_library.search_library(query) + assert res == snapshot_json diff --git a/tagstudio/tests/core/test_tags.py b/tagstudio/tests/core/test_tags.py index e8e48754..43cde427 100644 --- a/tagstudio/tests/core/test_tags.py +++ b/tagstudio/tests/core/test_tags.py @@ -1,18 +1,8 @@ -from src.core.library import Tag +def test_subtag(test_tag): + test_tag.remove_subtag(2) + test_tag.remove_subtag(2) - -def test_construction(): - tag = Tag( - id=1, - name="Tag Name", - shorthand="TN", - aliases=["First A", "Second A"], - subtags_ids=[2, 3, 4], - color="", - ) - assert tag - - -def test_empty_construction(): - tag = Tag(id=1, name="", shorthand="", aliases=[], subtags_ids=[], color="") - assert tag + test_tag.add_subtag(5) + # repeated add should not add the subtag + test_tag.add_subtag(5) + assert test_tag.subtag_ids == [3, 4, 5] diff --git a/tagstudio/tests/fixtures/library/.TagStudio/ts_library.json b/tagstudio/tests/fixtures/library/.TagStudio/ts_library.json new file mode 100644 index 00000000..eeab9fd6 --- /dev/null +++ b/tagstudio/tests/fixtures/library/.TagStudio/ts_library.json @@ -0,0 +1,69 @@ +{ + "ts-version": "9.3.1", + "ext_list": [ + ".json", + ".xmp", + ".aae" + ], + "is_exclude_list": true, + "tags": [ + { + "id": 0, + "name": "Archived", + "aliases": [ + "Archive" + ], + "color": "Red" + }, + { + "id": 1, + "name": "Favorite", + "aliases": [ + "Favorited", + "Favorites" + ], + "color": "Yellow" + }, + { + "id": 1000, + "name": "first", + "shorthand": "first", + "color": "magenta" + }, + { + "id": 1001, + "name": "second", + "shorthand": "second", + "color": "blue" + } + ], + "collations": [], + "fields": [], + "macros": [], + "entries": [ + { + "id": 1, + "filename": "foo.txt", + "path": ".", + "fields": [ + { + "6": [ + 1001 + ] + } + ] + }, + { + "id": 2, + "filename": "bar.txt", + "path": ".", + "fields": [ + { + "6": [ + 1000 + ] + } + ] + } + ] +}