Compare commits

...

12 Commits

Author SHA1 Message Date
Travis Abendshien
c79086f715 Fix collation data not clearing on library close 2024-07-04 17:40:19 -07:00
Travis Abendshien
9ce07bd369 Bump version to 9.3.2 2024-07-03 17:41:21 -07:00
Theasacraft
33ee27a84f Fix small bug (#306) 2024-07-03 17:02:59 -07:00
Travis Abendshien
1204d2b7b5 Fix search ignoring case of extension list 2024-06-21 11:28:12 -07:00
Travis Abendshien
501ab1f977 Update issue templates to ask for title 2024-06-16 19:54:56 -07:00
Theasacraft
b3c01e180a Update to pyside6 version 6.7.1 (#223)
* Update to pyside6.7.1

* Fix Ruff

* Fix MyPy

* Update mypy job to also use PySide6 6.7.1

* Remove unused imports

* Add Description to class

* Ruff format

* Fix Warning in pagination.py

* Probably fix Pyside app test

* Rename CustomQPushButton to QPushButtonWrapper
also renamed custom_qbutton.py to qbutton_wrapper.py
2024-06-16 16:53:38 -07:00
Jiri
4c6ebec529 refactoring: centralize field IDs (#157)
* use enum with named fields instead of ad-hoc numbers

* move tag ids into constants file
2024-06-16 14:24:48 -07:00
Travis Abendshien
5c25666e67 Fix TypeError in folders_to_tags.py
- Additionally use proper comparison syntax
2024-06-15 13:19:28 -07:00
Lennart S
fae65bd9e9 docs: Fixed broken markdown doc link (#291) 2024-06-15 13:10:55 -07:00
Jiri
8e065ca8ac create testing library files ad-hoc (#292) 2024-06-14 10:11:00 -07:00
Travis Abendshien
82946cb0b8 Update CONTRIBUTING.md with PyTest info 2024-06-13 15:39:13 -07:00
Jiri
aa2925cde0 add pytest to CI pipeline (#286) 2024-06-13 15:29:22 -07:00
33 changed files with 351 additions and 152 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

22
.github/workflows/pytest.yaml vendored Normal file
View File

@@ -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/

1
.gitignore vendored
View File

@@ -55,6 +55,7 @@ coverage.xml
.hypothesis/
.pytest_cache/
cover/
tagstudio/tests/fixtures/library/*
# Translations
*.mo

View File

@@ -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

View File

@@ -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.

View File

@@ -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']

View File

@@ -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

View File

@@ -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

View File

@@ -1,5 +1,5 @@
VERSION: str = "9.3.1" # Major.Minor.Patch
VERSION_BRANCH: str = "Pre-Release" # Usually "" or "Pre-Release"
VERSION: str = "9.3.2" # Major.Minor.Patch
VERSION_BRANCH: str = "" # Usually "" or "Pre-Release"
# The folder & file names where TagStudio keeps its data relative to a library.
TS_FOLDER_NAME: str = ".TagStudio"
@@ -163,3 +163,6 @@ TAG_COLORS = [
"cool gray",
"olive",
]
TAG_FAVORITE = 1
TAG_ARCHIVED = 0

View File

@@ -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

View File

@@ -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__()
@@ -843,24 +843,22 @@ class Library:
def clear_internal_vars(self):
"""Clears the internal variables of the Library object."""
self.library_dir = None
self.is_legacy_library = False
# Reset Directory Data =================================================
self.library_dir = None
# Reset Entries ========================================================
self.entries.clear()
self._next_entry_id = 0
# self.filtered_entries.clear()
self._entry_id_to_index_map.clear()
self._collation_id_to_index_map.clear()
self.missing_matches = {}
self.dir_file_count = -1
self.files_not_in_library.clear()
self.missing_files.clear()
self.fixed_files.clear()
self.filename_to_entry_id_map: dict[Path, int] = {}
self.ext_list = self.default_ext_exclude_list
# Reset Tags ===========================================================
self.tags.clear()
self._next_tag_id = 1000
self._tag_strings_to_id_map = {}
@@ -868,6 +866,13 @@ class Library:
self._tag_id_to_index_map = {}
self._tag_entry_ref_map.clear()
# Reset Collations =====================================================
self.collations.clear()
self._collation_id_to_index_map.clear()
# Reset Extension List =================================================
self.ext_list = self.default_ext_exclude_list
def refresh_dir(self) -> Generator:
"""Scans a directory for files, and adds those relative filenames to internal variables."""
@@ -889,12 +894,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 +1387,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 +1544,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 +1953,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 +2039,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 +2056,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:

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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())

View File

@@ -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("&", "&&")

View File

@@ -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)

View File

@@ -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
@@ -1252,8 +1254,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.

View File

@@ -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;')

View File

@@ -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)
@@ -394,19 +395,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):
"""
@@ -475,7 +479,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:

View File

@@ -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,9 @@ 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.preview_img.is_connected = False
self.common_fields = []
self.mixed_fields = []
@@ -773,12 +771,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 +1065,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()

View File

@@ -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):

View File

@@ -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

View File

@@ -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)

View File

@@ -0,0 +1,6 @@
[
[
"<ItemType.ENTRY: 0>",
2
]
]

View File

@@ -0,0 +1,6 @@
[
[
"<ItemType.ENTRY: 0>",
1
]
]

View File

@@ -0,0 +1,4 @@
[
"{'id': 1, 'filename': 'foo.txt', 'path': '.', 'fields': [{6: [1001]}]}",
"{'id': 2, 'filename': 'bar.txt', 'path': '.', 'fields': [{6: [1000]}]}"
]

View File

@@ -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

View File

@@ -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]

View File

@@ -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
]
}
]
}
]
}