diff --git a/README.md b/README.md index ffb3cae41..f5153a658 100644 --- a/README.md +++ b/README.md @@ -71,12 +71,12 @@ Translation hosting generously provided by [Weblate](https://weblate.org/en/). C - Create libraries/vaults centered around a system directory. Libraries contain a series of entries: the representations of your files combined with metadata fields. Each entry represents a file in your library’s directory, and is linked to its location. - Address moved, deleted, or otherwise "unlinked" files by using the "Fix Unlinked Entries" option in the Tools menu. -### Metadata + Tagging +### Tagging + Custom Metadata +- Add custom powerful tags to your library entries - Add metadata to your library entries, including: - Name, Author, Artist (Single-Line Text Fields) - Description, Notes (Multiline Text Fields) - - Tags, Meta Tags, Content Tags (Tag Boxes) - Create rich tags composed of a name, a list of aliases, and a list of “parent tags” - being tags in which these tags inherit values from. - Copy and paste tags and fields across file entries - Generate tags from your existing folder structure with the "Folders to Tags" macro (NOTE: these tags do NOT sync with folders after they are created) @@ -136,9 +136,15 @@ In order to scan for new files or file changes, you’ll need to manually go to > [!NOTE] > In the future, library refreshing will also be automatically done in the background, or additionally on app startup. -### Adding Metadata to Entries +### Adding Tags to File Entries -To add a metadata field to a file entry, start by clicking the “Add Field” button under the file preview in the right-hand preview panel. From the dropdown menu, select the type of metadata field you’d like to add to the entry. +Click the "Add Tag" button at the bottom of the preview panel with one or more tags selected. Search for existing inside the new dialog popup or create a new one from here. Click the “+” button next to whichever tags you want to add. Alternatively, after you search for a tag, press the Enter/Return key to add the first item in the list. Press Enter/Return once more to close the dialog box. + +To remove a tag from a file entry, hover over the tag in the preview panel and click on the "-" icon that appears. + +### Adding Metadata to File Entries + +To add a metadata field to a file entry, start by clicking the “Add Field” button at the bottom of the preview panel. From the dropdown menu, select the type of metadata field you’d like to add to the entry ### Editing Metadata Fields @@ -146,10 +152,6 @@ To add a metadata field to a file entry, start by clicking the “Add Field” b Hover over the field and click the pencil icon. From there, add or edit text in the dialog box popup. -#### Tag Box - -Click the “+” button at the end of the Tags list, and search for tags to add inside the new dialog popup. Click the “+” button next to whichever tags you want to add. Alternatively, after you search for a tag, press the Enter/Return key to add the add the first item in the list. Press Enter/Return once more to close the dialog box - > [!WARNING] > Keyboard control and navigation is currently _very_ buggy, but will be improved in future versions. diff --git a/docs/library/tag_categories.md b/docs/library/tag_categories.md index 4f2b1ec82..59b0059a6 100644 --- a/docs/library/tag_categories.md +++ b/docs/library/tag_categories.md @@ -1,8 +1,15 @@ --- tags: - - Upcoming Feature --- -# Tag Categories +# Tag Categories (v9.5) -Replaces [Tag Fields](field.md#tag_box). Tags are able to be marked as a “category” which then displays as tag fields currently do, with any tags inheriting from that category being displayed underneath. +The "Is Category" property of tags determines if a tag should be treated as a category itself when being organized inside the preview panel. Tags marked as categories will show themselves and all tags inheriting from it (including recursively) underneath a field-like section with the tag's name. This means that duplicates of tags can appear on entries if the tag inherits from multiple parent categories, however this is by design and reflects the nature multiple inheritance. Any tags not inheriting from a category tag will simply show under a default "Tag" section. + +### Built-In Tags and Categories + +The built-in tags "Favorite" and "Archived" inherit from the built-in "Meta Tags" category which is marked as a category by default. This behavior of default tags can be fully customized by disabling the category option and/or by adding/removing the tags' Parent Tags. + +### Migrating from v9.4 Libraries + +Due to the nature of how tags and Tag Felids operated prior to v9.5, the organization style of Tag Categories vs Tag Fields is not 1:1. Instead of tags being organized into fields on a per-entry basis, tags themselves determine their organizational layout via the "Is Property" flag. Any tags _(not currently inheriting from either the "Favorite" or "Archived" tags)_ will be shown under the default "Tags" header upon migrating to the v9.5+ library format. Similar organization to Tag Fields can be achieved by using the built-in "Meta Tags" tag or any other marked with "Is Category" and then setting those tags as parents for other tags to inherit from. diff --git a/docs/updates/roadmap.md b/docs/updates/roadmap.md index 8e39b4ee9..c9075c103 100644 --- a/docs/updates/roadmap.md +++ b/docs/updates/roadmap.md @@ -34,8 +34,8 @@ Features are broken up into the following priority levels, with nested prioritie - [ ] Existing colors are now a set of base colors [HIGH] - [ ] Editable [MEDIUM] - [ ] Non-removable [HIGH] - - [ ] [Tag Categories](../library/tag_categories.md) [HIGH] - - [ ] Property available for tags that allow the tag and any inheriting from it to be displayed separately in the preview panel under a title [HIGH] + - [x] [Tag Categories](../library/tag_categories.md) [HIGH] + - [x] Property available for tags that allow the tag and any inheriting from it to be displayed separately in the preview panel under a title [HIGH] - [ ] Title is tag name [HIGH] - [ ] Title has tag color [MEDIUM] - [ ] Tag marked as category does not display as a tag itself [HIGH] @@ -170,8 +170,8 @@ These version milestones are rough estimations for when the previous core featur - [ ] Existing colors are now a set of base colors [HIGH] - [ ] Editable [MEDIUM] - [ ] Non-removable [HIGH] - - [ ] [Tag Categories](../library/tag_categories.md) [HIGH] - - [ ] Property available for tags that allow the tag and any inheriting from it to be displayed separately in the preview panel under a title [HIGH] + - [x] [Tag Categories](../library/tag_categories.md) [HIGH] + - [x] Property available for tags that allow the tag and any inheriting from it to be displayed separately in the preview panel under a title [HIGH] - [ ] Search engine [HIGH] - [x] Boolean operators [HIGH] - [ ] Tag objects + autocomplete [HIGH] diff --git a/docs/usage.md b/docs/usage.md index 450f131a0..c2d6ce2a8 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -11,9 +11,14 @@ In order to scan for new files or file changes, you’ll need to manually go to !!! note In the future, library refreshing will also be automatically done in the background, or additionally on app startup. -## Adding Metadata to Entries +## Adding Tags to File Entries +Click the "Add Tag" button at the bottom of the preview panel with one or more tags selected. Search for existing inside the new dialog popup or create a new one from. Click the “+” button next to whichever tags you want to add. Alternatively, after you search for a tag, press the Enter/Return key to add the first item in the list. Press Enter/Return once more to close the dialog box. -To add a metadata field to a file entry, start by clicking the “Add Field” button under the file preview in the right-hand preview panel. From the dropdown menu, select the type of metadata field you’d like to add to the entry. +To remove a tag from a file entry, hover over the tag in the preview panel and click on the "-" icon that appears. + +## Adding Metadata Fields to File Entries + +To add a metadata field to a file entry, start by clicking the “Add Field” button at the bottom of the preview panel. From the dropdown menu, select the type of metadata field you’d like to add to the entry. ## Editing Metadata Fields @@ -21,10 +26,6 @@ To add a metadata field to a file entry, start by clicking the “Add Field” b Hover over the field and click the pencil icon. From there, add or edit text in the dialog box popup. -### Tag Box - -Click the “+” button at the end of the Tags list, and search for tags to add inside the new dialog popup. Click the “+” button next to whichever tags you want to add. Alternatively, after you search for a tag, press the Enter/Return key to add the add the first item in the list. Press Enter/Return once more to close the dialog box - !!! warning Keyboard control and navigation is currently _very_ buggy, but will be improved in future versions. @@ -51,6 +52,10 @@ Inevitably, some of the files inside your library will be renamed, moved, or del !!! warning If multiple matches for a moved file are found (matches are currently defined as files with a matching filename as the original), TagStudio will currently ignore the match groups. Adding a GUI for manual selection, as well as smarter automated relinking, are top priorities for future versions. +## Deleting Tags + +To delete a tag from your library, go to File -> Tag Manager, hover over the tag you wish to delete, and click the "-" icon that appears. You will be prompted to make sure you wish to delete this tag from your library and across all file entries. + ## Saving the Library Libraries are saved upon exiting the program. To manually save, select File -> Save Library from the menu bar. To save a backup of your library, select File -> Save Library Backup from the menu bar. diff --git a/tagstudio/resources/translations/en.json b/tagstudio/resources/translations/en.json index bc652b84d..988fb0033 100644 --- a/tagstudio/resources/translations/en.json +++ b/tagstudio/resources/translations/en.json @@ -158,6 +158,8 @@ "menu.file.close_library": "&Close Library", "menu.file.new_library": "New Library", "menu.file.open_create_library": "&Open/Create Library", + "menu.file.open_recent_library": "Open Recent", + "menu.file.clear_recent_libraries": "Clear Recent", "menu.file.open_library": "Open Library", "menu.file.refresh_directories": "&Refresh Directories", "menu.file.save_backup": "&Save Library Backup", diff --git a/tagstudio/src/core/constants.py b/tagstudio/src/core/constants.py index 335df5530..baec4a758 100644 --- a/tagstudio/src/core/constants.py +++ b/tagstudio/src/core/constants.py @@ -1,3 +1,7 @@ +# Copyright (C) 2025 +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + VERSION: str = "9.5.0" # Major.Minor.Patch VERSION_BRANCH: str = "EXPERIMENTAL" # Usually "" or "Pre-Release" @@ -11,7 +15,12 @@ ) FONT_SAMPLE_SIZES: list[int] = [10, 15, 20] -TAG_FAVORITE = 1 +# NOTE: These were the field IDs used for the "Tags", "Content Tags", and "Meta Tags" fields inside +# the legacy JSON database. These are used to help migrate libraries from JSON to SQLite. +LEGACY_TAG_FIELD_IDS: set[int] = {6, 7, 8} + TAG_ARCHIVED = 0 +TAG_FAVORITE = 1 +TAG_META = 2 RESERVED_TAG_START = 0 RESERVED_TAG_END = 999 diff --git a/tagstudio/src/core/enums.py b/tagstudio/src/core/enums.py index 416e8c645..74cdcbe4b 100644 --- a/tagstudio/src/core/enums.py +++ b/tagstudio/src/core/enums.py @@ -1,3 +1,7 @@ +# Copyright (C) 2025 +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + import enum from typing import Any from uuid import uuid4 @@ -20,10 +24,11 @@ class Theme(str, enum.Enum): COLOR_DARK_LABEL = "#DD000000" COLOR_BG = "#65000000" - COLOR_HOVER = "#65AAAAAA" - COLOR_PRESSED = "#65EEEEEE" - COLOR_DISABLED = "#65F39CAA" - COLOR_DISABLED_BG = "#65440D12" + COLOR_HOVER = "#65444444" + COLOR_PRESSED = "#65777777" + COLOR_DISABLED_BG = "#30000000" + COLOR_FORBIDDEN = "#65F39CAA" + COLOR_FORBIDDEN_BG = "#65440D12" class OpenStatus(enum.IntEnum): @@ -65,4 +70,4 @@ class LibraryPrefs(DefaultEnum): IS_EXCLUDE_LIST = True EXTENSION_LIST: list[str] = [".json", ".xmp", ".aae"] PAGE_SIZE: int = 500 - DB_VERSION: int = 2 + DB_VERSION: int = 3 diff --git a/tagstudio/src/core/library/alchemy/db.py b/tagstudio/src/core/library/alchemy/db.py index f48ed2b5b..8cc168288 100644 --- a/tagstudio/src/core/library/alchemy/db.py +++ b/tagstudio/src/core/library/alchemy/db.py @@ -1,3 +1,7 @@ +# Copyright (C) 2025 +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + from pathlib import Path import structlog @@ -44,7 +48,10 @@ def make_tables(engine: Engine) -> None: autoincrement_val = result.scalar() if not autoincrement_val or autoincrement_val <= RESERVED_TAG_END: conn.execute( - text(f"INSERT INTO tags (id, name, color) VALUES ({RESERVED_TAG_END}, 'temp', 1)") + text( + "INSERT INTO tags (id, name, color, is_category) VALUES " + f"({RESERVED_TAG_END}, 'temp', 1, false)" + ) ) conn.execute(text(f"DELETE FROM tags WHERE id = {RESERVED_TAG_END}")) conn.commit() diff --git a/tagstudio/src/core/library/alchemy/fields.py b/tagstudio/src/core/library/alchemy/fields.py index d0252ad9b..4f0b33b32 100644 --- a/tagstudio/src/core/library/alchemy/fields.py +++ b/tagstudio/src/core/library/alchemy/fields.py @@ -1,3 +1,7 @@ +# Copyright (C) 2025 +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + from __future__ import annotations from dataclasses import dataclass, field @@ -11,7 +15,7 @@ from .enums import FieldTypeEnum if TYPE_CHECKING: - from .models import Entry, Tag, ValueType + from .models import Entry, ValueType class BaseField(Base): @@ -75,33 +79,11 @@ def __key(self) -> tuple: def __eq__(self, value) -> bool: if isinstance(value, TextField): return self.__key() == value.__key() - elif isinstance(value, (TagBoxField, DatetimeField)): + elif isinstance(value, DatetimeField): return False raise NotImplementedError -class TagBoxField(BaseField): - __tablename__ = "tag_box_fields" - - tags: Mapped[set[Tag]] = relationship(secondary="tag_fields") - - def __key(self): - return ( - self.entry_id, - self.type_key, - ) - - @property - def value(self) -> None: - """For interface compatibility with other field types.""" - return None - - def __eq__(self, value) -> bool: - if isinstance(value, TagBoxField): - return self.__key() == value.__key() - raise NotImplementedError - - class DatetimeField(BaseField): __tablename__ = "datetime_fields" @@ -133,9 +115,6 @@ class _FieldID(Enum): URL = DefaultField(id=3, name="URL", type=FieldTypeEnum.TEXT_LINE) DESCRIPTION = DefaultField(id=4, name="Description", type=FieldTypeEnum.TEXT_LINE) NOTES = DefaultField(id=5, name="Notes", type=FieldTypeEnum.TEXT_BOX) - TAGS = DefaultField(id=6, name="Tags", type=FieldTypeEnum.TAGS) - TAGS_CONTENT = DefaultField(id=7, name="Content Tags", type=FieldTypeEnum.TAGS, is_default=True) - TAGS_META = DefaultField(id=8, name="Meta Tags", type=FieldTypeEnum.TAGS, is_default=True) COLLATION = DefaultField(id=9, name="Collation", type=FieldTypeEnum.TEXT_LINE) DATE = DefaultField(id=10, name="Date", type=FieldTypeEnum.DATETIME) DATE_CREATED = DefaultField(id=11, name="Date Created", type=FieldTypeEnum.DATETIME) diff --git a/tagstudio/src/core/library/alchemy/joins.py b/tagstudio/src/core/library/alchemy/joins.py index 71dddb81c..f8b569f8d 100644 --- a/tagstudio/src/core/library/alchemy/joins.py +++ b/tagstudio/src/core/library/alchemy/joins.py @@ -1,18 +1,22 @@ +# Copyright (C) 2025 +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column from .db import Base -class TagSubtag(Base): - __tablename__ = "tag_subtags" +class TagParent(Base): + __tablename__ = "tag_parents" parent_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True) child_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True) -class TagField(Base): - __tablename__ = "tag_fields" +class TagEntry(Base): + __tablename__ = "tag_entries" - field_id: Mapped[int] = mapped_column(ForeignKey("tag_box_fields.id"), primary_key=True) tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True) + entry_id: Mapped[int] = mapped_column(ForeignKey("entries.id"), primary_key=True) diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index d2fb77beb..37b58d050 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -1,3 +1,7 @@ +# Copyright (C) 2025 +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + import re import shutil import time @@ -7,8 +11,8 @@ from datetime import UTC, datetime from os import makedirs from pathlib import Path -from typing import Any from uuid import uuid4 +from warnings import catch_warnings import structlog from humanfriendly import format_timespan @@ -32,6 +36,7 @@ from sqlalchemy.orm import ( Session, contains_eager, + joinedload, make_transient, selectinload, ) @@ -39,8 +44,12 @@ from ...constants import ( BACKUP_FOLDER_NAME, + LEGACY_TAG_FIELD_IDS, + RESERVED_TAG_END, + RESERVED_TAG_START, TAG_ARCHIVED, TAG_FAVORITE, + TAG_META, TS_FOLDER_NAME, ) from ...enums import LibraryPrefs @@ -49,11 +58,10 @@ from .fields import ( BaseField, DatetimeField, - TagBoxField, TextField, _FieldID, ) -from .joins import TagField, TagSubtag +from .joins import TagEntry, TagParent from .models import Entry, Folder, Preferences, Tag, TagAlias, ValueType from .visitors import SQLBoolExpressionBuilder @@ -74,13 +82,19 @@ def slugify(input_string: str) -> str: def get_default_tags() -> tuple[Tag, ...]: + meta_tag = Tag( + id=TAG_META, + name="Meta Tags", + aliases={TagAlias(name="Meta"), TagAlias(name="Meta Tag")}, + is_category=True, + ) archive_tag = Tag( id=TAG_ARCHIVED, name="Archived", aliases={TagAlias(name="Archive")}, + parent_tags={meta_tag}, color=TagColor.RED, ) - favorite_tag = Tag( id=TAG_FAVORITE, name="Favorite", @@ -88,10 +102,15 @@ def get_default_tags() -> tuple[Tag, ...]: TagAlias(name="Favorited"), TagAlias(name="Favorites"), }, + parent_tags={meta_tag}, color=TagColor.YELLOW, ) - return archive_tag, favorite_tag + return archive_tag, favorite_tag, meta_tag + + +# The difference in the number of default JSON tags vs default tags in the current version. +DEFAULT_TAG_DIFF: int = len(get_default_tags()) - len([TAG_ARCHIVED, TAG_FAVORITE]) @dataclass(frozen=True) @@ -168,18 +187,30 @@ def migrate_json_to_sqlite(self, json_lib: JsonLibrary): color=TagColor.get_color_from_str(tag.color), ) ) + # Apply user edits to built-in JSON tags. + if tag.id in range(RESERVED_TAG_START, RESERVED_TAG_END + 1): + updated_tag = self.get_tag(tag.id) + updated_tag.color = TagColor.get_color_from_str(tag.color) + self.update_tag(updated_tag) # NOTE: This just calls add_tag? # Tag Aliases for tag in json_lib.tags: for alias in tag.aliases: if not alias: break - self.add_alias(name=alias, tag_id=tag.id) + # Only add new (user-created) aliases to the default tags. + # This prevents pre-existing built-in aliases from being added as duplicates. + if tag.id in range(RESERVED_TAG_START, RESERVED_TAG_END + 1): + for dt in get_default_tags(): + if dt.id == tag.id and alias not in dt.alias_strings: + self.add_alias(name=alias, tag_id=tag.id) + else: + self.add_alias(name=alias, tag_id=tag.id) - # Tag Subtags + # Parent Tags (Previously known as "Subtags" in JSON) for tag in json_lib.tags: - for subtag_id in tag.subtag_ids: - self.add_subtag(parent_id=tag.id, child_id=subtag_id) + for child_id in tag.subtag_ids: + self.add_parent_tag(parent_id=tag.id, child_id=child_id) # Entries self.add_entries( @@ -196,11 +227,15 @@ def migrate_json_to_sqlite(self, json_lib: JsonLibrary): for entry in json_lib.entries: for field in entry.fields: for k, v in field.items(): - self.add_entry_field_type( - entry_ids=(entry.id + 1), # JSON IDs start at 0 instead of 1 - field_id=self.get_field_name_from_id(k), - value=v, - ) + # Old tag fields get added as tags + if k in LEGACY_TAG_FIELD_IDS: + self.add_tags_to_entry(entry_id=entry.id + 1, tag_ids=v) + else: + self.add_field_to_entry( + entry_id=(entry.id + 1), # JSON IDs start at 0 instead of 1 + field_id=self.get_field_name_from_id(k), + value=v, + ) # Preferences self.set_prefs(LibraryPrefs.EXTENSION_LIST, [x.strip(".") for x in json_lib.ext_list]) @@ -236,9 +271,7 @@ def open_library(self, library_dir: Path, storage_path: str | None = None) -> Li return self.open_sqlite_library(library_dir, is_new) - def open_sqlite_library( - self, library_dir: Path, is_new: bool, add_default_data: bool = True - ) -> LibraryStatus: + def open_sqlite_library(self, library_dir: Path, is_new: bool) -> LibraryStatus: connection_string = URL.create( drivername="sqlite", database=str(self.storage_path), @@ -259,13 +292,13 @@ def open_sqlite_library( with Session(self.engine) as session: make_tables(self.engine) - if add_default_data: + # Add default tags to new libraries only. + if is_new: tags = get_default_tags() try: session.add_all(tags) session.commit() except IntegrityError: - # default tags may exist already session.rollback() # dont check db version when creating new library @@ -284,12 +317,13 @@ def open_sqlite_library( ) for pref in LibraryPrefs: - try: - session.add(Preferences(key=pref.name, value=pref.default)) - session.commit() - except IntegrityError: - logger.debug("preference already exists", pref=pref) - session.rollback() + with catch_warnings(record=True): + try: + session.add(Preferences(key=pref.name, value=pref.default)) + session.commit() + except IntegrityError: + logger.debug("preference already exists", pref=pref) + session.rollback() for field in _FieldID: try: @@ -359,43 +393,6 @@ def delete_item(self, item): session.delete(item) session.commit() - def remove_field_tag(self, entry: Entry, tag_id: int, field_key: str) -> bool: - assert isinstance(field_key, str), f"field_key is {type(field_key)}" - with Session(self.engine) as session: - # find field matching entry and field_type - field = session.scalars( - select(TagBoxField).where( - and_( - TagBoxField.entry_id == entry.id, - TagBoxField.type_key == field_key, - ) - ) - ).first() - - if not field: - logger.error("no field found", entry=entry, field=field) - return False - - try: - # find the record in `TagField` table and delete it - tag_field = session.scalars( - select(TagField).where( - and_( - TagField.tag_id == tag_id, - TagField.field_id == field.id, - ) - ) - ).first() - if tag_field: - session.delete(tag_field) - session.commit() - - return True - except IntegrityError as e: - logger.exception(e) - session.rollback() - return False - def get_entry(self, entry_id: int) -> Entry | None: """Load entry without joins.""" with Session(self.engine) as session: @@ -406,28 +403,61 @@ def get_entry(self, entry_id: int) -> Entry | None: make_transient(entry) return entry - def get_entry_full(self, entry_id: int) -> Entry | None: - """Load entry an join with all joins and all tags.""" + def get_entry_full( + self, entry_id: int, with_fields: bool = True, with_tags: bool = True + ) -> Entry | None: + """Load entry and join with all joins and all tags.""" with Session(self.engine) as session: statement = select(Entry).where(Entry.id == entry_id) + if with_fields: + statement = ( + statement.outerjoin(Entry.text_fields) + .outerjoin(Entry.datetime_fields) + .options(selectinload(Entry.text_fields), selectinload(Entry.datetime_fields)) + ) + if with_tags: + statement = ( + statement.outerjoin(Entry.tags) + .outerjoin(TagAlias) + .options( + selectinload(Entry.tags).options( + joinedload(Tag.aliases), + joinedload(Tag.parent_tags), + ) + ) + ) + entry = session.scalar(statement) + if not entry: + return None + session.expunge(entry) + make_transient(entry) + return entry + + def get_entries_full(self, entry_ids: list[int] | set[int]) -> Iterator[Entry]: + """Load entry and join with all joins and all tags.""" + with Session(self.engine) as session: + statement = select(Entry).where(Entry.id.in_(set(entry_ids))) statement = ( statement.outerjoin(Entry.text_fields) .outerjoin(Entry.datetime_fields) - .outerjoin(Entry.tag_box_fields) + .outerjoin(Entry.tags) ) statement = statement.options( selectinload(Entry.text_fields), selectinload(Entry.datetime_fields), - selectinload(Entry.tag_box_fields) - .joinedload(TagBoxField.tags) - .options(selectinload(Tag.aliases), selectinload(Tag.subtags)), + selectinload(Entry.tags).options( + selectinload(Tag.aliases), + selectinload(Tag.parent_tags), + ), ) - entry = session.scalar(statement) - if not entry: - return None - session.expunge(entry) - make_transient(entry) - return entry + statement = statement.distinct() + + entries = session.execute(statement).scalars() + entries = entries.unique() + + for entry in entries: + yield entry + session.expunge(entry) @property def entries_count(self) -> int: @@ -443,12 +473,12 @@ def get_entries(self, with_joins: bool = False) -> Iterator[Entry]: stmt = ( stmt.outerjoin(Entry.text_fields) .outerjoin(Entry.datetime_fields) - .outerjoin(Entry.tag_box_fields) + .outerjoin(Entry.tags) ) stmt = stmt.options( contains_eager(Entry.text_fields), contains_eager(Entry.datetime_fields), - contains_eager(Entry.tag_box_fields).selectinload(TagBoxField.tags), + contains_eager(Entry.tags), ) stmt = stmt.distinct() @@ -464,8 +494,8 @@ def get_entries(self, with_joins: bool = False) -> Iterator[Entry]: @property def tags(self) -> list[Tag]: with Session(self.engine) as session: - # load all tags and join subtags - tags_query = select(Tag).options(selectinload(Tag.subtags)) + # load all tags and join parent tags + tags_query = select(Tag).options(selectinload(Tag.parent_tags)) tags = session.scalars(tags_query).unique() tags_list = list(tags) @@ -547,13 +577,8 @@ def search_library( if search.ast: start_time = time.time() - - statement = statement.outerjoin(Entry.tag_box_fields).where( - SQLBoolExpressionBuilder(self).visit(search.ast) - ) - + statement = statement.where(SQLBoolExpressionBuilder(self).visit(search.ast)) end_time = time.time() - logger.info( f"SQL Expression Builder finished ({format_timespan(end_time - start_time)})" ) @@ -566,16 +591,7 @@ def search_library( elif extensions: statement = statement.where(Entry.suffix.in_(extensions)) - statement = statement.options( - selectinload(Entry.text_fields), - selectinload(Entry.datetime_fields), - selectinload(Entry.tag_box_fields) - .joinedload(TagBoxField.tags) - .options(selectinload(Tag.aliases), selectinload(Tag.subtags)), - ) - statement = statement.distinct(Entry.id) - query_count = select(func.count()).select_from(statement.alias("entries")) count_all: int = session.execute(query_count).scalar() @@ -585,7 +601,6 @@ def search_library( sort_on = Entry.id statement = statement.order_by(asc(sort_on) if search.ascending else desc(sort_on)) - statement = statement.limit(search.limit).offset(search.offset) logger.info( @@ -613,7 +628,7 @@ def search_tags( with Session(self.engine) as session: query = select(Tag) query = query.options( - selectinload(Tag.subtags), + selectinload(Tag.parent_tags), selectinload(Tag.aliases), ).limit(tag_limit) @@ -640,24 +655,6 @@ def search_tags( return res - def get_all_child_tag_ids(self, tag_id: int) -> list[int]: - """Recursively traverse a Tag's subtags and return a list of all children tags.""" - all_subtags: set[int] = {tag_id} - - with Session(self.engine) as session: - tag = session.scalar(select(Tag).where(Tag.id == tag_id)) - if tag is None: - raise ValueError(f"No tag found with id {tag_id}.") - - subtag_ids = tag.subtag_ids - - all_subtags.update(subtag_ids) - - for sub_id in subtag_ids: - all_subtags.update(self.get_all_child_tag_ids(sub_id)) - - return list(all_subtags) - def update_entry_path(self, entry_id: int | Entry, path: Path) -> None: if isinstance(entry_id, Entry): entry_id = entry_id.id @@ -679,11 +676,11 @@ def update_entry_path(self, entry_id: int | Entry, path: Path) -> None: def remove_tag(self, tag: Tag): with Session(self.engine, expire_on_commit=False) as session: try: - subtags = session.scalars( - select(TagSubtag).where(TagSubtag.parent_id == tag.id) + child_tags = session.scalars( + select(TagParent).where(TagParent.child_id == tag.id) ).all() tags_query = select(Tag).options( - selectinload(Tag.subtags), selectinload(Tag.aliases) + selectinload(Tag.parent_tags), selectinload(Tag.aliases) ) tag = session.scalar(tags_query.where(Tag.id == tag.id)) aliases = session.scalars(select(TagAlias).where(TagAlias.tag_id == tag.id)) @@ -691,9 +688,9 @@ def remove_tag(self, tag: Tag): for alias in aliases or []: session.delete(alias) - for subtag in subtags or []: - session.delete(subtag) - session.expunge(subtag) + for child_tag in child_tags or []: + session.delete(child_tag) + session.expunge(child_tag) session.delete(tag) session.commit() @@ -707,16 +704,6 @@ def remove_tag(self, tag: Tag): return None - def remove_tag_from_field(self, tag: Tag, field: TagBoxField) -> None: - with Session(self.engine) as session: - field_ = session.scalars(select(TagBoxField).where(TagBoxField.id == field.id)).one() - - tag = session.scalars(select(Tag).where(Tag.id == tag.id)).one() - - field_.tags.remove(tag) - session.add(field_) - session.commit() - def update_field_position( self, field_class: type[BaseField], @@ -786,7 +773,7 @@ def update_entry_field( self, entry_ids: list[int] | int, field: BaseField, - content: str | datetime | set[Tag], + content: str | datetime, ): if isinstance(entry_ids, int): entry_ids = [entry_ids] @@ -820,17 +807,17 @@ def get_value_type(self, field_key: str) -> ValueType: session.expunge(field) return field - def add_entry_field_type( + def add_field_to_entry( self, - entry_ids: list[int] | int, + entry_id: int, *, field: ValueType | None = None, field_id: _FieldID | str | None = None, - value: str | datetime | list[int] | None = None, + value: str | datetime | None = None, ) -> bool: logger.info( "add_field_to_entry", - entry_ids=entry_ids, + entry_id=entry_id, field_type=field, field_id=field_id, value=value, @@ -838,32 +825,17 @@ def add_entry_field_type( # supply only instance or ID, not both assert bool(field) != (field_id is not None) - if isinstance(entry_ids, int): - entry_ids = [entry_ids] - if not field: if isinstance(field_id, _FieldID): field_id = field_id.name field = self.get_value_type(field_id) - field_model: TextField | DatetimeField | TagBoxField + field_model: TextField | DatetimeField if field.type in (FieldTypeEnum.TEXT_LINE, FieldTypeEnum.TEXT_BOX): field_model = TextField( type_key=field.key, value=value or "", ) - elif field.type == FieldTypeEnum.TAGS: - field_model = TagBoxField( - type_key=field.key, - ) - - if value: - assert isinstance(value, list) - with Session(self.engine) as session: - for tag_id in list(set(value)): - tag = session.scalar(select(Tag).where(Tag.id == tag_id)) - field_model.tags.add(tag) - session.flush() elif field.type == FieldTypeEnum.DATETIME: field_model = DatetimeField( @@ -875,11 +847,9 @@ def add_entry_field_type( with Session(self.engine) as session: try: - for entry_id in entry_ids: - field_model.entry_id = entry_id - session.add(field_model) - session.flush() - + field_model.entry_id = entry_id + session.add(field_model) + session.flush() session.commit() except IntegrityError as e: logger.exception(e) @@ -891,7 +861,7 @@ def add_entry_field_type( self.update_field_position( field_class=type(field_model), field_type=field.key, - entry_ids=entry_ids, + entry_ids=entry_id, ) return True @@ -920,7 +890,7 @@ def tag_from_strings(self, strings: list[str] | str) -> list[int]: def add_tag( self, tag: Tag, - subtag_ids: list[int] | set[int] | None = None, + parent_ids: list[int] | set[int] | None = None, alias_names: list[str] | set[str] | None = None, alias_ids: list[int] | set[int] | None = None, ) -> Tag | None: @@ -929,8 +899,8 @@ def add_tag( session.add(tag) session.flush() - if subtag_ids is not None: - self.update_subtags(tag, subtag_ids, session) + if parent_ids is not None: + self.update_parent_tags(tag, parent_ids, session) if alias_ids is not None and alias_names is not None: self.update_aliases(tag, alias_ids, alias_names, session) @@ -944,59 +914,44 @@ def add_tag( session.rollback() return None - def add_field_tag( - self, - entry: Entry, - tag: Tag, - field_key: str = _FieldID.TAGS.name, - create_field: bool = False, - ) -> bool: - assert isinstance(field_key, str), f"field_key is {type(field_key)}" - - with Session(self.engine) as session: - # find field matching entry and field_type - field = session.scalars( - select(TagBoxField).where( - and_( - TagBoxField.entry_id == entry.id, - TagBoxField.type_key == field_key, - ) - ) - ).first() - - if not field and not create_field: - logger.error("no field found", entry=entry, field_key=field_key) - return False - + def add_tags_to_entry(self, entry_id: int, tag_ids: int | list[int] | set[int]) -> bool: + """Add one or more tags to an entry.""" + tag_ids_ = [tag_ids] if isinstance(tag_ids, int) else tag_ids + with Session(self.engine, expire_on_commit=False) as session: try: - if not field: - field = TagBoxField( - type_key=field_key, - entry_id=entry.id, - position=0, - ) - session.add(field) + # TODO: Optimize this by using a single query to update. + for tag_id in tag_ids_: + session.add(TagEntry(tag_id=tag_id, entry_id=entry_id)) session.flush() - - # create record for `TagField` table - if not tag.id: - session.add(tag) - session.flush() - - tag_field = TagField( - tag_id=tag.id, - field_id=field.id, - ) - - session.add(tag_field) session.commit() - logger.info("tag added to field", tag=tag, field=field, entry_id=entry.id) + return True + except IntegrityError as e: + logger.warning("[add_tags_to_entry]", warning=e) + session.rollback() + return False + def remove_tags_from_entry(self, entry_id: int, tag_ids: int | list[int] | set[int]) -> bool: + """Remove one or more tags from an entry.""" + tag_ids_ = [tag_ids] if isinstance(tag_ids, int) else tag_ids + with Session(self.engine, expire_on_commit=False) as session: + try: + for tag_id in tag_ids_: + tag_entry = session.scalars( + select(TagEntry).where( + and_( + TagEntry.tag_id == tag_id, + TagEntry.entry_id == entry_id, + ) + ) + ).first() + if tag_entry: + session.delete(tag_entry) + session.commit() + session.commit() return True except IntegrityError as e: logger.exception(e) session.rollback() - return False def save_library_backup_to_disk(self) -> Path: @@ -1016,12 +971,14 @@ def save_library_backup_to_disk(self) -> Path: def get_tag(self, tag_id: int) -> Tag: with Session(self.engine) as session: - tags_query = select(Tag).options(selectinload(Tag.subtags), selectinload(Tag.aliases)) + tags_query = select(Tag).options( + selectinload(Tag.parent_tags), selectinload(Tag.aliases) + ) tag = session.scalar(tags_query.where(Tag.id == tag_id)) session.expunge(tag) - for subtag in tag.subtags: - session.expunge(subtag) + for parent in tag.parent_tags: + session.expunge(parent) for alias in tag.aliases: session.expunge(alias) @@ -1044,19 +1001,19 @@ def get_alias(self, tag_id: int, alias_id: int) -> TagAlias: return alias - def add_subtag(self, parent_id: int, child_id: int) -> bool: + def add_parent_tag(self, parent_id: int, child_id: int) -> bool: if parent_id == child_id: return False # open session and save as parent tag with Session(self.engine) as session: - subtag = TagSubtag( + parent_tag = TagParent( parent_id=parent_id, child_id=child_id, ) try: - session.add(subtag) + session.add(parent_tag) session.commit() return True except IntegrityError: @@ -1083,11 +1040,11 @@ def add_alias(self, name: str, tag_id: int) -> bool: logger.exception("IntegrityError") return False - def remove_subtag(self, base_id: int, remove_tag_id: int) -> bool: + def remove_parent_tag(self, base_id: int, remove_tag_id: int) -> bool: with Session(self.engine) as session: p_id = base_id r_id = remove_tag_id - remove = session.query(TagSubtag).filter_by(parent_id=p_id, child_id=r_id).one() + remove = session.query(TagParent).filter_by(parent_id=p_id, child_id=r_id).one() session.delete(remove) session.commit() @@ -1096,12 +1053,12 @@ def remove_subtag(self, base_id: int, remove_tag_id: int) -> bool: def update_tag( self, tag: Tag, - subtag_ids: list[int] | set[int] | None = None, + parent_ids: list[int] | set[int] | None = None, alias_names: list[str] | set[str] | None = None, alias_ids: list[int] | set[int] | None = None, ) -> None: """Edit a Tag in the Library.""" - self.add_tag(tag, subtag_ids, alias_names, alias_ids) + self.add_tag(tag, parent_ids, alias_names, alias_ids) def update_aliases(self, tag, alias_ids, alias_names, session): prev_aliases = session.scalars(select(TagAlias).where(TagAlias.tag_id == tag.id)).all() @@ -1117,35 +1074,37 @@ def update_aliases(self, tag, alias_ids, alias_names, session): alias = TagAlias(alias_name, tag.id) session.add(alias) - def update_subtags(self, tag, subtag_ids, session): - if tag.id in subtag_ids: - subtag_ids.remove(tag.id) + def update_parent_tags(self, tag, parent_ids, session): + if tag.id in parent_ids: + parent_ids.remove(tag.id) - # load all tag's subtag to know which to remove - prev_subtags = session.scalars(select(TagSubtag).where(TagSubtag.parent_id == tag.id)).all() + # load all tag's parent tags to know which to remove + prev_parent_tags = session.scalars( + select(TagParent).where(TagParent.parent_id == tag.id) + ).all() - for subtag in prev_subtags: - if subtag.child_id not in subtag_ids: - session.delete(subtag) + for parent_tag in prev_parent_tags: + if parent_tag.child_id not in parent_ids: + session.delete(parent_tag) else: # no change, remove from list - subtag_ids.remove(subtag.child_id) + parent_ids.remove(parent_tag.child_id) # create remaining items - for subtag_id in subtag_ids: - # add new subtag - subtag = TagSubtag( + for parent_id in parent_ids: + # add new parent tag + parent_tag = TagParent( parent_id=tag.id, - child_id=subtag_id, + child_id=parent_id, ) - session.add(subtag) + session.add(parent_tag) - def prefs(self, key: LibraryPrefs) -> Any: + def prefs(self, key: LibraryPrefs): # load given item from Preferences table with Session(self.engine) as session: return session.scalar(select(Preferences).where(Preferences.key == key.name)).value - def set_prefs(self, key: LibraryPrefs, value: Any) -> None: + def set_prefs(self, key: LibraryPrefs, value) -> None: # set given item in Preferences table with Session(self.engine) as session: # load existing preference and update value @@ -1168,8 +1127,8 @@ def mirror_entry_fields(self, *entries: Entry) -> None: for entry in entries: for field_key, field in fields.items(): if field_key not in existing_fields: - self.add_entry_field_type( - entry_ids=entry.id, + self.add_field_to_entry( + entry_id=entry.id, field_id=field.type_key, value=field.value, ) diff --git a/tagstudio/src/core/library/alchemy/models.py b/tagstudio/src/core/library/alchemy/models.py index 1c06e0fd3..b6ffd2e49 100644 --- a/tagstudio/src/core/library/alchemy/models.py +++ b/tagstudio/src/core/library/alchemy/models.py @@ -1,3 +1,7 @@ +# Copyright (C) 2025 +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + from pathlib import Path from sqlalchemy import JSON, ForeignKey, Integer, event @@ -11,20 +15,16 @@ BooleanField, DatetimeField, FieldTypeEnum, - TagBoxField, TextField, - _FieldID, ) -from .joins import TagSubtag +from .joins import TagParent class TagAlias(Base): __tablename__ = "tag_aliases" id: Mapped[int] = mapped_column(primary_key=True) - name: Mapped[str] = mapped_column(nullable=False) - tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id")) tag: Mapped["Tag"] = relationship(back_populates="aliases") @@ -46,27 +46,21 @@ class Tag(Base): name: Mapped[str] shorthand: Mapped[str | None] color: Mapped[TagColor] + is_category: Mapped[bool] icon: Mapped[str | None] aliases: Mapped[set[TagAlias]] = relationship(back_populates="tag") parent_tags: Mapped[set["Tag"]] = relationship( - secondary=TagSubtag.__tablename__, - primaryjoin="Tag.id == TagSubtag.child_id", - secondaryjoin="Tag.id == TagSubtag.parent_id", - back_populates="subtags", - ) - - subtags: Mapped[set["Tag"]] = relationship( - secondary=TagSubtag.__tablename__, - primaryjoin="Tag.id == TagSubtag.parent_id", - secondaryjoin="Tag.id == TagSubtag.child_id", + secondary=TagParent.__tablename__, + primaryjoin="Tag.id == TagParent.parent_id", + secondaryjoin="Tag.id == TagParent.child_id", back_populates="parent_tags", ) @property - def subtag_ids(self) -> list[int]: - return [tag.id for tag in self.subtags] + def parent_ids(self) -> list[int]: + return [tag.id for tag in self.parent_tags] @property def alias_strings(self) -> list[str]: @@ -83,17 +77,17 @@ def __init__( shorthand: str | None = None, aliases: set[TagAlias] | None = None, parent_tags: set["Tag"] | None = None, - subtags: set["Tag"] | None = None, icon: str | None = None, color: TagColor = TagColor.DEFAULT, + is_category: bool = False, ): self.name = name self.aliases = aliases or set() self.parent_tags = parent_tags or set() - self.subtags = subtags or set() self.color = color self.icon = icon self.shorthand = shorthand + self.is_category = is_category assert not self.id self.id = id super().__init__() @@ -104,6 +98,18 @@ def __str__(self) -> str: def __repr__(self) -> str: return self.__str__() + def __lt__(self, other) -> bool: + return self.name < other.name + + def __le__(self, other) -> bool: + return self.name <= other.name + + def __gt__(self, other) -> bool: + return self.name > other.name + + def __ge__(self, other) -> bool: + return self.name >= other.name + class Folder(Base): __tablename__ = "folders" @@ -125,6 +131,8 @@ class Entry(Base): path: Mapped[Path] = mapped_column(PathType, unique=True) suffix: Mapped[str] = mapped_column() + tags: Mapped[set[Tag]] = relationship(secondary="tag_entries") + text_fields: Mapped[list[TextField]] = relationship( back_populates="entry", cascade="all, delete", @@ -133,44 +141,22 @@ class Entry(Base): back_populates="entry", cascade="all, delete", ) - tag_box_fields: Mapped[list[TagBoxField]] = relationship( - back_populates="entry", - cascade="all, delete", - ) @property def fields(self) -> list[BaseField]: fields: list[BaseField] = [] - fields.extend(self.tag_box_fields) fields.extend(self.text_fields) fields.extend(self.datetime_fields) fields = sorted(fields, key=lambda field: field.type.position) return fields @property - def tags(self) -> set[Tag]: - tag_set: set[Tag] = set() - for tag_box_field in self.tag_box_fields: - tag_set.update(tag_box_field.tags) - return tag_set - - @property - def is_favorited(self) -> bool: - for tag_box_field in self.tag_box_fields: - if tag_box_field.type_key == _FieldID.TAGS_META.name: - for tag in tag_box_field.tags: - if tag.id == TAG_FAVORITE: - return True - return False + def is_favorite(self) -> bool: + return any(tag.id == TAG_FAVORITE for tag in self.tags) @property def is_archived(self) -> bool: - for tag_box_field in self.tag_box_fields: - if tag_box_field.type_key == _FieldID.TAGS_META.name: - for tag in tag_box_field.tags: - if tag.id == TAG_ARCHIVED: - return True - return False + return any(tag.id == TAG_ARCHIVED for tag in self.tags) def __init__( self, @@ -189,27 +175,15 @@ def __init__( self.text_fields.append(field) elif isinstance(field, DatetimeField): self.datetime_fields.append(field) - elif isinstance(field, TagBoxField): - self.tag_box_fields.append(field) else: raise ValueError(f"Invalid field type: {field}") def has_tag(self, tag: Tag) -> bool: return tag in self.tags - def remove_tag(self, tag: Tag, field: TagBoxField | None = None) -> None: - """Removes a Tag from the Entry. - - If given a field index, the given Tag will - only be removed from that index. If left blank, all instances of that - Tag will be removed from the Entry. - """ - if field: - field.tags.remove(tag) - return - - for tag_box_field in self.tag_box_fields: - tag_box_field.tags.remove(tag) + def remove_tag(self, tag: Tag) -> None: + """Removes a Tag from the Entry.""" + self.tags.remove(tag) class ValueType(Base): @@ -237,7 +211,6 @@ class ValueType(Base): datetime_fields: Mapped[list[DatetimeField]] = relationship( "DatetimeField", back_populates="type" ) - tag_box_fields: Mapped[list[TagBoxField]] = relationship("TagBoxField", back_populates="type") boolean_fields: Mapped[list[BooleanField]] = relationship("BooleanField", back_populates="type") @property @@ -245,7 +218,6 @@ def as_field(self) -> BaseField: FieldClass = { # noqa: N806 FieldTypeEnum.TEXT_LINE: TextField, FieldTypeEnum.TEXT_BOX: TextField, - FieldTypeEnum.TAGS: TagBoxField, FieldTypeEnum.DATETIME: DatetimeField, FieldTypeEnum.BOOLEAN: BooleanField, } diff --git a/tagstudio/src/core/library/alchemy/visitors.py b/tagstudio/src/core/library/alchemy/visitors.py index 5eed4580f..f88bc2a77 100644 --- a/tagstudio/src/core/library/alchemy/visitors.py +++ b/tagstudio/src/core/library/alchemy/visitors.py @@ -1,3 +1,7 @@ +# Copyright (C) 2025 +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + from typing import TYPE_CHECKING import structlog @@ -8,8 +12,8 @@ from src.core.query_lang import BaseVisitor from src.core.query_lang.ast import AST, ANDList, Constraint, ConstraintType, Not, ORList, Property -from .joins import TagField -from .models import Entry, Tag, TagAlias, TagBoxField +from .joins import TagEntry +from .models import Entry, Tag, TagAlias # workaround to have autocompletion in the Editor if TYPE_CHECKING: @@ -19,16 +23,17 @@ logger = structlog.get_logger(__name__) +# TODO: Reevaluate after subtags -> parent tags name change CHILDREN_QUERY = text(""" --- Note for this entire query that tag_subtags.child_id is the parent id and tag_subtags.parent_id is the child id due to bad naming -WITH RECURSIVE Subtags AS ( +-- Note for this entire query that tag_parents.child_id is the parent id and tag_parents.parent_id is the child id due to bad naming +WITH RECURSIVE ChildTags AS ( SELECT :tag_id AS child_id UNION ALL - SELECT ts.parent_id AS child_id - FROM tag_subtags ts - INNER JOIN Subtags s ON ts.child_id = s.child_id + SELECT tp.parent_id AS child_id + FROM tag_parents tp + INNER JOIN ChildTags c ON tp.child_id = c.child_id ) -SELECT * FROM Subtags; +SELECT * FROM ChildTags; """) # noqa: E501 @@ -51,12 +56,18 @@ def visit_and_list(self, node: ANDList) -> ColumnExpressionArgument: tag_ids: list[int] = [] bool_expressions: list[ColumnExpressionArgument] = [] - # Search for TagID / unambigous Tag Constraints and store the respective tag ids seperately + # Search for TagID / unambiguous Tag Constraints and store the respective tag ids separately for term in node.terms: if isinstance(term, Constraint) and len(term.properties) == 0: match term.type: case ConstraintType.TagID: - tag_ids.append(int(term.value)) + try: + tag_ids.append(int(term.value)) + except ValueError: + logger.error( + "[SQLBoolExpressionBuilder] Could not cast value to an int Tag ID", + value=term.value, + ) continue case ConstraintType.Tag: if len(ids := self.__get_tag_ids(term.value)) == 1: @@ -72,19 +83,20 @@ def visit_and_list(self, node: ANDList) -> ColumnExpressionArgument: # If there is just one tag id, check the normal way elif len(tag_ids) == 1: bool_expressions.append( - self.__entry_satisfies_expression(TagField.tag_id == tag_ids[0]) + self.__entry_satisfies_expression(TagEntry.tag_id == tag_ids[0]) ) return and_(*bool_expressions) def visit_constraint(self, node: Constraint) -> ColumnExpressionArgument: + """Returns a Boolean Expression that is true, if the Entry satisfies the constraint.""" if len(node.properties) != 0: raise NotImplementedError("Properties are not implemented yet") # TODO TSQLANG if node.type == ConstraintType.Tag: - return TagBoxField.tags.any(Tag.id.in_(self.__get_tag_ids(node.value))) + return Entry.tags.any(Tag.id.in_(self.__get_tag_ids(node.value))) elif node.type == ConstraintType.TagID: - return TagBoxField.tags.any(Tag.id == int(node.value)) + return Entry.tags.any(Tag.id == int(node.value)) elif node.type == ConstraintType.Path: return Entry.path.op("GLOB")(node.value) elif node.type == ConstraintType.MediaType: @@ -100,9 +112,7 @@ def visit_constraint(self, node: Constraint) -> ColumnExpressionArgument: ) elif node.type == ConstraintType.Special: # noqa: SIM102 unnecessary once there is a second special constraint if node.value.lower() == "untagged": - return ~Entry.id.in_( - select(Entry.id).join(Entry.tag_box_fields).join(TagBoxField.tags) - ) + return ~Entry.id.in_(select(Entry.id).join(TagEntry)) # raise exception if Constraint stays unhandled raise NotImplementedError("This type of constraint is not implemented yet") @@ -141,11 +151,10 @@ def __entry_has_all_tags(self, tag_ids: list[int]) -> BinaryExpression[bool]: # Relational Division Query return Entry.id.in_( select(Entry.id) - .outerjoin(TagBoxField) - .outerjoin(TagField) - .where(TagField.tag_id.in_(tag_ids)) + .outerjoin(TagEntry) + .where(TagEntry.tag_id.in_(tag_ids)) .group_by(Entry.id) - .having(func.count(distinct(TagField.tag_id)) == len(tag_ids)) + .having(func.count(distinct(TagEntry.tag_id)) == len(tag_ids)) ) def __entry_satisfies_ast(self, partial_query: AST) -> BinaryExpression[bool]: @@ -155,7 +164,8 @@ def __entry_satisfies_ast(self, partial_query: AST) -> BinaryExpression[bool]: def __entry_satisfies_expression( self, expr: ColumnExpressionArgument ) -> BinaryExpression[bool]: - """Returns Binary Expression that is true if the Entry satisfies the column expression.""" - return Entry.id.in_( - select(Entry.id).outerjoin(Entry.tag_box_fields).outerjoin(TagField).where(expr) - ) + """Returns Binary Expression that is true if the Entry satisfies the column expression. + + Executed on: Entry ⟕ TagEntry (Entry LEFT OUTER JOIN TagEntry). + """ + return Entry.id.in_(select(Entry.id).outerjoin(TagEntry).where(expr)) diff --git a/tagstudio/src/core/palette.py b/tagstudio/src/core/palette.py index 82710b55b..38b1d890d 100644 --- a/tagstudio/src/core/palette.py +++ b/tagstudio/src/core/palette.py @@ -1,4 +1,4 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio import traceback @@ -25,7 +25,8 @@ class UiColor(IntEnum): THEME_LIGHT = 2 RED = 3 GREEN = 4 - PURPLE = 5 + BLUE = 5 + PURPLE = 6 TAG_COLORS: dict[TagColor, dict[ColorType, Any]] = { @@ -309,6 +310,12 @@ class UiColor(IntEnum): ColorType.LIGHT_ACCENT: "#DDFFCC", ColorType.DARK_ACCENT: "#0d3828", }, + UiColor.BLUE: { + ColorType.PRIMARY: "#3b87f0", + ColorType.BORDER: "#4e95f2", + ColorType.LIGHT_ACCENT: "#aedbfa", + ColorType.DARK_ACCENT: "#122948", + }, UiColor.PURPLE: { ColorType.PRIMARY: "#C76FF3", ColorType.BORDER: "#c364f2", diff --git a/tagstudio/src/core/ts_core.py b/tagstudio/src/core/ts_core.py index aaae942e4..3d412d9e3 100644 --- a/tagstudio/src/core/ts_core.py +++ b/tagstudio/src/core/ts_core.py @@ -126,7 +126,7 @@ def match_conditions(cls, lib: Library, entry_id: int) -> bool: is_new = field["id"] not in entry_field_types field_key = field["id"] if is_new: - lib.add_entry_field_type(entry.id, field_key, field["value"]) + lib.add_field_to_entry(entry.id, field_key, field["value"]) else: lib.update_entry_field(entry.id, field_key, field["value"]) diff --git a/tagstudio/src/qt/main_window.py b/tagstudio/src/qt/main_window.py index 513141cee..c76fa7e93 100644 --- a/tagstudio/src/qt/main_window.py +++ b/tagstudio/src/qt/main_window.py @@ -1,14 +1,4 @@ -# -*- coding: utf-8 -*- - -################################################################################ -# Form generated from reading UI file 'home.ui' -## -# Created by: Qt User Interface Compiler version 6.5.1 -## -# WARNING! All changes made in this file will be lost when recompiling UI file! -################################################################################ - -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio @@ -180,6 +170,7 @@ def setupUi(self, MainWindow): sizePolicy1.setHeightForWidth( self.statusbar.sizePolicy().hasHeightForWidth()) self.statusbar.setSizePolicy(sizePolicy1) + self.statusbar.setSizeGripEnabled(False) MainWindow.setStatusBar(self.statusbar) QMetaObject.connectSlotsByName(MainWindow) diff --git a/tagstudio/src/qt/modals/add_field.py b/tagstudio/src/qt/modals/add_field.py index e948e5116..bea2255bf 100644 --- a/tagstudio/src/qt/modals/add_field.py +++ b/tagstudio/src/qt/modals/add_field.py @@ -25,7 +25,6 @@ def __init__(self, library: Library): # - OR - # [Cancel] [Save] super().__init__() - self.is_connected = False self.lib = library Translations.translate_with_setter(self.setWindowTitle, "library.field.add") self.setWindowModality(Qt.WindowModality.ApplicationModal) diff --git a/tagstudio/src/qt/modals/build_tag.py b/tagstudio/src/qt/modals/build_tag.py index e0ce6615a..f9957b303 100644 --- a/tagstudio/src/qt/modals/build_tag.py +++ b/tagstudio/src/qt/modals/build_tag.py @@ -1,4 +1,4 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio @@ -10,8 +10,10 @@ from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import ( QApplication, + QCheckBox, QComboBox, QFrame, + QHBoxLayout, QLabel, QLineEdit, QPushButton, @@ -56,6 +58,7 @@ class BuildTagPanel(PanelWidget): def __init__(self, library: Library, tag: Tag | None = None): super().__init__() self.lib = library + self.tag: Tag # NOTE: This gets set at the end of the init. self.setMinimumSize(300, 400) self.root_layout = QVBoxLayout(self) @@ -115,23 +118,22 @@ def __init__(self, library: Library, tag: Tag | None = None): self.alias_add_button.clicked.connect(self.add_alias_callback) - # Subtags ------------------------------------------------------------ + # Parent Tags ---------------------------------------------------------- + self.parent_tags_widget = QWidget() + self.parent_tags_layout = QVBoxLayout(self.parent_tags_widget) + self.parent_tags_layout.setStretch(1, 1) + self.parent_tags_layout.setContentsMargins(0, 0, 0, 0) + self.parent_tags_layout.setSpacing(0) + self.parent_tags_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) - self.subtags_widget = QWidget() - self.subtags_layout = QVBoxLayout(self.subtags_widget) - self.subtags_layout.setStretch(1, 1) - self.subtags_layout.setContentsMargins(0, 0, 0, 0) - self.subtags_layout.setSpacing(0) - self.subtags_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) - - self.subtags_title = QLabel() - Translations.translate_qobject(self.subtags_title, "tag.parent_tags") - self.subtags_layout.addWidget(self.subtags_title) + self.parent_tags_title = QLabel() + Translations.translate_qobject(self.parent_tags_title, "tag.parent_tags") + self.parent_tags_layout.addWidget(self.parent_tags_title) self.scroll_contents = QWidget() - self.subtags_scroll_layout = QVBoxLayout(self.scroll_contents) - self.subtags_scroll_layout.setContentsMargins(6, 0, 6, 0) - self.subtags_scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + self.parent_tags_scroll_layout = QVBoxLayout(self.scroll_contents) + self.parent_tags_scroll_layout.setContentsMargins(6, 0, 6, 0) + self.parent_tags_scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop) self.scroll_area = QScrollArea() # self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn) @@ -141,29 +143,29 @@ def __init__(self, library: Library, tag: Tag | None = None): self.scroll_area.setWidget(self.scroll_contents) # self.scroll_area.setMinimumHeight(60) - self.subtags_layout.addWidget(self.scroll_area) + self.parent_tags_layout.addWidget(self.scroll_area) - self.subtags_add_button = QPushButton() - self.subtags_add_button.setCursor(Qt.CursorShape.PointingHandCursor) - self.subtags_add_button.setText("+") - self.subtags_layout.addWidget(self.subtags_add_button) + self.parent_tags_add_button = QPushButton() + self.parent_tags_add_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.parent_tags_add_button.setText("+") + self.parent_tags_layout.addWidget(self.parent_tags_add_button) exclude_ids: list[int] = list() if tag is not None: exclude_ids.append(tag.id) tsp = TagSearchPanel(self.lib, exclude_ids) - tsp.tag_chosen.connect(lambda x: self.add_subtag_callback(x)) + tsp.tag_chosen.connect(lambda x: self.add_parent_tag_callback(x)) self.add_tag_modal = PanelModal(tsp) Translations.translate_with_setter(self.add_tag_modal.setTitle, "tag.parent_tags.add") Translations.translate_with_setter(self.add_tag_modal.setWindowTitle, "tag.parent_tags.add") - self.subtags_add_button.clicked.connect(self.add_tag_modal.show) + self.parent_tags_add_button.clicked.connect(self.add_tag_modal.show) - # Shorthand ------------------------------------------------------------ + # Color ---------------------------------------------------------------- self.color_widget = QWidget() self.color_layout = QVBoxLayout(self.color_widget) self.color_layout.setStretch(1, 1) - self.color_layout.setContentsMargins(0, 0, 0, 0) + self.color_layout.setContentsMargins(0, 0, 0, 24) self.color_layout.setSpacing(0) self.color_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) self.color_title = QLabel() @@ -190,16 +192,47 @@ def __init__(self, library: Library, tag: Tag | None = None): ) self.color_layout.addWidget(self.color_field) + # Category ------------------------------------------------------------- + self.cat_widget = QWidget() + self.cat_layout = QHBoxLayout(self.cat_widget) + self.cat_layout.setStretch(1, 1) + self.cat_layout.setContentsMargins(0, 0, 0, 0) + self.cat_layout.setSpacing(0) + self.cat_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.cat_title = QLabel() + self.cat_title.setText("Is Category") + self.cat_checkbox = QCheckBox() + # TODO: Style checkbox + self.cat_checkbox.setStyleSheet( + "QCheckBox::indicator{" + "width: 19px; height: 19px;" + # f"background: #1e1e1e;" + # f"border-color: #333333;" + # f"border-radius: 6px;" + # f"border-style:solid;" + # f"border-width:{math.ceil(self.devicePixelRatio())}px;" + "}" + # f"QCheckBox::indicator::hover" + # f"{{" + # f"border-color: #CCCCCC;" + # f"background: #555555;" + # f"}}" + ) + self.cat_layout.addWidget(self.cat_checkbox) + self.cat_layout.addWidget(self.cat_title) + # Add Widgets to Layout ================================================ self.root_layout.addWidget(self.name_widget) self.root_layout.addWidget(self.shorthand_widget) self.root_layout.addWidget(self.aliases_widget) self.root_layout.addWidget(self.aliases_table) self.root_layout.addWidget(self.alias_add_button) - self.root_layout.addWidget(self.subtags_widget) + self.root_layout.addWidget(self.parent_tags_widget) self.root_layout.addWidget(self.color_widget) + self.root_layout.addWidget(QLabel("

Properties

")) + self.root_layout.addWidget(self.cat_widget) - self.subtag_ids: set[int] = set() + self.parent_ids: set[int] = set() self.alias_ids: list[int] = [] self.alias_names: list[str] = [] self.new_alias_names: dict = {} @@ -239,26 +272,23 @@ def enter(self): if isinstance(focused_widget, CustomTableItem): self.add_alias_callback() - def add_subtag_callback(self, tag_id: int): - logger.info("add_subtag_callback", tag_id=tag_id) - self.subtag_ids.add(tag_id) - self.set_subtags() + def add_parent_tag_callback(self, tag_id: int): + logger.info("add_parent_tag_callback", tag_id=tag_id) + self.parent_ids.add(tag_id) + self.set_parent_tags() - def remove_subtag_callback(self, tag_id: int): - logger.info("removing subtag", tag_id=tag_id) - self.subtag_ids.remove(tag_id) - self.set_subtags() + def remove_parent_tag_callback(self, tag_id: int): + logger.info("remove_parent_tag_callback", tag_id=tag_id) + self.parent_ids.remove(tag_id) + self.set_parent_tags() def add_alias_callback(self): logger.info("add_alias_callback") id = self.new_item_id - self.alias_ids.append(id) self.new_alias_names[id] = "" - self.new_item_id -= 1 - self._set_aliases() row = self.aliases_table.rowCount() - 1 @@ -271,20 +301,20 @@ def remove_alias_callback(self, alias_name: str, alias_id: int | None = None): self.alias_ids.remove(alias_id) self._set_aliases() - def set_subtags(self): - while self.subtags_scroll_layout.itemAt(0): - self.subtags_scroll_layout.takeAt(0).widget().deleteLater() + def set_parent_tags(self): + while self.parent_tags_scroll_layout.itemAt(0): + self.parent_tags_scroll_layout.takeAt(0).widget().deleteLater() c = QWidget() layout = QVBoxLayout(c) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(3) - for tag_id in self.subtag_ids: + for tag_id in self.parent_ids: tag = self.lib.get_tag(tag_id) tw = TagWidget(tag, has_edit=False, has_remove=True) - tw.on_remove.connect(lambda t=tag_id: self.remove_subtag_callback(t)) + tw.on_remove.connect(lambda t=tag_id: self.remove_parent_tag_callback(t)) layout.addWidget(tw) - self.subtags_scroll_layout.addWidget(c) + self.parent_tags_scroll_layout.addWidget(c) def add_aliases(self): names: set[str] = set() @@ -349,22 +379,18 @@ def _alias_name_change(self, item: CustomTableItem): self.new_alias_names[item.id] = item.text() def set_tag(self, tag: Tag): + logger.info("[BuildTagPanel] Setting Tag", tag=tag) self.tag = tag - - logger.info("setting tag", tag=tag) - self.name_field.setText(tag.name) self.shorthand_field.setText(tag.shorthand or "") for alias_id in tag.alias_ids: self.alias_ids.append(alias_id) - self._set_aliases() - for subtag in tag.subtag_ids: - self.subtag_ids.add(subtag) - - self.set_subtags() + for parent_id in tag.parent_ids: + self.parent_ids.add(parent_id) + self.set_parent_tags() # select item in self.color_field where the userData value matched tag.color for i in range(self.color_field.count()): @@ -372,6 +398,8 @@ def set_tag(self, tag: Tag): self.color_field.setCurrentIndex(i) break + self.cat_checkbox.setChecked(tag.is_category) + def on_name_changed(self): is_empty = not self.name_field.text().strip() @@ -386,14 +414,13 @@ def on_name_changed(self): def build_tag(self) -> Tag: color = self.color_field.currentData() or TagColor.DEFAULT - tag = self.tag - self.add_aliases() tag.name = self.name_field.text() tag.shorthand = self.shorthand_field.text() tag.color = color + tag.is_category = self.cat_checkbox.isChecked() logger.info("built tag", tag=tag) return tag diff --git a/tagstudio/src/qt/modals/folders_to_tags.py b/tagstudio/src/qt/modals/folders_to_tags.py index f680fdc18..a0b9b355a 100644 --- a/tagstudio/src/qt/modals/folders_to_tags.py +++ b/tagstudio/src/qt/modals/folders_to_tags.py @@ -1,4 +1,4 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Copyright (C) 2025 # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio @@ -20,7 +20,6 @@ ) from src.core.constants import TAG_ARCHIVED, TAG_FAVORITE from src.core.library import Library, Tag -from src.core.library.alchemy.fields import _FieldID from src.core.palette import ColorType, get_tag_color from src.qt.flowlayout import FlowLayout from src.qt.translations import Translations @@ -42,7 +41,7 @@ def add_folders_to_tree(library: Library, tree: BranchData, items: tuple[str, .. branch = tree for folder in items: if folder not in branch.dirs: - # TODO - subtags + # TODO: Reimplement parent tags new_tag = Tag(name=folder) library.add_tag(new_tag) branch.dirs[folder] = BranchData(tag=new_tag) @@ -73,7 +72,7 @@ def add_tag_to_tree(items: list[Tag]): tag = add_folders_to_tree(library, tree, folders).tag if tag and not entry.has_tag(tag): - library.add_field_tag(entry, tag, _FieldID.TAGS.name, create_field=True) + library.add_tags_to_entry(entry.id, tag.id) logger.info("Done") @@ -82,11 +81,11 @@ def reverse_tag(library: Library, tag: Tag, items: list[Tag] | None) -> list[Tag items = items or [] items.append(tag) - if not tag.subtag_ids: + if not tag.parent_ids: items.reverse() return items - for subtag_id in tag.subtag_ids: + for subtag_id in tag.parent_ids: subtag = library.get_tag(subtag_id) return reverse_tag(library, subtag, items) @@ -127,11 +126,10 @@ def _add_folders_to_tree(items: typing.Sequence[str]) -> BranchData: branch = _add_folders_to_tree(folders) if branch: has_tag = False - for tag_field in entry.tag_box_fields: - for tag in tag_field.tags: - if tag.name == branch.tag.name: - has_tag = True - break + for tag in entry.tags: + if tag.name == branch.tag.name: + has_tag = True + break if not has_tag: branch.files.append(entry.path.name) diff --git a/tagstudio/src/qt/modals/tag_database.py b/tagstudio/src/qt/modals/tag_database.py index c2cdc8f0d..d567e8991 100644 --- a/tagstudio/src/qt/modals/tag_database.py +++ b/tagstudio/src/qt/modals/tag_database.py @@ -87,7 +87,7 @@ def build_tag(self, name: str): lambda: ( self.lib.add_tag( tag=panel.build_tag(), - subtag_ids=panel.subtag_ids, + parent_ids=panel.parent_ids, alias_names=panel.alias_names, alias_ids=panel.alias_ids, ), @@ -121,7 +121,7 @@ def update_tags(self, query: str | None = None): row.setSpacing(3) if tag.id in range(RESERVED_TAG_START, RESERVED_TAG_END): - tag_widget = TagWidget(tag, has_edit=False, has_remove=False) + tag_widget = TagWidget(tag, has_edit=True, has_remove=False) else: tag_widget = TagWidget(tag, has_edit=True, has_remove=True) @@ -151,9 +151,6 @@ def remove_tag(self, tag: Tag): self.update_tags() def edit_tag(self, tag: Tag): - if tag.id in range(RESERVED_TAG_START, RESERVED_TAG_END): - return - build_tag_panel = BuildTagPanel(self.lib, tag=tag) self.edit_modal = PanelModal( @@ -169,7 +166,7 @@ def edit_tag(self, tag: Tag): def edit_tag_callback(self, btp: BuildTagPanel): self.lib.update_tag( - btp.build_tag(), set(btp.subtag_ids), set(btp.alias_names), set(btp.alias_ids) + btp.build_tag(), set(btp.parent_ids), set(btp.alias_names), set(btp.alias_ids) ) self.update_tags(self.search_field.text()) diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 7251246a5..d80e20390 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -1,4 +1,4 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio @@ -15,8 +15,6 @@ import sys import time import webbrowser -from collections.abc import Sequence -from itertools import zip_longest from pathlib import Path from queue import Queue @@ -93,6 +91,12 @@ from src.qt.widgets.progress import ProgressWidget from src.qt.widgets.thumb_renderer import ThumbRenderer +BADGE_TAGS = { + BadgeType.FAVORITE: TAG_FAVORITE, + BadgeType.ARCHIVED: TAG_ARCHIVED, +} + + # SIGQUIT is not defined on Windows if sys.platform == "win32": from signal import SIGINT, SIGTERM, signal @@ -137,8 +141,8 @@ def __init__(self, backend, args): self.lib = backend.Library() self.rm: ResourceManager = ResourceManager() self.args = args - self.frame_content = [] self.filter = FilterState.show_all() + self.frame_content: list[int] = [] # List of Entry IDs on the current page self.pages_count = 0 self.scrollbar_pos = 0 @@ -152,9 +156,7 @@ def __init__(self, backend, args): self.thumb_job_queue: Queue = Queue() self.thumb_threads: list[Consumer] = [] self.thumb_cutoff: float = time.time() - - # grid indexes of selected items - self.selected: list[int] = [] + self.selected: list[int] = [] # Selected Entry IDs self.SIGTERM.connect(self.handle_sigterm) @@ -295,6 +297,26 @@ def start(self) -> None: open_library_action.setToolTip("Ctrl+O") file_menu.addAction(open_library_action) + self.open_recent_library_menu = QMenu(menu_bar) + Translations.translate_qobject( + self.open_recent_library_menu, "menu.file.open_recent_library" + ) + file_menu.addMenu(self.open_recent_library_menu) + self.update_recent_lib_menu() + + open_on_start_action = QAction(self) + Translations.translate_qobject(open_on_start_action, "settings.open_library_on_start") + open_on_start_action.setCheckable(True) + open_on_start_action.setChecked( + bool(self.settings.value(SettingItems.START_LOAD_LAST, defaultValue=True, type=bool)) + ) + open_on_start_action.triggered.connect( + lambda checked: self.settings.setValue(SettingItems.START_LOAD_LAST, checked) + ) + file_menu.addAction(open_on_start_action) + + file_menu.addSeparator() + save_library_backup_action = QAction(menu_bar) Translations.translate_qobject(save_library_backup_action, "menu.file.save_backup") save_library_backup_action.triggered.connect( @@ -335,17 +357,6 @@ def start(self) -> None: file_menu.addAction(close_library_action) file_menu.addSeparator() - open_on_start_action = QAction(self) - Translations.translate_qobject(open_on_start_action, "settings.open_library_on_start") - open_on_start_action.setCheckable(True) - open_on_start_action.setChecked( - bool(self.settings.value(SettingItems.START_LOAD_LAST, defaultValue=True, type=bool)) - ) - open_on_start_action.triggered.connect( - lambda checked: self.settings.setValue(SettingItems.START_LOAD_LAST, checked) - ) - file_menu.addAction(open_on_start_action) - # Edit Menu ============================================================ new_tag_action = QAction(menu_bar) Translations.translate_qobject(new_tag_action, "menu.edit.new_tag") @@ -401,13 +412,6 @@ def start(self) -> None: show_libs_list_action.setChecked( bool(self.settings.value(SettingItems.WINDOW_SHOW_LIBS, defaultValue=True, type=bool)) ) - show_libs_list_action.triggered.connect( - lambda checked: ( - self.settings.setValue(SettingItems.WINDOW_SHOW_LIBS, checked), - self.toggle_libs_list(checked), - ) - ) - view_menu.addAction(show_libs_list_action) show_filenames_action = QAction(menu_bar) Translations.translate_qobject(show_filenames_action, "settings.show_filenames_in_grid") @@ -489,6 +493,17 @@ def create_folders_tags_modal(): self.main_window.searchField.textChanged.connect(self.update_completions_list) self.preview_panel = PreviewPanel(self.lib, self) + self.preview_panel.fields.archived_updated.connect( + lambda hidden: self.update_badges( + {BadgeType.ARCHIVED: hidden}, origin_id=0, add_tags=False + ) + ) + self.preview_panel.fields.favorite_updated.connect( + lambda hidden: self.update_badges( + {BadgeType.FAVORITE: hidden}, origin_id=0, add_tags=False + ) + ) + splitter = self.main_window.splitter splitter.addWidget(self.preview_panel) @@ -613,13 +628,6 @@ def init_library_window(self): self.splash.finish(self.main_window) self.preview_panel.update_widgets() - def toggle_libs_list(self, value: bool): - if value: - self.preview_panel.libs_flow_container.show() - else: - self.preview_panel.libs_flow_container.hide() - self.preview_panel.update() - def show_grid_filenames(self, value: bool): for thumb in self.item_thumbs: thumb.set_filename_visibility(value) @@ -710,7 +718,7 @@ def add_tag_action_callback(self): lambda: ( self.lib.add_tag( panel.build_tag(), - set(panel.subtag_ids), + set(panel.parent_ids), set(panel.alias_names), set(panel.alias_ids), ), @@ -720,10 +728,12 @@ def add_tag_action_callback(self): self.modal.show() def select_all_action_callback(self): - self.selected = list(range(0, len(self.frame_content))) - - for grid_idx in self.selected: - self.item_thumbs[grid_idx].thumb_button.set_selected(True) + """Set the selection to all visible items.""" + self.selected.clear() + for item in self.item_thumbs: + if item.mode and item.item_id not in self.selected: + self.selected.append(item.item_id) + item.thumb_button.set_selected(True) self.set_macro_menu_viability() self.preview_panel.update_widgets() @@ -839,29 +849,20 @@ def add_new_files_runnable(self, tracker: RefreshDirTracker): def new_file_macros_runnable(self, new_ids): """Threaded method that runs macros on a set of Entry IDs.""" - # sleep(1) # for i, id in enumerate(new_ids): # # pb.setValue(i) # # pb.setLabelText(f'Running Configured Macros on {i}/{len(new_ids)} New Entries') # # self.run_macro('autofill', id) - - # NOTE: I don't know. I don't know why it needs this. The whole program - # falls apart if this method doesn't run, and it DOESN'T DO ANYTHING yield 0 - # self.main_window.statusbar.showMessage('', 3) - - # sleep(5) - # pb.deleteLater() - - def run_macros(self, name: MacroID, grid_idx: list[int]): + def run_macros(self, name: MacroID, entry_ids: list[int]): """Run a specific Macro on a group of given entry_ids.""" - for gid in grid_idx: - self.run_macro(name, gid) + for entry_id in entry_ids: + self.run_macro(name, entry_id) - def run_macro(self, name: MacroID, grid_idx: int): + def run_macro(self, name: MacroID, entry_id: int): """Run a specific Macro on an Entry given a Macro name.""" - entry: Entry = self.frame_content[grid_idx] + entry: Entry = self.lib.get_entry(entry_id) full_path = self.lib.library_dir / entry.path source = "" if entry.path.parent == Path(".") else entry.path.parts[0].lower() @@ -870,21 +871,21 @@ def run_macro(self, name: MacroID, grid_idx: int): source=source, macro=name, entry_id=entry.id, - grid_idx=grid_idx, + grid_idx=entry_id, ) if name == MacroID.AUTOFILL: for macro_id in MacroID: if macro_id == MacroID.AUTOFILL: continue - self.run_macro(macro_id, grid_idx) + self.run_macro(macro_id, entry_id) elif name == MacroID.SIDECAR: parsed_items = TagStudioCore.get_gdl_sidecar(full_path, source) for field_id, value in parsed_items.items(): if isinstance(value, list) and len(value) > 0 and isinstance(value[0], str): value = self.lib.tag_from_strings(value) - self.lib.add_entry_field_type( + self.lib.add_field_to_entry( entry.id, field_id=field_id, value=value, @@ -893,7 +894,7 @@ def run_macro(self, name: MacroID, grid_idx: int): elif name == MacroID.BUILD_URL: url = TagStudioCore.build_url(entry, source) if url is not None: - self.lib.add_entry_field_type(entry.id, field_id=_FieldID.SOURCE, value=url) + self.lib.add_field_to_entry(entry.id, field_id=_FieldID.SOURCE, value=url) elif name == MacroID.MATCH: TagStudioCore.match_conditions(self.lib, entry.id) elif name == MacroID.CLEAN_URL: @@ -979,6 +980,8 @@ def page_move(self, delta: int = None, page_id: int = None) -> None: 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() def remove_grid_item(self, grid_idx: int): @@ -992,13 +995,12 @@ def _init_thumb_grid(self): layout.setAlignment(Qt.AlignmentFlag.AlignCenter) # TODO - init after library is loaded, it can have different page_size - for grid_idx in range(self.filter.page_size): + for _ in range(self.filter.page_size): item_thumb = ItemThumb( None, self.lib, self, (self.thumb_size, self.thumb_size), - grid_idx, bool( self.settings.value(SettingItems.SHOW_FILENAMES, defaultValue=True, type=bool) ), @@ -1015,44 +1017,74 @@ def _init_thumb_grid(self): sa.setWidgetResizable(True) sa.setWidget(self.flow_container) - def select_item(self, grid_index: int, append: bool, bridge: bool): - """Select one or more items in the Thumbnail Grid.""" - logger.info("selecting item", grid_index=grid_index, append=append, bridge=bridge) - if append: - if grid_index not in self.selected: - self.selected.append(grid_index) - self.item_thumbs[grid_index].thumb_button.set_selected(True) - else: - self.selected.remove(grid_index) - self.item_thumbs[grid_index].thumb_button.set_selected(False) + def toggle_item_selection(self, item_id: int, append: bool, bridge: bool): + """Toggle the selection of an item in the Thumbnail Grid. - elif bridge and self.selected: - select_from = min(self.selected) - select_to = max(self.selected) + If an item is not selected, this selects it. If an item is already selected, this will + deselect it as long as append and bridge are False. - if select_to < grid_index: - index_range = range(select_from, grid_index + 1) + Args: + item_id(int): The ID of the item/entry to select. + append(bool): Whether or not to add this item to the previous selection + or to restart the selection with this item. + Setting to True acts like "Ctrl + Click" selecting. + bridge(bool): Whether or not to select items in the visual range of the last item + selected and this current item. + Setting to True acts like "Shift + Click" selecting. + """ + logger.info("[QtDriver] Selecting Items:", item_id=item_id, append=append, bridge=bridge) + + if append: + if item_id not in self.selected: + self.selected.append(item_id) + for it in self.item_thumbs: + if it.item_id == item_id: + it.thumb_button.set_selected(True) else: - index_range = range(grid_index, select_to + 1) + self.selected.remove(item_id) + for it in self.item_thumbs: + if it.item_id == item_id: + it.thumb_button.set_selected(False) - self.selected = list(index_range) + # TODO: Allow bridge selecting across pages. + elif bridge and self.selected: + last_index = -1 + current_index = -1 + try: + contents = self.frame_content + last_index = self.frame_content.index(self.selected[-1]) + current_index = self.frame_content.index(item_id) + index_range: list = contents[ + min(last_index, current_index) : max(last_index, current_index) + 1 + ] + + # Preserve bridge direction for correct appending order. + if last_index < current_index: + index_range.reverse() + for entry_id in index_range: + for it in self.item_thumbs: + if it.item_id == entry_id: + it.thumb_button.set_selected(True) + if entry_id not in self.selected: + self.selected.append(entry_id) + except Exception as e: + # TODO: Allow bridge selecting across pages. + logger.error( + "[QtDriver] Previous selected item not on current page!", + error=e, + item_id=item_id, + current_index=current_index, + last_index=last_index, + ) - for selected_idx in self.selected: - self.item_thumbs[selected_idx].thumb_button.set_selected(True) else: - self.selected = [grid_index] - for thumb_idx, item_thumb in enumerate(self.item_thumbs): - item_matched = thumb_idx == grid_index - item_thumb.thumb_button.set_selected(item_matched) - - # NOTE: By using the preview panel's "set_tags_updated_slot" method, - # only the last of multiple identical item selections are connected. - # If attaching the slot to multiple duplicate selections is needed, - # just bypass the method and manually disconnect and connect the slots. - if len(self.selected) == 1: - for it in self.item_thumbs: - if it.item_id == id: - self.preview_panel.set_tags_updated_slot(it.refresh_badge) + self.selected.clear() + self.selected.append(item_id) + for it in self.item_thumbs: + if it.item_id in self.selected: + it.thumb_button.set_selected(True) + else: + it.thumb_button.set_selected(False) self.set_macro_menu_viability() self.preview_panel.update_widgets() @@ -1149,18 +1181,26 @@ def update_thumbs(self): self.main_window.update() is_grid_thumb = True - # Show loading placeholder icons - for entry, item_thumb in zip_longest(self.frame_content, self.item_thumbs): - if not entry: + logger.info("[QtDriver] Loading Entries...") + # TODO: The full entries with joins don't need to be grabbed here. + # Use a method that only selects the frame content but doesn't include the joins. + entries: list[Entry] = list(self.lib.get_entries_full(self.frame_content)) + logger.info("[QtDriver] Building Filenames...") + filenames: list[Path] = [self.lib.library_dir / e.path for e in entries] + logger.info("[QtDriver] Done! Processing ItemThumbs...") + for index, item_thumb in enumerate(self.item_thumbs, start=0): + entry = None + try: + entry = entries[index] + except IndexError: item_thumb.hide() continue + if not entry: + continue item_thumb.set_mode(ItemType.ENTRY) - item_thumb.set_item_id(entry) - - # TODO - show after item is rendered + item_thumb.set_item_id(entry.id) item_thumb.show() - is_loading = True self.thumb_job_queue.put( ( @@ -1170,29 +1210,29 @@ def update_thumbs(self): ) # Show rendered thumbnails - for idx, (entry, item_thumb) in enumerate( - zip_longest(self.frame_content, self.item_thumbs) - ): + for index, item_thumb in enumerate(self.item_thumbs, start=0): + entry = None + try: + entry = entries[index] + except IndexError: + item_thumb.hide() + continue if not entry: continue - filepath = self.lib.library_dir / entry.path is_loading = False - self.thumb_job_queue.put( ( item_thumb.renderer.render, - (time.time(), filepath, base_size, ratio, is_loading, is_grid_thumb), + (time.time(), filenames[index], base_size, ratio, is_loading, is_grid_thumb), ) ) - - entry_tag_ids = {tag.id for tag in entry.tags} - item_thumb.assign_badge(BadgeType.ARCHIVED, TAG_ARCHIVED in entry_tag_ids) - item_thumb.assign_badge(BadgeType.FAVORITE, TAG_FAVORITE in entry_tag_ids) + item_thumb.assign_badge(BadgeType.ARCHIVED, entry.is_archived) + item_thumb.assign_badge(BadgeType.FAVORITE, entry.is_favorite) item_thumb.update_clickable( clickable=( - lambda checked=False, index=idx: self.select_item( - index, + lambda checked=False, item_id=entry.id: self.toggle_item_selection( + item_id, append=( QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ControlModifier @@ -1205,27 +1245,28 @@ def update_thumbs(self): ) # Restore Selected Borders - is_selected = (item_thumb.mode, item_thumb.item_id) in self.selected + is_selected = item_thumb.item_id in self.selected item_thumb.thumb_button.set_selected(is_selected) - self.thumb_job_queue.put( - ( - item_thumb.renderer.render, - (time.time(), filepath, base_size, ratio, False, True), - ) - ) + def update_badges(self, badge_values: dict[BadgeType, bool], origin_id: int, add_tags=True): + """Update the tag badges for item_thumbs. - def update_badges(self, grid_item_ids: Sequence[int] = None): - if not grid_item_ids: - # no items passed, update all items in grid - grid_item_ids = range(min(len(self.item_thumbs), len(self.frame_content))) - - logger.info("updating badges for items", grid_item_ids=grid_item_ids) + Args: + badge_values(dict[BadgeType, bool]): The BadgeType and associated viability state. + origin_id(int): The ID of the item_thumb calling this method. If the ID is found as a + part of the current selection, or if the ID is 0, the the entire current selection + will be updated. Otherwise, only item_thumbs with that ID will be updated. + add_tags(bool): Flag determining if tags associated with the badges need to be added to + the items. Defaults to True. + """ + item_ids = self.selected if (not origin_id or origin_id in self.selected) else [origin_id] - for grid_idx in grid_item_ids: - # get the entry from grid to avoid loading from db again - entry = self.frame_content[grid_idx] - self.item_thumbs[grid_idx].refresh_badge(entry) + for it in self.item_thumbs: + if it.item_id in item_ids: + for badge_type, value in badge_values.items(): + if add_tags: + it.toggle_item_tag(it.item_id, value, BADGE_TAGS[badge_type]) + it.assign_badge(badge_type, value) def filter_items(self, filter: FilterState | None = None) -> None: if not self.lib.library_dir: @@ -1244,13 +1285,9 @@ def filter_items(self, filter: FilterState | None = None) -> None: self.main_window.statusbar.repaint() # search the library - start_time = time.time() - results = self.lib.search_library(self.filter) - logger.info("items to render", count=len(results)) - end_time = time.time() # inform user about completed search @@ -1263,7 +1300,7 @@ def filter_items(self, filter: FilterState | None = None) -> None: ) # update page content - self.frame_content = results.items + self.frame_content = [item.id for item in results.items] self.update_thumbs() # update pagination @@ -1303,6 +1340,63 @@ def update_libs_list(self, path: Path | str): self.settings.endGroup() self.settings.sync() + self.update_recent_lib_menu() + + def update_recent_lib_menu(self): + """Updates the recent library menu from the latest values from the settings file.""" + actions: list[QAction] = [] + lib_items: dict[str, tuple[str, str]] = {} + + settings = self.settings + settings.beginGroup(SettingItems.LIBS_LIST) + for item_tstamp in settings.allKeys(): + val = str(settings.value(item_tstamp, type=str)) + cut_val = val + if len(val) > 45: + cut_val = f"{val[0:10]} ... {val[-10:]}" + lib_items[item_tstamp] = (val, cut_val) + + # Sort lib_items by the key + libs_sorted = sorted(lib_items.items(), key=lambda item: item[0], reverse=True) + settings.endGroup() + + # Create actions for each library + for library_key in libs_sorted: + path = Path(library_key[1][0]) + action = QAction(self.open_recent_library_menu) + action.setText(str(path)) + action.triggered.connect(lambda checked=False, p=path: self.open_library(p)) + actions.append(action) + + clear_recent_action = QAction(self.open_recent_library_menu) + Translations.translate_qobject(clear_recent_action, "menu.file.clear_recent_libraries") + clear_recent_action.triggered.connect(self.clear_recent_libs) + actions.append(clear_recent_action) + + # Clear previous actions + for action in self.open_recent_library_menu.actions(): + self.open_recent_library_menu.removeAction(action) + + # Add new actions + for action in actions: + self.open_recent_library_menu.addAction(action) + + # Only enable add "clear recent" if there are still recent libraries. + if len(actions) > 1: + self.open_recent_library_menu.setDisabled(False) + self.open_recent_library_menu.addSeparator() + self.open_recent_library_menu.addAction(clear_recent_action) + else: + self.open_recent_library_menu.setDisabled(True) + + def clear_recent_libs(self): + """Clear the list of recent libraries from the settings file.""" + settings = self.settings + settings.beginGroup(SettingItems.LIBS_LIST) + self.settings.remove("") + self.settings.endGroup() + self.settings.sync() + self.update_recent_lib_menu() def open_library(self, path: Path) -> None: """Open a TagStudio library.""" @@ -1315,7 +1409,12 @@ def open_library(self, path: Path) -> None: ) self.main_window.repaint() - open_status: LibraryStatus = self.lib.open_library(path) + open_status: LibraryStatus = None + try: + open_status = self.lib.open_library(path) + except Exception as e: + logger.exception(e) + open_status = LibraryStatus(success=False, library_path=path, message=type(e).__name__) # Migration is required if open_status.json_migration_req: diff --git a/tagstudio/src/qt/widgets/collage_icon.py b/tagstudio/src/qt/widgets/collage_icon.py index e15cdd52f..4757747a7 100644 --- a/tagstudio/src/qt/widgets/collage_icon.py +++ b/tagstudio/src/qt/widgets/collage_icon.py @@ -1,4 +1,4 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio @@ -14,7 +14,6 @@ Signal, ) from src.core.library import Library -from src.core.library.alchemy.fields import _FieldID from src.core.media_types import MediaCategories from src.qt.helpers.file_tester import is_readable_video @@ -43,27 +42,7 @@ def render( try: if data_tint_mode or data_only_mode: - if entry.fields: - has_any_tags: bool = False - has_content_tags: bool = False - has_meta_tags: bool = False - for field in entry.tag_box_fields: - if field.tags: - has_any_tags = True - if field.type_key == _FieldID.TAGS_CONTENT.name: - has_content_tags = True - elif field.type_key == _FieldID.TAGS_META.name: - has_meta_tags = True - if has_content_tags and has_meta_tags: - color = "#28bb48" # Green - elif has_any_tags: - color = "#ffd63d" # Yellow - # color = '#95e345' # Yellow-Green - else: - # color = '#fa9a2c' # Yellow-Orange - color = "#ed8022" # Orange - else: - color = "#e22c3c" # Red + color = "#28bb48" if entry.tags else "#e22c3c" if data_only_mode: pic = Image.new("RGB", size, color) diff --git a/tagstudio/src/qt/widgets/fields.py b/tagstudio/src/qt/widgets/fields.py index dfcc89d00..f4fd6ae1f 100644 --- a/tagstudio/src/qt/widgets/fields.py +++ b/tagstudio/src/qt/widgets/fields.py @@ -1,18 +1,18 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio import math from pathlib import Path -from types import MethodType -from typing import Callable, Optional +from typing import Callable +from warnings import catch_warnings from PIL import Image, ImageQt from PySide6.QtCore import QEvent, Qt from PySide6.QtGui import QEnterEvent, QPixmap -from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget -from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper +from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget +from src.core.enums import Theme class FieldContainer(QWidget): @@ -32,6 +32,19 @@ class FieldContainer(QWidget): ).resize((math.floor(24 * 1.25), math.floor(24 * 1.25))) trash_icon_128.load() + # TODO: There should be a global button theme somewhere. + container_style = ( + f"QWidget#fieldContainer{{" + "border-radius:4px;" + f"}}" + f"QWidget#fieldContainer::hover{{" + f"background-color:{Theme.COLOR_HOVER.value};" + f"}}" + f"QWidget#fieldContainer::pressed{{" + f"background-color:{Theme.COLOR_PRESSED.value};" + f"}}" + ) + def __init__(self, title: str = "Field", inline: bool = True) -> None: super().__init__() self.setObjectName("fieldContainer") @@ -48,12 +61,12 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None: self.inner_layout = QVBoxLayout() self.inner_layout.setObjectName("innerLayout") - self.inner_layout.setContentsMargins(0, 0, 0, 0) + self.inner_layout.setContentsMargins(6, 0, 6, 6) self.inner_layout.setSpacing(0) - self.inner_container = QWidget() - self.inner_container.setObjectName("innerContainer") - self.inner_container.setLayout(self.inner_layout) - self.root_layout.addWidget(self.inner_container) + self.field_container = QWidget() + self.field_container.setObjectName("fieldContainer") + self.field_container.setLayout(self.inner_layout) + self.root_layout.addWidget(self.field_container) self.title_container = QWidget() self.title_layout = QHBoxLayout(self.title_container) @@ -67,12 +80,12 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None: self.title_widget.setMinimumHeight(button_size) self.title_widget.setObjectName("fieldTitle") self.title_widget.setWordWrap(True) - self.title_widget.setStyleSheet("font-weight: bold; font-size: 14px;") self.title_widget.setText(title) self.title_layout.addWidget(self.title_widget) self.title_layout.addStretch(2) - self.copy_button = QPushButtonWrapper() + self.copy_button = QPushButton() + self.copy_button.setObjectName("copyButton") self.copy_button.setMinimumSize(button_size, button_size) self.copy_button.setMaximumSize(button_size, button_size) self.copy_button.setFlat(True) @@ -81,7 +94,8 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None: self.title_layout.addWidget(self.copy_button) self.copy_button.setHidden(True) - self.edit_button = QPushButtonWrapper() + self.edit_button = QPushButton() + self.edit_button.setObjectName("editButton") self.edit_button.setMinimumSize(button_size, button_size) self.edit_button.setMaximumSize(button_size, button_size) self.edit_button.setFlat(True) @@ -90,7 +104,8 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None: self.title_layout.addWidget(self.edit_button) self.edit_button.setHidden(True) - self.remove_button = QPushButtonWrapper() + self.remove_button = QPushButton() + self.remove_button.setObjectName("removeButton") self.remove_button.setMinimumSize(button_size, button_size) self.remove_button.setMaximumSize(button_size, button_size) self.remove_button.setFlat(True) @@ -99,37 +114,39 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None: self.title_layout.addWidget(self.remove_button) self.remove_button.setHidden(True) - self.field_container = QWidget() - self.field_container.setObjectName("fieldContainer") + self.field = QWidget() + self.field.setObjectName("field") self.field_layout = QHBoxLayout() self.field_layout.setObjectName("fieldLayout") self.field_layout.setContentsMargins(0, 0, 0, 0) - self.field_container.setLayout(self.field_layout) - self.inner_layout.addWidget(self.field_container) + self.field.setLayout(self.field_layout) + self.inner_layout.addWidget(self.field) + + self.setStyleSheet(FieldContainer.container_style) - def set_copy_callback(self, callback: Optional[MethodType]): - if self.copy_button.is_connected: + def set_copy_callback(self, callback: Callable | None = None): + with catch_warnings(record=True): self.copy_button.clicked.disconnect() self.copy_callback = callback - self.copy_button.clicked.connect(callback) - self.copy_button.is_connected = callable(callback) + if callback: + self.copy_button.clicked.connect(callback) - def set_edit_callback(self, callback: Callable): - if self.edit_button.is_connected: + def set_edit_callback(self, callback: Callable | None = None): + with catch_warnings(record=True): self.edit_button.clicked.disconnect() self.edit_callback = callback - self.edit_button.clicked.connect(callback) - self.edit_button.is_connected = callable(callback) + if callback: + self.edit_button.clicked.connect(callback) - def set_remove_callback(self, callback: Callable): - if self.remove_button.is_connected: + def set_remove_callback(self, callback: Callable | None = None): + with catch_warnings(record=True): self.remove_button.clicked.disconnect() self.remove_callback = callback - self.remove_button.clicked.connect(callback) - self.remove_button.is_connected = callable(callback) + if callback: + self.remove_button.clicked.connect(callback) def set_inner_widget(self, widget: "FieldWidget"): if self.field_layout.itemAt(0): @@ -145,8 +162,8 @@ def get_inner_widget(self): return None def set_title(self, title: str): - self.title = title - self.title_widget.setText(title) + self.title = self.title = f"

{title}

" + self.title_widget.setText(self.title) def set_inline(self, inline: bool): self.inline = inline diff --git a/tagstudio/src/qt/widgets/item_thumb.py b/tagstudio/src/qt/widgets/item_thumb.py index f79708ad4..adb05dfce 100644 --- a/tagstudio/src/qt/widgets/item_thumb.py +++ b/tagstudio/src/qt/widgets/item_thumb.py @@ -1,4 +1,4 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio import time @@ -7,6 +7,7 @@ from functools import wraps from pathlib import Path from typing import TYPE_CHECKING +from warnings import catch_warnings import structlog from PIL import Image, ImageQt @@ -24,8 +25,7 @@ TAG_ARCHIVED, TAG_FAVORITE, ) -from src.core.library import Entry, ItemType, Library -from src.core.library.alchemy.fields import _FieldID +from src.core.library import ItemType, Library from src.core.media_types import MediaCategories, MediaType from src.qt.flowlayout import FlowWidget from src.qt.helpers.file_opener import FileOpenerHelper @@ -119,11 +119,9 @@ def __init__( library: Library, driver: "QtDriver", thumb_size: tuple[int, int], - grid_idx: int, show_filename_label: bool = False, ): super().__init__() - self.grid_idx = grid_idx self.lib = library self.mode: ItemType = mode self.driver = driver @@ -206,10 +204,10 @@ def __init__( self.thumb_button = ThumbButton(self.thumb_container, thumb_size) self.renderer = ThumbRenderer() self.renderer.updated.connect( - lambda ts, i, s, fn, ext: ( - self.update_thumb(ts, image=i), - self.update_size(ts, size=s), - self.set_filename_text(fn), + lambda timestamp, image, size, filename, ext: ( + self.update_thumb(timestamp, image=image), + self.update_size(timestamp, size=size), + self.set_filename_text(filename), self.set_extension(ext), ) ) @@ -325,7 +323,7 @@ def is_favorite(self) -> bool: return self.badge_active[BadgeType.FAVORITE] @property - def is_archived(self): + def is_archived(self) -> bool: return self.badge_active[BadgeType.ARCHIVED] def set_mode(self, mode: ItemType | None) -> None: @@ -399,8 +397,9 @@ def set_count(self, count: str) -> None: self.ext_badge.setHidden(True) self.count_badge.setHidden(True) - def set_filename_text(self, filename: Path | str | None): - self.file_label.setText(str(filename)) + def set_filename_text(self, filename: Path | None): + self.set_item_path(filename) + self.file_label.setText(str(filename.name)) def set_filename_visibility(self, set_visible: bool): """Toggle the visibility of the filename label. @@ -439,30 +438,17 @@ def update_size(self, timestamp: float, size: QSize): def update_clickable(self, clickable: typing.Callable): """Updates attributes of a thumbnail element.""" - if self.thumb_button.is_connected: - self.thumb_button.pressed.disconnect() if clickable: + with catch_warnings(record=True): + self.thumb_button.pressed.disconnect() self.thumb_button.pressed.connect(clickable) - self.thumb_button.is_connected = True - def refresh_badge(self, entry: Entry | None = None): - if not entry: - if not self.item_id: - logger.error("missing both entry and item_id") - return None + def set_item_id(self, item_id: int): + self.item_id = item_id - entry = self.lib.get_entry(self.item_id) - if not entry: - logger.error("Entry not found", item_id=self.item_id) - return - - self.assign_badge(BadgeType.ARCHIVED, entry.is_archived) - self.assign_badge(BadgeType.FAVORITE, entry.is_favorited) - - def set_item_id(self, entry: Entry): - filepath = self.lib.library_dir / entry.path - self.opener.set_filepath(filepath) - self.item_id = entry.id + def set_item_path(self, path: Path | str | None): + """Set the absolute filepath for the item. Used for locating on disk.""" + self.opener.set_filepath(path) def assign_badge(self, badge_type: BadgeType, value: bool) -> None: mode = self.mode @@ -496,47 +482,22 @@ def on_badge_check(self, badge_type: BadgeType): return toggle_value = self.badges[badge_type].isChecked() - self.badge_active[badge_type] = toggle_value - tag_id = BADGE_TAGS[badge_type] - - # check if current item is selected. if so, update all selected items - if self.grid_idx in self.driver.selected: - update_items = self.driver.selected - else: - update_items = [self.grid_idx] - - for idx in update_items: - entry = self.driver.frame_content[idx] - self.toggle_item_tag( - entry, toggle_value, tag_id, _FieldID.TAGS_META.name, create_field=True - ) - # update the entry - self.driver.frame_content[idx] = self.lib.get_entry_full(entry.id) - - self.driver.update_badges(update_items) + badge_values: dict[BadgeType, bool] = {badge_type: toggle_value} + self.driver.update_badges(badge_values, self.item_id) def toggle_item_tag( self, - entry: Entry, + entry_id: int, toggle_value: bool, tag_id: int, - field_key: str, - create_field: bool = False, ): - logger.info( - "toggle_item_tag", - entry_id=entry.id, - toggle_value=toggle_value, - tag_id=tag_id, - field_key=field_key, - ) + logger.info("toggle_item_tag", entry_id=entry_id, toggle_value=toggle_value, tag_id=tag_id) - tag = self.lib.get_tag(tag_id) if toggle_value: - self.lib.add_field_tag(entry, tag, field_key, create_field) + self.lib.add_tags_to_entry(entry_id, tag_id) else: - self.lib.remove_field_tag(entry, tag.id, field_key) + self.lib.remove_tags_from_entry(entry_id, tag_id) if self.driver.preview_panel.is_open: self.driver.preview_panel.update_widgets() @@ -549,13 +510,13 @@ def mouseMoveEvent(self, event): # noqa: N802 paths = [] mimedata = QMimeData() - selected_idxs = self.driver.selected - if self.grid_idx not in selected_idxs: - selected_idxs = [self.grid_idx] + selected_ids = self.driver.selected + if self.item_id not in selected_ids: + selected_ids = [self.item_id] - for grid_idx in selected_idxs: - id = self.driver.item_thumbs[grid_idx].item_id - entry = self.lib.get_entry(id) + for selected_id in selected_ids: + item_id = self.driver.item_thumbs[selected_id].item_id + entry = self.lib.get_entry(item_id) if not entry: continue @@ -565,4 +526,4 @@ def mouseMoveEvent(self, event): # noqa: N802 mimedata.setUrls(paths) drag.setMimeData(mimedata) drag.exec(Qt.DropAction.CopyAction) - logger.info("dragged files to external program", thumbnail_indexs=selected_idxs) + logger.info("dragged files to external program", thumbnail_indexs=selected_ids) diff --git a/tagstudio/src/qt/widgets/migration_modal.py b/tagstudio/src/qt/widgets/migration_modal.py index 6e8aeb06b..954ad4722 100644 --- a/tagstudio/src/qt/widgets/migration_modal.py +++ b/tagstudio/src/qt/widgets/migration_modal.py @@ -1,4 +1,4 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio @@ -17,16 +17,17 @@ QVBoxLayout, QWidget, ) -from sqlalchemy import and_, select +from sqlalchemy import select from sqlalchemy.orm import Session -from src.core.constants import TS_FOLDER_NAME +from src.core.constants import LEGACY_TAG_FIELD_IDS, TS_FOLDER_NAME from src.core.enums import LibraryPrefs -from src.core.library.alchemy.enums import FieldTypeEnum, TagColor -from src.core.library.alchemy.fields import TagBoxField, _FieldID -from src.core.library.alchemy.joins import TagField, TagSubtag +from src.core.library.alchemy.enums import TagColor +from src.core.library.alchemy.joins import TagParent +from src.core.library.alchemy.library import TAG_ARCHIVED, TAG_FAVORITE, TAG_META from src.core.library.alchemy.library import Library as SqliteLibrary -from src.core.library.alchemy.models import Entry, Tag, TagAlias +from src.core.library.alchemy.models import Entry, TagAlias from src.core.library.json.library import Library as JsonLibrary # type: ignore +from src.core.library.json.library import Tag as JsonTag # type: ignore from src.qt.helpers.custom_runnable import CustomRunnable from src.qt.helpers.function_iterator import FunctionIterator from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper @@ -115,7 +116,7 @@ def init_page_convert(self) -> None: entries_text: str = Translations["json_migration.heading.entires"] tags_text: str = Translations["json_migration.heading.tags"] shorthand_text: str = tab + Translations["json_migration.heading.shorthands"] - subtags_text: str = tab + Translations["json_migration.heading.parent_tags"] + parent_tags_text: str = tab + Translations["json_migration.heading.parent_tags"] aliases_text: str = tab + Translations["json_migration.heading.aliases"] colors_text: str = tab + Translations["json_migration.heading.colors"] ext_text: str = Translations["json_migration.heading.file_extension_list"] @@ -129,7 +130,7 @@ def init_page_convert(self) -> None: self.fields_row: int = 2 self.tags_row: int = 3 self.shorthands_row: int = 4 - self.subtags_row: int = 5 + self.parent_tags_row: int = 5 self.aliases_row: int = 6 self.colors_row: int = 7 self.ext_row: int = 8 @@ -151,7 +152,7 @@ def init_page_convert(self) -> None: self.old_content_layout.addWidget(QLabel(field_parity_text), self.fields_row, 0) self.old_content_layout.addWidget(QLabel(tags_text), self.tags_row, 0) self.old_content_layout.addWidget(QLabel(shorthand_text), self.shorthands_row, 0) - self.old_content_layout.addWidget(QLabel(subtags_text), self.subtags_row, 0) + self.old_content_layout.addWidget(QLabel(parent_tags_text), self.parent_tags_row, 0) self.old_content_layout.addWidget(QLabel(aliases_text), self.aliases_row, 0) self.old_content_layout.addWidget(QLabel(colors_text), self.colors_row, 0) self.old_content_layout.addWidget(QLabel(ext_text), self.ext_row, 0) @@ -183,7 +184,7 @@ def init_page_convert(self) -> None: self.old_content_layout.addWidget(old_field_value, self.fields_row, 1) self.old_content_layout.addWidget(old_tag_count, self.tags_row, 1) self.old_content_layout.addWidget(old_shorthand_count, self.shorthands_row, 1) - self.old_content_layout.addWidget(old_subtag_value, self.subtags_row, 1) + self.old_content_layout.addWidget(old_subtag_value, self.parent_tags_row, 1) self.old_content_layout.addWidget(old_alias_value, self.aliases_row, 1) self.old_content_layout.addWidget(old_color_value, self.colors_row, 1) self.old_content_layout.addWidget(old_ext_count, self.ext_row, 1) @@ -192,7 +193,7 @@ def init_page_convert(self) -> None: self.old_content_layout.addWidget(QLabel(), self.path_row, 2) self.old_content_layout.addWidget(QLabel(), self.fields_row, 2) self.old_content_layout.addWidget(QLabel(), self.shorthands_row, 2) - self.old_content_layout.addWidget(QLabel(), self.subtags_row, 2) + self.old_content_layout.addWidget(QLabel(), self.parent_tags_row, 2) self.old_content_layout.addWidget(QLabel(), self.aliases_row, 2) self.old_content_layout.addWidget(QLabel(), self.colors_row, 2) @@ -214,7 +215,7 @@ def init_page_convert(self) -> None: self.new_content_layout.addWidget(QLabel(field_parity_text), self.fields_row, 0) self.new_content_layout.addWidget(QLabel(tags_text), self.tags_row, 0) self.new_content_layout.addWidget(QLabel(shorthand_text), self.shorthands_row, 0) - self.new_content_layout.addWidget(QLabel(subtags_text), self.subtags_row, 0) + self.new_content_layout.addWidget(QLabel(parent_tags_text), self.parent_tags_row, 0) self.new_content_layout.addWidget(QLabel(aliases_text), self.aliases_row, 0) self.new_content_layout.addWidget(QLabel(colors_text), self.colors_row, 0) self.new_content_layout.addWidget(QLabel(ext_text), self.ext_row, 0) @@ -246,7 +247,7 @@ def init_page_convert(self) -> None: self.new_content_layout.addWidget(field_parity_value, self.fields_row, 1) self.new_content_layout.addWidget(new_tag_count, self.tags_row, 1) self.new_content_layout.addWidget(new_shorthand_count, self.shorthands_row, 1) - self.new_content_layout.addWidget(subtag_parity_value, self.subtags_row, 1) + self.new_content_layout.addWidget(subtag_parity_value, self.parent_tags_row, 1) self.new_content_layout.addWidget(alias_parity_value, self.aliases_row, 1) self.new_content_layout.addWidget(new_color_value, self.colors_row, 1) self.new_content_layout.addWidget(new_ext_count, self.ext_row, 1) @@ -257,7 +258,7 @@ def init_page_convert(self) -> None: self.new_content_layout.addWidget(QLabel(), self.fields_row, 2) self.new_content_layout.addWidget(QLabel(), self.shorthands_row, 2) self.new_content_layout.addWidget(QLabel(), self.tags_row, 2) - self.new_content_layout.addWidget(QLabel(), self.subtags_row, 2) + self.new_content_layout.addWidget(QLabel(), self.parent_tags_row, 2) self.new_content_layout.addWidget(QLabel(), self.aliases_row, 2) self.new_content_layout.addWidget(QLabel(), self.colors_row, 2) self.new_content_layout.addWidget(QLabel(), self.ext_row, 2) @@ -283,7 +284,6 @@ def init_page_convert(self) -> None: Translations.translate_qobject(start_button, "json_migration.start_and_preview") start_button.setMinimumWidth(120) start_button.clicked.connect(self.migrate) - start_button.clicked.connect(lambda: finish_button.setDisabled(False)) start_button.clicked.connect(lambda: start_button.setDisabled(True)) finish_button: QPushButtonWrapper = QPushButtonWrapper() Translations.translate_qobject(finish_button, "json_migration.finish_migration") @@ -311,6 +311,7 @@ def migrate(self, skip_ui: bool = False): # Open the JSON Library self.json_lib = JsonLibrary() self.json_lib.open_library(self.path) + self.update_json_builtins() # Update JSON UI self.update_json_entry_count(len(self.json_lib.entries)) @@ -321,6 +322,26 @@ def migrate(self, skip_ui: bool = False): self.migration_progress(skip_ui=skip_ui) self.is_migration_initialized = True + def update_json_builtins(self): + """Updates the built-in JSON values to include any future changes or additions. + + Used to preserve user-modified built-in tags and to + match values between JSON and SQL during parity checking. + """ + # v9.5.0: Add "Meta Tags" tag and parent that to "Archived" and "Favorite". + meta_tags: JsonTag = JsonTag(TAG_META, "Meta Tags", "", ["Meta", "Meta Tag"], [], "") + # self.json_lib.add_tag_to_library(meta_tags) + self.json_lib.tags.append(meta_tags) + self.json_lib._map_tag_id_to_index(meta_tags, len(self.json_lib.tags) - 1) + + archived_tag: JsonTag = self.json_lib.get_tag(TAG_ARCHIVED) + archived_tag.subtag_ids.append(TAG_META) + self.json_lib.update_tag(archived_tag) + + favorite_tag: JsonTag = self.json_lib.get_tag(TAG_FAVORITE) + favorite_tag.subtag_ids.append(TAG_META) + self.json_lib.update_tag(favorite_tag) + def migration_progress(self, skip_ui: bool = False): """Initialize the progress bar and iterator for the library migration.""" pb = QProgressDialog( @@ -350,6 +371,8 @@ def migration_progress(self, skip_ui: bool = False): self.update_sql_value_ui(show_msg_box=not skip_ui), pb.setMinimum(1), pb.setValue(1), + # Enable the finish button + self.stack[1].buttons[4].setDisabled(False), # type: ignore ) ) QThreadPool.globalInstance().start(r) @@ -367,9 +390,7 @@ def migration_iterator(self): if self.temp_path.exists(): logger.info('Temporary migration file "temp_path" already exists. Removing...') self.temp_path.unlink() - self.sql_lib.open_sqlite_library( - self.json_lib.library_dir, is_new=True, add_default_data=False - ) + self.sql_lib.open_sqlite_library(self.json_lib.library_dir, is_new=True) yield Translations.translate_formatted( "json_migration.migrating_files_entries", entries=len(self.json_lib.entries) ) @@ -382,15 +403,19 @@ def migration_iterator(self): check_set.add(self.check_subtag_parity()) check_set.add(self.check_alias_parity()) check_set.add(self.check_color_parity()) - self.update_parity_ui() if False not in check_set: yield Translations["json_migration.migration_complete"] else: yield Translations["json_migration.migration_complete_with_discrepancies"] + self.update_parity_ui() + QApplication.beep() + QApplication.alert(self.paged_panel) self.done = True except Exception as e: yield f"Error: {type(e).__name__}" + QApplication.beep() + QApplication.alert(self.paged_panel) self.done = True def update_parity_ui(self): @@ -398,7 +423,7 @@ def update_parity_ui(self): self.update_parity_value(self.fields_row, self.field_parity) self.update_parity_value(self.path_row, self.path_parity) self.update_parity_value(self.shorthands_row, self.shorthand_parity) - self.update_parity_value(self.subtags_row, self.subtag_parity) + self.update_parity_value(self.parent_tags_row, self.subtag_parity) self.update_parity_value(self.aliases_row, self.alias_parity) self.update_parity_value(self.colors_row, self.color_parity) self.sql_lib.close() @@ -429,7 +454,7 @@ def update_sql_value_ui(self, show_msg_box: bool = True): if self.discrepancies: logger.warning("Discrepancies found:") logger.warning("\n".join(self.discrepancies)) - QApplication.beep() + QApplication.alert(self.paged_panel) if not show_msg_box: return msg_box = QMessageBox() @@ -498,28 +523,10 @@ def color_value_conditional(self, old_value: int | str, new_value: int | str) -> return str(f"{new_value}") def check_field_parity(self) -> bool: - """Check if all JSON field data matches the new SQL field data.""" - - def sanitize_field(session, entry: Entry, value, type, type_key): - if type is FieldTypeEnum.TAGS: - tags = list( - session.scalars( - select(Tag.id) - .join(TagField) - .join(TagBoxField) - .where( - and_( - TagBoxField.entry_id == entry.id, - TagBoxField.id == TagField.field_id, - TagBoxField.type_key == type_key, - ) - ) - ) - ) + """Check if all JSON field and tag data matches the new SQL data.""" - return set(tags) if tags else None - else: - return value if value else None + def sanitize_field(entry: Entry, value, type, type_key): + return value if value else None def sanitize_json_field(value): if isinstance(value, list): @@ -527,107 +534,68 @@ def sanitize_json_field(value): else: return value if value else None - with Session(self.sql_lib.engine) as session: - for json_entry in self.json_lib.entries: - sql_fields: list[tuple] = [] - json_fields: list[tuple] = [] + for json_entry in self.json_lib.entries: + sql_fields: list[tuple] = [] + json_fields: list[tuple] = [] - sql_entry: Entry = session.scalar( - select(Entry).where(Entry.id == json_entry.id + 1) + sql_entry: Entry = self.sql_lib.get_entry_full(json_entry.id + 1) + if not sql_entry: + logger.info( + "[Field Comparison]", + message=f"NEW (SQL): SQL Entry ID mismatch: {json_entry.id+1}", ) - if not sql_entry: - logger.info( - "[Field Comparison]", - message=f"NEW (SQL): SQL Entry ID mismatch: {json_entry.id+1}", - ) - self.discrepancies.append( - f"[Field Comparison]:\nNEW (SQL): SQL Entry ID not found: {json_entry.id+1}" - ) - self.field_parity = False - return self.field_parity + self.discrepancies.append( + f"[Field Comparison]:\nNEW (SQL): SQL Entry ID not found: {json_entry.id+1}" + ) + self.field_parity = False + return self.field_parity - for sf in sql_entry.fields: + for sf in sql_entry.fields: + if sf.type.type.value not in LEGACY_TAG_FIELD_IDS: sql_fields.append( ( sql_entry.id, sf.type.key, - sanitize_field(session, sql_entry, sf.value, sf.type.type, sf.type_key), + sanitize_field(sql_entry, sf.value, sf.type.type, sf.type_key), ) ) - sql_fields.sort() - - # NOTE: The JSON database allowed for separate tag fields of the same type with - # different values. The SQL database does not, and instead merges these values - # across all instances of that field on an entry. - # TODO: ROADMAP: "Tag Categories" will merge all field tags onto the entry. - # All visual separation from there will be data-driven from the tag itself. - meta_tags_count: int = 0 - content_tags_count: int = 0 - tags_count: int = 0 - merged_meta_tags: set[int] = set() - merged_content_tags: set[int] = set() - merged_tags: set[int] = set() - for jf in json_entry.fields: - key: str = self.sql_lib.get_field_name_from_id(list(jf.keys())[0]).name - value = sanitize_json_field(list(jf.values())[0]) - - if key == _FieldID.TAGS_META.name: - meta_tags_count += 1 - merged_meta_tags = merged_meta_tags.union(value or []) - elif key == _FieldID.TAGS_CONTENT.name: - content_tags_count += 1 - merged_content_tags = merged_content_tags.union(value or []) - elif key == _FieldID.TAGS.name: - tags_count += 1 - merged_tags = merged_tags.union(value or []) - else: - # JSON IDs start at 0 instead of 1 - json_fields.append((json_entry.id + 1, key, value)) - - if meta_tags_count: - for _ in range(0, meta_tags_count): - json_fields.append( - ( - json_entry.id + 1, - _FieldID.TAGS_META.name, - merged_meta_tags if merged_meta_tags else None, - ) - ) - if content_tags_count: - for _ in range(0, content_tags_count): - json_fields.append( - ( - json_entry.id + 1, - _FieldID.TAGS_CONTENT.name, - merged_content_tags if merged_content_tags else None, - ) - ) - if tags_count: - for _ in range(0, tags_count): - json_fields.append( - ( - json_entry.id + 1, - _FieldID.TAGS.name, - merged_tags if merged_tags else None, - ) - ) - json_fields.sort() + sql_fields.sort() + + # NOTE: The JSON database stored tags inside of special "tag field" types which + # no longer exist. The SQL database instead associates tags directly with entries. + tags_count: int = 0 + json_tags: set[int] = set() + for jf in json_entry.fields: + int_key: int = list(jf.keys())[0] + value = sanitize_json_field(list(jf.values())[0]) + if int_key in LEGACY_TAG_FIELD_IDS: + tags_count += 1 + json_tags = json_tags.union(value or []) + else: + key: str = self.sql_lib.get_field_name_from_id(int_key).name + json_fields.append((json_entry.id + 1, key, value)) + json_fields.sort() + + sql_tags = {t.id for t in sql_entry.tags} - if not ( - json_fields is not None - and sql_fields is not None - and (json_fields == sql_fields) - ): - self.discrepancies.append( - f"[Field Comparison]:\nOLD (JSON):{json_fields}\nNEW (SQL):{sql_fields}" - ) - self.field_parity = False - return self.field_parity - - logger.info( - "[Field Comparison]", - fields="\n".join([str(x) for x in zip(json_fields, sql_fields)]), + if not ( + json_fields is not None + and sql_fields is not None + and (json_fields == sql_fields) + and (json_tags == sql_tags) + ): + self.discrepancies.append( + f"[Field Comparison]:\n" + f"OLD (JSON):{json_fields}\n{json_tags}\n" + f"NEW (SQL):{sql_fields}\n{sql_tags}" ) + self.field_parity = False + return self.field_parity + + logger.info( + "[Field Comparison]", + fields="\n".join([str(x) for x in zip(json_fields, sql_fields)]), + ) self.field_parity = True return self.field_parity @@ -643,35 +611,36 @@ def check_path_parity(self) -> bool: return self.path_parity def check_subtag_parity(self) -> bool: - """Check if all JSON subtags match the new SQL subtags.""" - sql_subtags: set[int] = None - json_subtags: set[int] = None + """Check if all JSON parent tags match the new SQL parent tags.""" + sql_parent_tags: set[int] = None + json_parent_tags: set[int] = None with Session(self.sql_lib.engine) as session: for tag in self.sql_lib.tags: tag_id = tag.id # Tag IDs start at 0 - sql_subtags = set( - session.scalars(select(TagSubtag.child_id).where(TagSubtag.parent_id == tag.id)) + sql_parent_tags = set( + session.scalars(select(TagParent.child_id).where(TagParent.parent_id == tag.id)) ) + # JSON tags allowed self-parenting; SQL tags no longer allow this. - json_subtags = set(self.json_lib.get_tag(tag_id).subtag_ids).difference( - set([self.json_lib.get_tag(tag_id).id]) - ) + json_parent_tags = set(self.json_lib.get_tag(tag_id).subtag_ids) + json_parent_tags.discard(tag_id) logger.info( "[Subtag Parity]", tag_id=tag_id, - json_subtags=json_subtags, - sql_subtags=sql_subtags, + json_parent_tags=json_parent_tags, + sql_parent_tags=sql_parent_tags, ) if not ( - sql_subtags is not None - and json_subtags is not None - and (sql_subtags == json_subtags) + sql_parent_tags is not None + and json_parent_tags is not None + and (sql_parent_tags == json_parent_tags) ): self.discrepancies.append( - f"[Subtag Parity]:\nOLD (JSON):{json_subtags}\nNEW (SQL):{sql_subtags}" + f"[Subtag Parity][Tag ID: {tag_id}]:" + f"\nOLD (JSON):{json_parent_tags}\nNEW (SQL):{sql_parent_tags}" ) self.subtag_parity = False return self.subtag_parity @@ -707,7 +676,8 @@ def check_alias_parity(self) -> bool: and (sql_aliases == json_aliases) ): self.discrepancies.append( - f"[Alias Parity]:\nOLD (JSON):{json_aliases}\nNEW (SQL):{sql_aliases}" + f"[Alias Parity][Tag ID: {tag_id}]:" + f"\nOLD (JSON):{json_aliases}\nNEW (SQL):{sql_aliases}" ) self.alias_parity = False return self.alias_parity @@ -720,10 +690,14 @@ def check_shorthand_parity(self) -> bool: sql_shorthand: str = None json_shorthand: str = None + def sanitize(value): + """Return value or convert a "not" value into None.""" + return value if value else None + for tag in self.sql_lib.tags: tag_id = tag.id # Tag IDs start at 0 - sql_shorthand = tag.shorthand - json_shorthand = self.json_lib.get_tag(tag_id).shorthand + sql_shorthand = sanitize(tag.shorthand) + json_shorthand = sanitize(self.json_lib.get_tag(tag_id).shorthand) logger.info( "[Shorthand Parity]", @@ -732,13 +706,10 @@ def check_shorthand_parity(self) -> bool: sql_shorthand=sql_shorthand, ) - if not ( - sql_shorthand is not None - and json_shorthand is not None - and (sql_shorthand == json_shorthand) - ): + if sql_shorthand != json_shorthand: self.discrepancies.append( - f"[Shorthand Parity]:\nOLD (JSON):{json_shorthand}\nNEW (SQL):{sql_shorthand}" + f"[Shorthand Parity][Tag ID: {tag_id}]:" + f"\nOLD (JSON):{json_shorthand}\nNEW (SQL):{sql_shorthand}" ) self.shorthand_parity = False return self.shorthand_parity @@ -756,7 +727,7 @@ def check_color_parity(self) -> bool: sql_color = tag.color.name json_color = ( TagColor.get_color_from_str(self.json_lib.get_tag(tag_id).color).name - if self.json_lib.get_tag(tag_id).color != "" + if (self.json_lib.get_tag(tag_id).color) != "" else TagColor.DEFAULT.name ) @@ -769,7 +740,8 @@ def check_color_parity(self) -> bool: if not (sql_color is not None and json_color is not None and (sql_color == json_color)): self.discrepancies.append( - f"[Color Parity]:\nOLD (JSON):{json_color}\nNEW (SQL):{sql_color}" + f"[Color Parity][Tag ID: {tag_id}]:" + f"\nOLD (JSON):{json_color}\nNEW (SQL):{sql_color}" ) self.color_parity = False return self.color_parity diff --git a/tagstudio/src/qt/widgets/preview/field_containers.py b/tagstudio/src/qt/widgets/preview/field_containers.py new file mode 100644 index 000000000..5c1cde83e --- /dev/null +++ b/tagstudio/src/qt/widgets/preview/field_containers.py @@ -0,0 +1,520 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import sys +import typing +from collections.abc import Callable +from datetime import datetime as dt +from warnings import catch_warnings + +import structlog +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QGuiApplication +from PySide6.QtWidgets import ( + QFrame, + QHBoxLayout, + QMessageBox, + QScrollArea, + QSizePolicy, + QVBoxLayout, + QWidget, +) +from src.core.constants import ( + TAG_ARCHIVED, + TAG_FAVORITE, +) +from src.core.enums import Theme +from src.core.library.alchemy.fields import ( + BaseField, + DatetimeField, + FieldTypeEnum, + TextField, +) +from src.core.library.alchemy.library import Library +from src.core.library.alchemy.models import Entry, Tag +from src.qt.translations import Translations +from src.qt.widgets.fields import FieldContainer +from src.qt.widgets.panel import PanelModal +from src.qt.widgets.tag_box import TagBoxWidget +from src.qt.widgets.text import TextWidget +from src.qt.widgets.text_box_edit import EditTextBox +from src.qt.widgets.text_line_edit import EditTextLine + +if typing.TYPE_CHECKING: + from src.qt.ts_qt import QtDriver + +logger = structlog.get_logger(__name__) + + +class FieldContainers(QWidget): + """The Preview Panel Widget.""" + + favorite_updated = Signal(bool) + archived_updated = Signal(bool) + + def __init__(self, library: Library, driver: "QtDriver"): + super().__init__() + + self.lib = library + self.driver: QtDriver = driver + self.initialized = False + self.is_open: bool = False + self.common_fields: list = [] + self.mixed_fields: list = [] + self.cached_entries: list[Entry] = [] + self.containers: list[FieldContainer] = [] + + self.panel_bg_color = ( + Theme.COLOR_BG_DARK.value + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else Theme.COLOR_BG_LIGHT.value + ) + + self.scroll_layout = QVBoxLayout() + self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + self.scroll_layout.setContentsMargins(3, 3, 3, 3) + self.scroll_layout.setSpacing(0) + + scroll_container: QWidget = QWidget() + scroll_container.setObjectName("entryScrollContainer") + scroll_container.setLayout(self.scroll_layout) + + info_section = QWidget() + info_layout = QVBoxLayout(info_section) + info_layout.setContentsMargins(0, 0, 0, 0) + info_layout.setSpacing(0) + + self.scroll_area = QScrollArea() + self.scroll_area.setObjectName("entryScrollArea") + self.scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setFrameShadow(QFrame.Shadow.Plain) + self.scroll_area.setFrameShape(QFrame.Shape.NoFrame) + + # NOTE: I would rather have this style applied to the scroll_area + # background and NOT the scroll container background, so that the + # rounded corners are maintained when scrolling. I was unable to + # find the right trick to only select that particular element. + self.scroll_area.setStyleSheet( + "QWidget#entryScrollContainer{" + f"background:{self.panel_bg_color};" + "border-radius:6px;" + "}" + ) + self.scroll_area.setWidget(scroll_container) + + root_layout = QHBoxLayout(self) + root_layout.setContentsMargins(0, 0, 0, 0) + root_layout.addWidget(self.scroll_area) + + def update_from_entry(self, entry_id: int, update_badges: bool = True): + """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] + container_len: int = len(entry_.fields) + container_index = 0 + + # Write tag container(s) + if entry_.tags: + categories = self.get_tag_categories(entry_.tags) + for cat, tags in sorted(categories.items(), key=lambda kv: (kv[0] is None, kv)): + self.write_tag_container( + container_index, tags=tags, category_tag=cat, is_mixed=False + ) + container_index += 1 + container_len += 1 + if update_badges: + self.emit_badge_signals({t.id for t in entry_.tags}) + + # Write field container(s) + for index, field in enumerate(entry_.fields, start=container_index): + self.write_container(index, field, is_mixed=False) + + # Hide leftover container(s) + if len(self.containers) > container_len: + for i, c in enumerate(self.containers): + if i > (container_len - 1): + c.setHidden(True) + + def hide_containers(self): + """Hide all field and tag containers.""" + for c in self.containers: + c.setHidden(True) + + def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[Tag]]: + """Get a dictionary of category tags mapped to their respective tags.""" + cats: dict[Tag | None, set[Tag]] = {} + cats[None] = set() + + base_tag_ids: set[int] = {x.id for x in tags} + exhausted: set[int] = set() + cluster_map: dict[int, set[int]] = {} + + def add_to_cluster(tag_id: int, p_ids: list[int] | None = None): + """Maps a Tag's child tags' IDs back to it's parent tag's ID. + + Example: + Tag: ["Johnny Bravo", Parent Tags: "Cartoon Network (TV)", "Character"] maps to: + "Cartoon Network" -> Johnny Bravo, + "Character" -> "Johnny Bravo", + "TV" -> Johnny Bravo" + """ + tag_obj = self.lib.get_tag(tag_id) # Get full object + if p_ids is None: + p_ids = tag_obj.parent_ids + + for p_id in p_ids: + if cluster_map.get(p_id) is None: + cluster_map[p_id] = set() + # If the p_tag has p_tags of its own, recursively link those to the original Tag. + 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 + if p_tag.parent_ids: + add_to_cluster( + tag_id, + [sub_id for sub_id in p_tag.parent_ids if sub_id != tag_id], + ) + exhausted.add(p_id) + exhausted.add(tag_id) + + for tag in tags: + add_to_cluster(tag.id) + + logger.info("[FieldContainers] Entry Cluster", entry_cluster=exhausted) + logger.info("[FieldContainers] Cluster Map", cluster_map=cluster_map) + + # Initialize all categories from parents. + tags_ = {self.lib.get_tag(x) for x in exhausted} + for tag in tags_: + if tag.is_category: + cats[tag] = set() + logger.info("[FieldContainers] Blank Tag Categories", cats=cats) + + # Add tags to any applicable categories. + added_ids: set[int] = set() + for key in cats: + logger.info("[FieldContainers] Checking category tag key", key=key) + + if key: + logger.info( + "[FieldContainers] Key cluster:", key=key, cluster=cluster_map.get(key.id) + ) + + 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}) + + # 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} + logger.info( + f"[FieldContainers] [{key}] Key cluster: None, general case!", + general_tags=cats[key], + added=added_ids, + base_tag_ids=base_tag_ids, + ) + + # Remove unused categories + empty: list[Tag] = [] + for k, v in list(cats.items()): + if not v: + empty.append(k) + for key in empty: + cats.pop(key, None) + + logger.info("[FieldContainers] Tag Categories", categories=cats) + return cats + + def remove_field_prompt(self, name: str) -> str: + return Translations.translate_formatted("library.field.confirm_remove", name=name) + + def add_field_to_selected(self, field_list: list): + """Add list of entry fields to one or more selected items. + + Uses the current driver selection, NOT the field containers cache. + """ + logger.info( + "[FieldContainers][add_field_to_selected]", + selected=self.driver.selected, + fields=field_list, + ) + for entry_id in self.driver.selected: + for field_item in field_list: + self.lib.add_field_to_entry( + entry_id, + field_id=field_item.data(Qt.ItemDataRole.UserRole), + ) + + def add_tags_to_selected(self, tags: int | list[int]): + """Add list of tags to one or more selected items. + + Uses the current driver selection, NOT the field containers cache. + """ + if isinstance(tags, int): + tags = [tags] + logger.info( + "[FieldContainers][add_tags_to_selected]", + selected=self.driver.selected, + tags=tags, + ) + for entry_id in self.driver.selected: + self.lib.add_tags_to_entry( + entry_id, + tag_ids=tags, + ) + self.emit_badge_signals(tags, emit_on_absent=False) + + def write_container(self, index: int, field: BaseField, is_mixed: bool = False): + """Update/Create data for a FieldContainer. + + Args: + index(int): The container index. + field(BaseField): The type of field to write to. + is_mixed(bool): Relevant when multiple items are selected. + + If True, field is not present in all selected items. + """ + logger.info("[FieldContainers][write_field_container]", index=index) + if len(self.containers) < (index + 1): + container = FieldContainer() + self.containers.append(container) + self.scroll_layout.addWidget(container) + else: + container = self.containers[index] + + if field.type.type == FieldTypeEnum.TEXT_LINE: + container.set_title(field.type.name) + container.set_inline(False) + + # Normalize line endings in any text content. + if not is_mixed: + assert isinstance(field.value, (str, type(None))) + text = field.value or "" + else: + text = "Mixed Data" + + title = f"{field.type.name} ({field.type.type.value})" + inner_widget = TextWidget(title, text) + container.set_inner_widget(inner_widget) + if not is_mixed: + modal = PanelModal( + EditTextLine(field.value), + title=title, + window_title=f"Edit {field.type.type.value}", + save_callback=( + lambda content: ( + self.update_field(field, content), + self.update_from_entry(self.cached_entries[0].id), + ) + ), + ) + if "pytest" in sys.modules: + # for better testability + container.modal = modal # type: ignore + + container.set_edit_callback(modal.show) + container.set_remove_callback( + lambda: self.remove_message_box( + prompt=self.remove_field_prompt(field.type.type.value), + callback=lambda: ( + self.remove_field(field), + self.update_from_entry(self.cached_entries[0].id), + ), + ) + ) + + elif field.type.type == FieldTypeEnum.TEXT_BOX: + container.set_title(field.type.name) + container.set_inline(False) + # Normalize line endings in any text content. + if not is_mixed: + assert isinstance(field.value, (str, type(None))) + text = (field.value or "").replace("\r", "\n") + else: + text = "Mixed Data" + title = f"{field.type.name} (Text Box)" + inner_widget = TextWidget(title, text) + container.set_inner_widget(inner_widget) + if not is_mixed: + modal = PanelModal( + EditTextBox(field.value), + title=title, + window_title=f"Edit {field.type.name}", + save_callback=( + lambda content: ( + self.update_field(field, content), + 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), + callback=lambda: ( + self.remove_field(field), + self.update_from_entry(self.cached_entries[0].id), + ), + ) + ) + + elif field.type.type == FieldTypeEnum.DATETIME: + if not is_mixed: + 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) + title = f"{field.type.name} (Date) (Unknown Format)" + inner_widget = TextWidget(title, str(field.value)) + container.set_inner_widget(inner_widget) + + container.set_edit_callback() + container.set_remove_callback( + lambda: self.remove_message_box( + prompt=self.remove_field_prompt(field.type.name), + callback=lambda: ( + self.remove_field(field), + self.update_from_entry(self.cached_entries[0].id), + ), + ) + ) + else: + text = "Mixed Data" + title = f"{field.type.name} (Wacky Date)" + inner_widget = TextWidget(title, text) + container.set_inner_widget(inner_widget) + else: + logger.warning("[FieldContainers][write_container] Unknown Field", field=field) + container.set_title(field.type.name) + container.set_inline(False) + title = f"{field.type.name} (Unknown Field Type)" + inner_widget = TextWidget(title, field.type.name) + container.set_inner_widget(inner_widget) + container.set_remove_callback( + lambda: self.remove_message_box( + prompt=self.remove_field_prompt(field.type.name), + callback=lambda: ( + self.remove_field(field), + self.update_from_entry(self.cached_entries[0].id), + ), + ) + ) + + container.setHidden(False) + + def write_tag_container( + self, index: int, tags: set[Tag], category_tag: Tag | None = None, is_mixed: bool = False + ): + """Update/Create tag data for a FieldContainer. + + Args: + index(int): The container index. + tags(set[Tag]): The list of tags for this container. + category_tag(Tag|None): The category tag this container represents. + is_mixed(bool): Relevant when multiple items are selected. + + If True, field is not present in all selected items. + """ + logger.info("[FieldContainers][write_tag_container]", index=index) + if len(self.containers) < (index + 1): + container = FieldContainer() + self.containers.append(container) + self.scroll_layout.addWidget(container) + else: + container = self.containers[index] + + container.set_title("Tags" if not category_tag else category_tag.name) + container.set_inline(False) + + if not is_mixed: + inner_widget = container.get_inner_widget() + + if isinstance(inner_widget, TagBoxWidget): + inner_widget.set_tags(tags) + with catch_warnings(record=True): + inner_widget.updated.disconnect() + + else: + inner_widget = TagBoxWidget( + tags, + "Tags", + self.driver, + ) + container.set_inner_widget(inner_widget) + + inner_widget.updated.connect( + lambda: (self.update_from_entry(self.cached_entries[0].id, update_badges=True)) + ) + else: + text = "Mixed Data" + inner_widget = TextWidget("Mixed Tags", text) + container.set_inner_widget(inner_widget) + + container.set_edit_callback() + container.set_remove_callback() + container.setHidden(False) + + def remove_field(self, field: BaseField): + """Remove a field from all selected Entries.""" + logger.info( + "[FieldContainers] Removing Field", + field=field, + selected=[x.path for x in self.cached_entries], + ) + entry_ids = [e.id for e in self.cached_entries] + self.lib.remove_entry_field(field, entry_ids) + + def update_field(self, field: BaseField, content: str) -> None: + """Update a field in all selected Entries, given a field object.""" + assert isinstance( + field, + (TextField, DatetimeField), + ), f"instance: {type(field)}" + + entry_ids = [e.id for e in self.cached_entries] + + assert entry_ids, "No entries selected" + self.lib.update_entry_field( + entry_ids, + field, + content, + ) + + def remove_message_box(self, prompt: str, callback: Callable) -> None: + remove_mb = QMessageBox() + remove_mb.setText(prompt) + remove_mb.setWindowTitle("Remove Field") + remove_mb.setIcon(QMessageBox.Icon.Warning) + cancel_button = remove_mb.addButton( + Translations["generic.cancel_alt"], QMessageBox.ButtonRole.DestructiveRole + ) + remove_mb.addButton("&Remove", QMessageBox.ButtonRole.RejectRole) + remove_mb.setDefaultButton(cancel_button) + remove_mb.setEscapeButton(cancel_button) + result = remove_mb.exec_() + if result == 3: # TODO - what is this magic number? + callback() + + def emit_badge_signals(self, tag_ids: list[int] | set[int], emit_on_absent: bool = True): + """Emit any connected signals for updating badge icons.""" + logger.info("[emit_badge_signals] Emitting", tag_ids=tag_ids, emit_on_absent=emit_on_absent) + if TAG_ARCHIVED in tag_ids: + self.archived_updated.emit(True) # noqa: FBT003 + elif emit_on_absent: + self.archived_updated.emit(False) # noqa: FBT003 + + if TAG_FAVORITE in tag_ids: + self.favorite_updated.emit(True) # noqa: FBT003 + elif emit_on_absent: + self.favorite_updated.emit(False) # noqa: FBT003 diff --git a/tagstudio/src/qt/widgets/preview/file_attributes.py b/tagstudio/src/qt/widgets/preview/file_attributes.py new file mode 100644 index 000000000..fa1a6c7e5 --- /dev/null +++ b/tagstudio/src/qt/widgets/preview/file_attributes.py @@ -0,0 +1,228 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import os +import platform +import typing +from datetime import datetime as dt +from datetime import timedelta +from pathlib import Path + +import structlog +from humanfriendly import format_size +from PIL import ImageFont +from PySide6.QtCore import Qt +from PySide6.QtGui import QGuiApplication +from PySide6.QtWidgets import ( + QLabel, + QVBoxLayout, + QWidget, +) +from src.core.enums import Theme +from src.core.library.alchemy.library import Library +from src.core.media_types import MediaCategories +from src.qt.helpers.file_opener import FileOpenerHelper, FileOpenerLabel + +if typing.TYPE_CHECKING: + from src.qt.ts_qt import QtDriver + +logger = structlog.get_logger(__name__) + + +class FileAttributes(QWidget): + """The Preview Panel Widget.""" + + def __init__(self, library: Library, driver: "QtDriver"): + super().__init__() + root_layout = QVBoxLayout(self) + root_layout.setContentsMargins(0, 0, 0, 0) + root_layout.setSpacing(0) + + label_bg_color = ( + Theme.COLOR_BG_DARK.value + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else Theme.COLOR_DARK_LABEL.value + ) + + self.date_style = "font-size:12px;" + self.file_label_style = "font-size: 12px" + self.properties_style = ( + f"background-color:{label_bg_color};" + "color:#FFFFFF;" + "font-family:Oxanium;" + "font-weight:bold;" + "font-size:12px;" + "border-radius:3px;" + "padding-top: 4px;" + "padding-right: 1px;" + "padding-bottom: 1px;" + "padding-left: 1px;" + ) + + self.file_label = FileOpenerLabel() + self.file_label.setObjectName("filenameLabel") + self.file_label.setTextFormat(Qt.TextFormat.RichText) + self.file_label.setWordWrap(True) + self.file_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + self.file_label.setStyleSheet(self.file_label_style) + + self.date_created_label = QLabel() + self.date_created_label.setObjectName("dateCreatedLabel") + self.date_created_label.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.date_created_label.setTextFormat(Qt.TextFormat.RichText) + self.date_created_label.setStyleSheet(self.date_style) + self.date_created_label.setHidden(True) + + self.date_modified_label = QLabel() + self.date_modified_label.setObjectName("dateModifiedLabel") + self.date_modified_label.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.date_modified_label.setTextFormat(Qt.TextFormat.RichText) + self.date_modified_label.setStyleSheet(self.date_style) + self.date_modified_label.setHidden(True) + + self.dimensions_label = QLabel() + self.dimensions_label.setObjectName("dimensionsLabel") + self.dimensions_label.setWordWrap(True) + self.dimensions_label.setStyleSheet(self.properties_style) + self.dimensions_label.setHidden(True) + + self.date_container = QWidget() + date_layout = QVBoxLayout(self.date_container) + date_layout.setContentsMargins(0, 2, 0, 0) + date_layout.setSpacing(0) + date_layout.addWidget(self.date_created_label) + date_layout.addWidget(self.date_modified_label) + + root_layout.addWidget(self.file_label) + root_layout.addWidget(self.date_container) + root_layout.addWidget(self.dimensions_label) + + 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 + 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"Date Created: {dt.strftime(created, "%a, %x, %X")}" # TODO: Translate + ) + self.date_modified_label.setText( + f"Date Modified: {dt.strftime(modified, "%a, %x, %X")}" # TODO: Translate + ) + self.date_created_label.setHidden(False) + self.date_modified_label.setHidden(False) + elif filepath: + self.date_created_label.setText("Date Created: N/A") # TODO: Translate + self.date_modified_label.setText("Date Modified: N/A") # TODO: Translate + self.date_created_label.setHidden(False) + self.date_modified_label.setHidden(False) + else: + 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): + """Render the panel widgets with the newest data from the Library.""" + if not stats: + stats = {} + + if not filepath: + self.layout().setSpacing(0) + self.file_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.file_label.setText("No Items Selected") # TODO: Translate + self.file_label.set_file_path("") + self.file_label.setCursor(Qt.CursorShape.ArrowCursor) + self.dimensions_label.setText("") + self.dimensions_label.setHidden(True) + else: + self.layout().setSpacing(6) + self.file_label.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.file_label.set_file_path(filepath) + self.dimensions_label.setHidden(False) + + file_str: str = "" + separator: str = f"{os.path.sep}" # Gray + for i, part in enumerate(filepath.parts): + part_ = part.strip(os.path.sep) + if i != len(filepath.parts) - 1: + file_str += f"{"\u200b".join(part_)}{separator}" + else: + file_str += f"
{"\u200b".join(part_)}" + self.file_label.setText(file_str) + self.file_label.setCursor(Qt.CursorShape.PointingHandCursor) + self.opener = FileOpenerHelper(filepath) + + # Initialize the possible stat variables + stats_label_text = "" + ext_display: str = "" + file_size: str = "" + width_px_text: str = "" + height_px_text: str = "" + duration_text: str = "" + font_family: str = "" + + # Attempt to populate the stat variables + width_px_text = stats.get("width", "") + height_px_text = stats.get("height", "") + duration_text = stats.get("duration", "") + font_family = stats.get("font_family", "") + if ext: + ext_display = ext.upper()[1:] + if filepath: + try: + file_size = format_size(filepath.stat().st_size) + + if MediaCategories.is_ext_in_category( + ext, MediaCategories.FONT_TYPES, mime_fallback=True + ): + font = ImageFont.truetype(filepath) + font_family = f"{font.getname()[0]} ({font.getname()[1]}) " + except (FileNotFoundError, OSError) as e: + logger.error( + "[FileAttributes] Could not process file stats", filepath=filepath, error=e + ) + + # Format and display any stat variables + def add_newline(stats_label_text: str) -> str: + if stats_label_text and stats_label_text[-2:] != "\n": + return stats_label_text + "\n" + return stats_label_text + + if ext_display: + stats_label_text += ext_display + if file_size: + stats_label_text += f" • {file_size}" + elif file_size: + stats_label_text += file_size + + if width_px_text and height_px_text: + stats_label_text = add_newline(stats_label_text) + stats_label_text += f"{width_px_text} x {height_px_text} px" + + if duration_text: + stats_label_text = add_newline(stats_label_text) + dur_str = str(timedelta(seconds=float(duration_text)))[:-7] + if dur_str.startswith("0:"): + dur_str = dur_str[2:] + if dur_str.startswith("0"): + dur_str = dur_str[1:] + stats_label_text += f"{dur_str}" + + if font_family: + stats_label_text = add_newline(stats_label_text) + stats_label_text += f"{font_family}" + + self.dimensions_label.setText(stats_label_text) + + def update_multi_selection(self, count: int): + """Format attributes for multiple selected items.""" + self.layout().setSpacing(0) + self.file_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.file_label.setText(f"{count} Items Selected") # TODO: Translate + self.file_label.setCursor(Qt.CursorShape.ArrowCursor) + self.file_label.set_file_path("") + self.dimensions_label.setText("") + self.dimensions_label.setHidden(True) diff --git a/tagstudio/src/qt/widgets/preview/preview_thumb.py b/tagstudio/src/qt/widgets/preview/preview_thumb.py new file mode 100644 index 000000000..d73f81f33 --- /dev/null +++ b/tagstudio/src/qt/widgets/preview/preview_thumb.py @@ -0,0 +1,376 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import io +import time +import typing +from pathlib import Path + +import cv2 +import rawpy +import structlog +from PIL import Image, UnidentifiedImageError +from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt +from PySide6.QtGui import QAction, QMovie, QResizeEvent +from PySide6.QtWidgets import ( + QHBoxLayout, + QLabel, + QWidget, +) +from src.core.library.alchemy.library import Library +from src.core.media_types import MediaCategories +from src.qt.helpers.file_opener import FileOpenerHelper, open_file +from src.qt.helpers.file_tester import is_readable_video +from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper +from src.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle +from src.qt.platform_strings import PlatformStrings +from src.qt.translations import Translations +from src.qt.widgets.media_player import MediaPlayer +from src.qt.widgets.thumb_renderer import ThumbRenderer +from src.qt.widgets.video_player import VideoPlayer + +if typing.TYPE_CHECKING: + from src.qt.ts_qt import QtDriver + +logger = structlog.get_logger(__name__) + + +class PreviewThumb(QWidget): + """The Preview Panel Widget.""" + + def __init__(self, library: Library, driver: "QtDriver"): + super().__init__() + + self.is_connected = False + self.lib = library + self.driver: QtDriver = driver + + self.img_button_size: tuple[int, int] = (266, 266) + self.image_ratio: float = 1.0 + + image_layout = QHBoxLayout(self) + image_layout.setContentsMargins(0, 0, 0, 0) + + self.open_file_action = QAction(self) + Translations.translate_qobject(self.open_file_action, "file.open_file") + self.open_explorer_action = QAction(PlatformStrings.open_file_str, self) + + self.preview_img = QPushButtonWrapper() + self.preview_img.setMinimumSize(*self.img_button_size) + self.preview_img.setFlat(True) + self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + self.preview_img.addAction(self.open_file_action) + self.preview_img.addAction(self.open_explorer_action) + + self.preview_gif = QLabel() + self.preview_gif.setMinimumSize(*self.img_button_size) + self.preview_gif.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + self.preview_gif.setCursor(Qt.CursorShape.ArrowCursor) + self.preview_gif.addAction(self.open_file_action) + self.preview_gif.addAction(self.open_explorer_action) + self.preview_gif.hide() + self.gif_buffer: QBuffer = QBuffer() + + self.preview_vid = VideoPlayer(driver) + self.preview_vid.hide() + self.thumb_renderer = ThumbRenderer() + self.thumb_renderer.updated.connect(lambda ts, i, s: (self.preview_img.setIcon(i))) + self.thumb_renderer.updated_ratio.connect( + lambda ratio: ( + self.set_image_ratio(ratio), + self.update_image_size( + ( + self.size().width(), + self.size().height(), + ), + ratio, + ), + ) + ) + + self.media_player = MediaPlayer(driver) + self.media_player.hide() + + image_layout.addWidget(self.preview_img) + image_layout.setAlignment(self.preview_img, Qt.AlignmentFlag.AlignCenter) + image_layout.addWidget(self.preview_gif) + image_layout.setAlignment(self.preview_gif, Qt.AlignmentFlag.AlignCenter) + image_layout.addWidget(self.preview_vid) + image_layout.setAlignment(self.preview_vid, Qt.AlignmentFlag.AlignCenter) + self.setMinimumSize(*self.img_button_size) + + def set_image_ratio(self, ratio: float): + self.image_ratio = ratio + + def update_image_size(self, size: tuple[int, int], ratio: float = None): + if ratio: + self.set_image_ratio(ratio) + + adj_width: float = size[0] + adj_height: float = size[1] + # Landscape + if self.image_ratio > 1: + adj_height = size[0] * (1 / self.image_ratio) + # Portrait + elif self.image_ratio <= 1: + adj_width = size[1] * self.image_ratio + + if adj_width > size[0]: + adj_height = adj_height * (size[0] / adj_width) + adj_width = size[0] + elif adj_height > size[1]: + adj_width = adj_width * (size[1] / adj_height) + adj_height = size[1] + + adj_size = QSize(int(adj_width), int(adj_height)) + self.img_button_size = (int(adj_width), int(adj_height)) + self.preview_img.setMaximumSize(adj_size) + self.preview_img.setIconSize(adj_size) + self.preview_vid.resize_video(adj_size) + self.preview_vid.setMaximumSize(adj_size) + self.preview_vid.setMinimumSize(adj_size) + self.preview_gif.setMaximumSize(adj_size) + self.preview_gif.setMinimumSize(adj_size) + proxy_style = RoundedPixmapStyle(radius=8) + self.preview_gif.setStyle(proxy_style) + self.preview_vid.setStyle(proxy_style) + m = self.preview_gif.movie() + if m: + m.setScaledSize(adj_size) + + def get_preview_size(self) -> tuple[int, int]: + return ( + self.size().width(), + self.size().height(), + ) + + def switch_preview(self, preview: str): + if preview != "image" and preview != "media": + self.preview_img.hide() + + if preview != "video_legacy": + self.preview_vid.stop() + self.preview_vid.hide() + + if preview != "media": + self.media_player.stop() + self.media_player.hide() + + if preview != "animated": + if self.preview_gif.movie(): + self.preview_gif.movie().stop() + self.gif_buffer.close() + self.preview_gif.hide() + + def _display_fallback_image(self, filepath: Path, ext=str) -> dict: + """Renders the given file as an image, no matter its media type. + + Useful for fallback scenarios. + """ + self.switch_preview("image") + self.thumb_renderer.render( + time.time(), + filepath, + (512, 512), + self.devicePixelRatio(), + update_on_ratio_change=True, + ) + self.preview_img.show() + return self._update_image(filepath, ext) + + def _update_image(self, filepath: Path, ext: str) -> dict: + """Update the static image preview from a filepath.""" + stats: dict = {} + self.switch_preview("image") + + image: Image.Image = None + + if MediaCategories.is_ext_in_category( + ext, MediaCategories.IMAGE_RAW_TYPES, mime_fallback=True + ): + try: + with rawpy.imread(str(filepath)) as raw: + rgb = raw.postprocess() + image = Image.new("L", (rgb.shape[1], rgb.shape[0]), color="black") + stats["width"] = image.width + stats["height"] = image.height + except ( + rawpy._rawpy.LibRawIOError, + rawpy._rawpy.LibRawFileUnsupportedError, + ): + pass + elif MediaCategories.is_ext_in_category( + ext, MediaCategories.IMAGE_RASTER_TYPES, mime_fallback=True + ): + try: + image = Image.open(str(filepath)) + stats["width"] = image.width + stats["height"] = image.height + except UnidentifiedImageError as e: + logger.error("[PreviewThumb] Could not get image stats", filepath=filepath, error=e) + elif MediaCategories.is_ext_in_category( + ext, MediaCategories.IMAGE_VECTOR_TYPES, mime_fallback=True + ): + pass + + self.preview_img.show() + + return stats + + def _update_animation(self, filepath: Path, ext: str) -> dict: + """Update the animated image preview from a filepath.""" + stats: dict = {} + + # Ensure that any movie and buffer from previous animations are cleared. + if self.preview_gif.movie(): + self.preview_gif.movie().stop() + self.gif_buffer.close() + + try: + image: Image.Image = Image.open(filepath) + stats["width"] = image.width + stats["height"] = image.height + self.update_image_size((image.width, image.height), image.width / image.height) + anim_image: Image.Image = image + image_bytes_io: io.BytesIO = io.BytesIO() + anim_image.save( + image_bytes_io, + "GIF", + lossless=True, + save_all=True, + loop=0, + disposal=2, + ) + image_bytes_io.seek(0) + ba: bytes = image_bytes_io.read() + self.gif_buffer.setData(ba) + movie = QMovie(self.gif_buffer, QByteArray()) + self.preview_gif.setMovie(movie) + + # If the animation only has 1 frame, display it like a normal image. + if movie.frameCount() == 1: + self._display_fallback_image(filepath, ext) + return stats + + # The animation has more than 1 frame, continue displaying it as an animation + self.switch_preview("animated") + self.resizeEvent( + QResizeEvent( + QSize(image.width, image.height), + QSize(image.width, image.height), + ) + ) + movie.start() + self.preview_gif.show() + + stats["duration"] = movie.frameCount() // 60 + except UnidentifiedImageError as e: + logger.error("[PreviewThumb] Could not load animated image", filepath=filepath, error=e) + return self._display_fallback_image(filepath, ext) + + return stats + + def _update_video_legacy(self, filepath: Path) -> dict: + stats: dict = {} + self.switch_preview("video_legacy") + + video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG) + video.set( + cv2.CAP_PROP_POS_FRAMES, + (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2), + ) + success, frame = video.read() + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + image = Image.fromarray(frame) + stats["width"] = image.width + stats["height"] = image.height + if success: + self.preview_vid.play(str(filepath), QSize(image.width, image.height)) + self.update_image_size((image.width, image.height), image.width / image.height) + self.resizeEvent( + QResizeEvent( + QSize(image.width, image.height), + QSize(image.width, image.height), + ) + ) + self.preview_vid.show() + + stats["duration"] = video.get(cv2.CAP_PROP_FRAME_COUNT) / video.get(cv2.CAP_PROP_FPS) + return stats + + def _update_media(self, filepath: Path) -> dict: + stats: dict = {} + self.switch_preview("media") + + self.preview_img.show() + self.media_player.show() + self.media_player.play(filepath) + + stats["duration"] = self.media_player.player.duration() * 1000 + return stats + + def update_preview(self, filepath: Path, ext: str) -> dict: + """Render a single file preview.""" + stats: dict = {} + + # Video (Legacy) + if MediaCategories.is_ext_in_category( + ext, MediaCategories.VIDEO_TYPES, mime_fallback=True + ) and is_readable_video(filepath): + stats = self._update_video_legacy(filepath) + + # Audio + elif MediaCategories.is_ext_in_category( + ext, MediaCategories.AUDIO_TYPES, mime_fallback=True + ): + self._update_image(filepath, ext) + stats = self._update_media(filepath) + self.thumb_renderer.render( + time.time(), + filepath, + (512, 512), + self.devicePixelRatio(), + update_on_ratio_change=True, + ) + + # Animated Images + elif MediaCategories.is_ext_in_category( + ext, MediaCategories.IMAGE_ANIMATED_TYPES, mime_fallback=True + ): + stats = self._update_animation(filepath, ext) + + # Other Types (Including Images) + else: + # TODO: Get thumb renderer to return this stuff to pass on + stats = self._update_image(filepath, ext) + + self.thumb_renderer.render( + time.time(), + filepath, + (512, 512), + self.devicePixelRatio(), + update_on_ratio_change=True, + ) + + if self.preview_img.is_connected: + self.preview_img.clicked.disconnect() + self.preview_img.clicked.connect(lambda checked=False, path=filepath: open_file(path)) + self.preview_img.is_connected = True + + self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + self.preview_img.setCursor(Qt.CursorShape.PointingHandCursor) + + self.opener = FileOpenerHelper(filepath) + self.open_file_action.triggered.connect(self.opener.open_file) + self.open_explorer_action.triggered.connect(self.opener.open_explorer) + + return stats + + def hide_preview(self): + """Completely hide the file preview.""" + self.switch_preview("") + + def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 + self.update_image_size((self.size().width(), self.size().height())) + return super().resizeEvent(event) diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index 0bb1c1812..62cf0bd24 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -1,67 +1,32 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -import io -import os -import platform -import sys -import time +import traceback import typing -from collections.abc import Callable -from datetime import datetime as dt from pathlib import Path +from warnings import catch_warnings -import cv2 -import rawpy import structlog -from humanfriendly import format_size -from PIL import Image, ImageFont, UnidentifiedImageError -from PIL.Image import DecompressionBombError -from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt, Signal -from PySide6.QtGui import QAction, QGuiApplication, QMovie, QResizeEvent +from PySide6.QtCore import Qt from PySide6.QtWidgets import ( - QFrame, QHBoxLayout, - QLabel, - QMessageBox, QPushButton, - QScrollArea, - QSizePolicy, QSplitter, QVBoxLayout, QWidget, ) -from src.core.constants import ( - TS_FOLDER_NAME, -) -from src.core.enums import SettingItems, Theme -from src.core.library.alchemy.fields import ( - BaseField, - DatetimeField, - FieldTypeEnum, - TagBoxField, - TextField, - _FieldID, -) +from src.core.enums import Theme from src.core.library.alchemy.library import Library -from src.core.media_types import MediaCategories -from src.qt.helpers.file_opener import FileOpenerHelper, FileOpenerLabel, open_file -from src.qt.helpers.file_tester import is_readable_video -from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper -from src.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle +from src.core.library.alchemy.models import Entry +from src.core.palette import ColorType, UiColor, get_ui_color from src.qt.modals.add_field import AddFieldModal -from src.qt.platform_strings import PlatformStrings +from src.qt.modals.tag_search import TagSearchPanel from src.qt.translations import Translations -from src.qt.widgets.fields import FieldContainer -from src.qt.widgets.media_player import MediaPlayer from src.qt.widgets.panel import PanelModal -from src.qt.widgets.tag_box import TagBoxWidget -from src.qt.widgets.text import TextWidget -from src.qt.widgets.text_box_edit import EditTextBox -from src.qt.widgets.text_line_edit import EditTextLine -from src.qt.widgets.thumb_renderer import ThumbRenderer -from src.qt.widgets.video_player import VideoPlayer +from src.qt.widgets.preview.field_containers import FieldContainers +from src.qt.widgets.preview.file_attributes import FileAttributes +from src.qt.widgets.preview.preview_thumb import PreviewThumb if typing.TYPE_CHECKING: from src.qt.ts_qt import QtDriver @@ -72,1040 +37,177 @@ class PreviewPanel(QWidget): """The Preview Panel Widget.""" - tags_updated = Signal() + # TODO: There should be a global button theme somewhere. + button_style = ( + f"QPushButton{{" + f"background-color:{Theme.COLOR_BG.value};" + "border-radius:6px;" + "font-weight: 500;" + "text-align: center;" + f"}}" + f"QPushButton::hover{{" + f"background-color:{Theme.COLOR_HOVER.value};" + f"border-color:{get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};" + f"border-style:solid;" + f"border-width: 2px;" + f"}}" + f"QPushButton::pressed{{" + f"background-color:{Theme.COLOR_PRESSED.value};" + f"border-color:{get_ui_color(ColorType.LIGHT_ACCENT, UiColor.THEME_DARK)};" + f"border-style:solid;" + f"border-width: 2px;" + f"}}" + f"QPushButton::disabled{{" + f"background-color:{Theme.COLOR_DISABLED_BG.value};" + f"}}" + ) def __init__(self, library: Library, driver: "QtDriver"): super().__init__() - self.is_connected = False self.lib = library self.driver: QtDriver = driver self.initialized = False - self.is_open: bool = False - self.common_fields: list = [] - self.mixed_fields: list = [] - self.selected: list[int] = [] # New way of tracking items - self.tag_callback = None - self.containers: list[FieldContainer] = [] - - self.img_button_size: tuple[int, int] = (266, 266) - self.image_ratio: float = 1.0 - - self.label_bg_color = ( - Theme.COLOR_BG_DARK.value - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else Theme.COLOR_DARK_LABEL.value - ) - self.panel_bg_color = ( - Theme.COLOR_BG_DARK.value - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else Theme.COLOR_BG_LIGHT.value - ) - - self.image_container = QWidget() - image_layout = QHBoxLayout(self.image_container) - image_layout.setContentsMargins(0, 0, 0, 0) - - file_label_style = "font-size: 12px" - properties_style = ( - f"background-color:{self.label_bg_color};" - "color:#FFFFFF;" - "font-family:Oxanium;" - "font-weight:bold;" - "font-size:12px;" - "border-radius:3px;" - "padding-top: 4px;" - "padding-right: 1px;" - "padding-bottom: 1px;" - "padding-left: 1px;" - ) - date_style = "font-size:12px;" + self.is_open: bool = True - self.open_file_action = QAction(self) - Translations.translate_qobject(self.open_file_action, "file.open_file") - self.open_explorer_action = QAction(PlatformStrings.open_file_str, self) + self.thumb = PreviewThumb(library, driver) + self.file_attrs = FileAttributes(library, driver) + self.fields = FieldContainers(library, driver) - self.preview_img = QPushButtonWrapper() - self.preview_img.setMinimumSize(*self.img_button_size) - self.preview_img.setFlat(True) - self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) - self.preview_img.addAction(self.open_file_action) - self.preview_img.addAction(self.open_explorer_action) - - self.preview_gif = QLabel() - self.preview_gif.setMinimumSize(*self.img_button_size) - self.preview_gif.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) - self.preview_gif.setCursor(Qt.CursorShape.ArrowCursor) - self.preview_gif.addAction(self.open_file_action) - self.preview_gif.addAction(self.open_explorer_action) - self.preview_gif.hide() - self.gif_buffer: QBuffer = QBuffer() - - self.preview_vid = VideoPlayer(driver) - self.preview_vid.hide() - self.thumb_renderer = ThumbRenderer() - self.thumb_renderer.updated.connect(lambda ts, i, s: (self.preview_img.setIcon(i))) - self.thumb_renderer.updated_ratio.connect( - lambda ratio: ( - self.set_image_ratio(ratio), - self.update_image_size( - ( - self.image_container.size().width(), - self.image_container.size().height(), - ), - ratio, - ), - ) + tag_search_panel = TagSearchPanel(self.driver.lib) + self.add_tag_modal = PanelModal( + tag_search_panel, Translations.translate_formatted("tag.add.plural") ) + Translations.translate_with_setter(self.add_tag_modal.setWindowTitle, "tag.add.plural") - self.media_player = MediaPlayer(driver) - self.media_player.hide() - - image_layout.addWidget(self.preview_img) - image_layout.setAlignment(self.preview_img, Qt.AlignmentFlag.AlignCenter) - image_layout.addWidget(self.preview_gif) - image_layout.setAlignment(self.preview_gif, Qt.AlignmentFlag.AlignCenter) - image_layout.addWidget(self.preview_vid) - image_layout.setAlignment(self.preview_vid, Qt.AlignmentFlag.AlignCenter) - self.image_container.setMinimumSize(*self.img_button_size) - self.file_label = FileOpenerLabel() - self.file_label.setObjectName("filenameLabel") - self.file_label.setTextFormat(Qt.TextFormat.RichText) - self.file_label.setWordWrap(True) - self.file_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) - self.file_label.setStyleSheet(file_label_style) - - self.date_created_label = QLabel() - self.date_created_label.setObjectName("dateCreatedLabel") - self.date_created_label.setAlignment(Qt.AlignmentFlag.AlignLeft) - self.date_created_label.setTextFormat(Qt.TextFormat.RichText) - self.date_created_label.setStyleSheet(date_style) - - self.date_modified_label = QLabel() - self.date_modified_label.setObjectName("dateModifiedLabel") - self.date_modified_label.setAlignment(Qt.AlignmentFlag.AlignLeft) - self.date_modified_label.setTextFormat(Qt.TextFormat.RichText) - self.date_modified_label.setStyleSheet(date_style) - - self.dimensions_label = QLabel() - self.dimensions_label.setObjectName("dimensionsLabel") - self.dimensions_label.setWordWrap(True) - self.dimensions_label.setStyleSheet(properties_style) - - self.scroll_layout = QVBoxLayout() - self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - self.scroll_layout.setContentsMargins(6, 1, 6, 6) + self.add_field_modal = AddFieldModal(self.lib) - scroll_container: QWidget = QWidget() - scroll_container.setObjectName("entryScrollContainer") - scroll_container.setLayout(self.scroll_layout) + preview_section = QWidget() + preview_layout = QVBoxLayout(preview_section) + preview_layout.setContentsMargins(0, 0, 0, 0) + preview_layout.setSpacing(6) info_section = QWidget() info_layout = QVBoxLayout(info_section) info_layout.setContentsMargins(0, 0, 0, 0) info_layout.setSpacing(6) - scroll_area = QScrollArea() - scroll_area.setObjectName("entryScrollArea") - scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) - scroll_area.setWidgetResizable(True) - scroll_area.setFrameShadow(QFrame.Shadow.Plain) - scroll_area.setFrameShape(QFrame.Shape.NoFrame) - # NOTE: I would rather have this style applied to the scroll_area - # background and NOT the scroll container background, so that the - # rounded corners are maintained when scrolling. I was unable to - # find the right trick to only select that particular element. - - scroll_area.setStyleSheet( - "QWidget#entryScrollContainer{" - f"background:{self.panel_bg_color};" - "border-radius:6px;" - "}" - ) - scroll_area.setWidget(scroll_container) - - date_container = QWidget() - date_layout = QVBoxLayout(date_container) - date_layout.setContentsMargins(0, 2, 0, 0) - date_layout.setSpacing(0) - date_layout.addWidget(self.date_created_label) - date_layout.addWidget(self.date_modified_label) - - info_layout.addWidget(self.file_label) - info_layout.addWidget(date_container) - info_layout.addWidget(self.dimensions_label) - info_layout.addWidget(scroll_area) - - # keep list of rendered libraries to avoid needless re-rendering - self.render_libs: set = set() - self.libs_layout = QVBoxLayout() - self.fill_libs_widget(self.libs_layout) - - self.libs_flow_container: QWidget = QWidget() - self.libs_flow_container.setObjectName("librariesList") - self.libs_flow_container.setLayout(self.libs_layout) - self.libs_flow_container.setSizePolicy( - QSizePolicy.Preferred, # type: ignore - QSizePolicy.Maximum, # type: ignore - ) - - # set initial visibility based on settings - if not self.driver.settings.value( - SettingItems.WINDOW_SHOW_LIBS, defaultValue=True, type=bool - ): - self.libs_flow_container.hide() - splitter = QSplitter() splitter.setOrientation(Qt.Orientation.Vertical) splitter.setHandleWidth(12) - splitter.splitterMoved.connect( - lambda: self.update_image_size( - ( - self.image_container.size().width(), - self.image_container.size().height(), - ) - ) - ) - splitter.addWidget(self.image_container) - splitter.addWidget(self.media_player) - splitter.addWidget(info_section) - splitter.addWidget(self.libs_flow_container) - splitter.setStretchFactor(1, 2) + add_buttons_container = QWidget() + add_buttons_layout = QHBoxLayout(add_buttons_container) + add_buttons_layout.setContentsMargins(0, 0, 0, 0) + add_buttons_layout.setSpacing(6) - self.afb_container = QWidget() - self.afb_layout = QVBoxLayout(self.afb_container) - self.afb_layout.setContentsMargins(0, 12, 0, 0) + self.add_tag_button = QPushButton() + self.add_tag_button.setEnabled(False) + self.add_tag_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.add_tag_button.setMinimumHeight(28) + self.add_tag_button.setStyleSheet(PreviewPanel.button_style) + self.add_tag_button.setText("Add Tag") # TODO: Translate - self.add_field_button = QPushButtonWrapper() + self.add_field_button = QPushButton() + self.add_field_button.setEnabled(False) self.add_field_button.setCursor(Qt.CursorShape.PointingHandCursor) - self.add_field_button.setMinimumSize(96, 28) - self.add_field_button.setMaximumSize(96, 28) - Translations.translate_qobject(self.add_field_button, "library.field.add") - self.afb_layout.addWidget(self.add_field_button) - self.add_field_modal = AddFieldModal(self.lib) - self.place_add_field_button() - self.update_image_size( - (self.image_container.size().width(), self.image_container.size().height()) - ) - - root_layout = QHBoxLayout(self) - root_layout.setContentsMargins(0, 0, 0, 0) - root_layout.addWidget(splitter) + self.add_field_button.setMinimumHeight(28) + self.add_field_button.setStyleSheet(PreviewPanel.button_style) + self.add_field_button.setText("Add Field") # TODO: Translate - def update_selected_entry(self, driver: "QtDriver"): - for grid_idx in driver.selected: - entry = driver.frame_content[grid_idx] - result = self.lib.get_entry_full(entry.id) - logger.info( - "found item", - grid_idx=grid_idx, - lookup_id=entry.id, - ) - self.driver.frame_content[grid_idx] = result - - def remove_field_prompt(self, name: str) -> str: - return Translations.translate_formatted("library.field.confirm_remove", name=name) - - def fill_libs_widget(self, layout: QVBoxLayout): - settings = self.driver.settings - settings.beginGroup(SettingItems.LIBS_LIST) - lib_items: dict[str, tuple[str, str]] = {} - for item_tstamp in settings.allKeys(): - val = str(settings.value(item_tstamp, type=str)) - cut_val = val - if len(val) > 45: - cut_val = f"{val[0:10]} ... {val[-10:]}" - lib_items[item_tstamp] = (val, cut_val) - - settings.endGroup() - - new_keys = set(lib_items.keys()) - if new_keys == self.render_libs: - # no need to re-render - return - - # sort lib_items by the key - libs_sorted = sorted(lib_items.items(), key=lambda item: item[0], reverse=True) - - self.render_libs = new_keys - self._fill_libs_widget(libs_sorted, layout) - - def _fill_libs_widget(self, libraries: list[tuple[str, tuple[str, str]]], layout: QVBoxLayout): - def clear_layout(layout_item: QVBoxLayout): - for i in reversed(range(layout_item.count())): - child = layout_item.itemAt(i) - if child.widget() is not None: - child.widget().deleteLater() - elif child.layout() is not None: - clear_layout(child.layout()) # type: ignore - - # remove any potential previous items - clear_layout(layout) - - label = QLabel() - Translations.translate_qobject(label, "generic.recent_libraries") - label.setAlignment(Qt.AlignmentFlag.AlignCenter) - - row_layout = QHBoxLayout() - row_layout.addWidget(label) - layout.addLayout(row_layout) - - def set_button_style( - btn: QPushButtonWrapper | QPushButton, extras: list[str] | None = None - ): - base_style = [ - f"background-color:{Theme.COLOR_BG.value};", - "border-radius:6px;", - "text-align: left;", - "padding-top: 3px;", - "padding-left: 6px;", - "padding-bottom: 4px;", - ] - - full_style_rows = base_style + (extras or []) - - btn.setStyleSheet( - "QPushButton{" - f"{''.join(full_style_rows)}" - "}" - f"QPushButton::hover{{background-color:{Theme.COLOR_HOVER.value};}}" - f"QPushButton::pressed{{background-color:{Theme.COLOR_PRESSED.value};}}" - f"QPushButton::disabled{{background-color:{Theme.COLOR_DISABLED_BG.value};}}" - ) - btn.setCursor(Qt.CursorShape.PointingHandCursor) - - for item_key, (full_val, cut_val) in libraries: - button = QPushButton(text=cut_val) - button.setObjectName(f"path{item_key}") - - lib = Path(full_val) - if not lib.exists() or not (lib / TS_FOLDER_NAME).exists(): - button.setDisabled(True) - Translations.translate_with_setter(button.setToolTip, "library.missing") - - def open_library_button_clicked(path): - return lambda: self.driver.open_library(Path(path)) + add_buttons_layout.addWidget(self.add_tag_button) + add_buttons_layout.addWidget(self.add_field_button) - button.clicked.connect(open_library_button_clicked(full_val)) - set_button_style(button, ["padding-left: 6px;", "text-align: left;"]) - button_remove = QPushButton("—") - button_remove.setCursor(Qt.CursorShape.PointingHandCursor) - button_remove.setFixedWidth(24) - set_button_style(button_remove, ["font-weight:bold;", "text-align:center;"]) + preview_layout.addWidget(self.thumb) + preview_layout.addWidget(self.thumb.media_player) + info_layout.addWidget(self.file_attrs) + info_layout.addWidget(self.fields) - def remove_recent_library_clicked(key: str): - return lambda: ( - self.driver.remove_recent_library(key), - self.fill_libs_widget(self.libs_layout), - ) - - button_remove.clicked.connect(remove_recent_library_clicked(item_key)) - - row_layout = QHBoxLayout() - row_layout.addWidget(button) - row_layout.addWidget(button_remove) - - layout.addLayout(row_layout) - - def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 - self.update_image_size( - (self.image_container.size().width(), self.image_container.size().height()) - ) - return super().resizeEvent(event) - - def get_preview_size(self) -> tuple[int, int]: - return ( - self.image_container.size().width(), - self.image_container.size().height(), - ) - - def set_image_ratio(self, ratio: float): - self.image_ratio = ratio - - def update_image_size(self, size: tuple[int, int], ratio: float = None): - if ratio: - self.set_image_ratio(ratio) + splitter.addWidget(preview_section) + splitter.addWidget(info_section) + splitter.setStretchFactor(1, 2) - adj_width: float = size[0] - adj_height: float = size[1] - # Landscape - if self.image_ratio > 1: - adj_height = size[0] * (1 / self.image_ratio) - # Portrait - elif self.image_ratio <= 1: - adj_width = size[1] * self.image_ratio + root_layout = QVBoxLayout(self) + root_layout.setContentsMargins(0, 0, 0, 0) + root_layout.addWidget(splitter) + root_layout.addWidget(add_buttons_container) - if adj_width > size[0]: - adj_height = adj_height * (size[0] / adj_width) - adj_width = size[0] - elif adj_height > size[1]: - adj_width = adj_width * (size[1] / adj_height) - adj_height = size[1] + def update_widgets(self) -> bool: + """Render the panel widgets with the newest data from the Library.""" + # No Items Selected + try: + if len(self.driver.selected) == 0: + self.thumb.hide_preview() + self.file_attrs.update_stats() + self.file_attrs.update_date_label() + self.fields.hide_containers() + + self.add_tag_button.setEnabled(False) + self.add_field_button.setEnabled(False) + + # One Item Selected + elif len(self.driver.selected) == 1: + 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() - adj_size = QSize(int(adj_width), int(adj_height)) - self.img_button_size = (int(adj_width), int(adj_height)) - self.preview_img.setMaximumSize(adj_size) - self.preview_img.setIconSize(adj_size) - self.preview_vid.resize_video(adj_size) - self.preview_vid.setMaximumSize(adj_size) - self.preview_vid.setMinimumSize(adj_size) - self.preview_gif.setMaximumSize(adj_size) - self.preview_gif.setMinimumSize(adj_size) - proxy_style = RoundedPixmapStyle(radius=8) - self.preview_gif.setStyle(proxy_style) - self.preview_vid.setStyle(proxy_style) - m = self.preview_gif.movie() - if m: - m.setScaledSize(adj_size) + stats: dict = self.thumb.update_preview(filepath, ext) + self.file_attrs.update_stats(filepath, ext, stats) + self.file_attrs.update_date_label(filepath) + self.fields.update_from_entry(entry_id) + self.update_add_tag_button(entry_id) + self.update_add_field_button(entry_id) + + self.add_tag_button.setEnabled(True) + self.add_field_button.setEnabled(True) + + # Multiple Selected Items + elif len(self.driver.selected) > 1: + # items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected] + self.thumb.hide_preview() # TODO: Render mixed selection + self.file_attrs.update_multi_selection(len(self.driver.selected)) + self.file_attrs.update_date_label() + self.fields.hide_containers() # TODO: Allow for mixed editing + self.update_add_tag_button() + self.update_add_field_button() + + self.add_tag_button.setEnabled(True) + self.add_field_button.setEnabled(True) - def place_add_field_button(self): - self.scroll_layout.addWidget(self.afb_container) - self.scroll_layout.setAlignment(self.afb_container, Qt.AlignmentFlag.AlignHCenter) + return True + except Exception as e: + logger.error("[Preview Panel] Error updating selection", error=e) + traceback.print_exc() + return False - if self.add_field_modal.is_connected: + def update_add_field_button(self, entry_id: int | None = None): + with catch_warnings(record=True): self.add_field_modal.done.disconnect() - if self.add_field_button.is_connected: self.add_field_button.clicked.disconnect() self.add_field_modal.done.connect( - lambda f: (self.add_field_to_selected(f), self.update_widgets()) + lambda f: ( + self.fields.add_field_to_selected(f), + (self.fields.update_from_entry(entry_id) if entry_id else ()), + ) ) - self.add_field_modal.is_connected = True self.add_field_button.clicked.connect(self.add_field_modal.show) - def add_field_to_selected(self, field_list: list): - """Add list of entry fields to one or more selected items.""" - logger.info("add_field_to_selected", selected=self.selected, fields=field_list) - for grid_idx in self.selected: - entry = self.driver.frame_content[grid_idx] - for field_item in field_list: - self.lib.add_entry_field_type( - entry.id, - field_id=field_item.data(Qt.ItemDataRole.UserRole), - ) + def update_add_tag_button(self, entry_id: int = None): + with catch_warnings(record=True): + self.add_tag_modal.widget.tag_chosen.disconnect() + self.add_tag_button.clicked.disconnect() - 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 - 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"Date Created: {dt.strftime(created, "%a, %x, %X")}" # TODO translate + self.add_tag_modal.widget.tag_chosen.connect( + lambda t: ( + self.fields.add_tags_to_selected(t), + (self.fields.update_from_entry(entry_id) if entry_id else ()), ) - self.date_modified_label.setText( - f"Date Modified: {dt.strftime(modified, "%a, %x, %X")}" # TODO translate - ) - self.date_created_label.setHidden(False) - self.date_modified_label.setHidden(False) - elif filepath: - self.date_created_label.setText("Date Created: N/A") # TODO translate - self.date_modified_label.setText("Date Modified: N/A") # TODO translate - self.date_created_label.setHidden(False) - self.date_modified_label.setHidden(False) - else: - self.date_created_label.setHidden(True) - self.date_modified_label.setHidden(True) - - def update_widgets(self) -> bool: - """Render the panel widgets with the newest data from the Library.""" - logger.info("update_widgets", selected=self.driver.selected) - self.is_open = True - # self.tag_callback = tag_callback if tag_callback else None - window_title = "" - - # update list of libraries - self.fill_libs_widget(self.libs_layout) - - if not self.driver.selected: - if self.selected or not self.initialized: - self.file_label.setText("No Items Selected") # TODO translate - self.file_label.set_file_path("") - self.file_label.setCursor(Qt.CursorShape.ArrowCursor) - - self.dimensions_label.setText("") - self.update_date_label() - self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu) - self.preview_img.setCursor(Qt.CursorShape.ArrowCursor) - - ratio = self.devicePixelRatio() - self.thumb_renderer.render( - time.time(), - "", - (512, 512), - ratio, - is_loading=True, - update_on_ratio_change=True, - ) - if self.preview_img.is_connected: - self.preview_img.clicked.disconnect() - for c in self.containers: - c.setHidden(True) - self.preview_img.show() - self.preview_vid.stop() - self.preview_vid.hide() - self.media_player.hide() - self.media_player.stop() - self.preview_gif.hide() - self.selected = list(self.driver.selected) - self.add_field_button.setHidden(True) - - # common code - self.initialized = True - self.setWindowTitle(window_title) - self.show() - return True - - # reload entry and fill it into the grid again - # TODO - do this more granular - # TODO - Entry reload is maybe not necessary - for grid_idx in self.driver.selected: - entry = self.driver.frame_content[grid_idx] - result = self.lib.get_entry_full(entry.id) - logger.info( - "found item", - grid_idx=grid_idx, - lookup_id=entry.id, - ) - self.driver.frame_content[grid_idx] = result - - if len(self.driver.selected) == 1: - # 1 Selected Entry - selected_idx = self.driver.selected[0] - item = self.driver.frame_content[selected_idx] - - self.preview_img.show() - self.preview_vid.stop() - self.preview_vid.hide() - self.media_player.stop() - self.media_player.hide() - self.preview_gif.hide() - - # If a new selection is made, update the thumbnail and filepath. - if not self.selected or self.selected != self.driver.selected: - filepath = self.lib.library_dir / item.path - self.file_label.set_file_path(filepath) - ratio = self.devicePixelRatio() - self.thumb_renderer.render( - time.time(), - filepath, - (512, 512), - ratio, - update_on_ratio_change=True, - ) - file_str: str = "" - separator: str = f"{os.path.sep}" # Gray - for i, part in enumerate(filepath.parts): - part_ = part.strip(os.path.sep) - if i != len(filepath.parts) - 1: - file_str += f"{"\u200b".join(part_)}{separator}" - else: - file_str += f"
{"\u200b".join(part_)}" - self.file_label.setText(file_str) - self.file_label.setCursor(Qt.CursorShape.PointingHandCursor) - - self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) - self.preview_img.setCursor(Qt.CursorShape.PointingHandCursor) - - self.opener = FileOpenerHelper(filepath) - self.open_file_action.triggered.connect(self.opener.open_file) - self.open_explorer_action.triggered.connect(self.opener.open_explorer) - - # TODO: Do this all somewhere else, this is just here temporarily. - ext: str = filepath.suffix.lower() - try: - if MediaCategories.is_ext_in_category( - ext, MediaCategories.IMAGE_ANIMATED_TYPES, mime_fallback=True - ): - if self.preview_gif.movie(): - self.preview_gif.movie().stop() - self.gif_buffer.close() - - image: Image.Image = Image.open(filepath) - anim_image: Image.Image = image - image_bytes_io: io.BytesIO = io.BytesIO() - anim_image.save( - image_bytes_io, - "GIF", - lossless=True, - save_all=True, - loop=0, - disposal=2, - ) - image_bytes_io.seek(0) - ba: bytes = image_bytes_io.read() - - self.gif_buffer.setData(ba) - movie = QMovie(self.gif_buffer, QByteArray()) - self.preview_gif.setMovie(movie) - movie.start() - - self.resizeEvent( - QResizeEvent( - QSize(image.width, image.height), - QSize(image.width, image.height), - ) - ) - self.preview_img.hide() - self.preview_vid.hide() - self.preview_gif.show() - - image = None - if MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_RASTER_TYPES): - image = Image.open(str(filepath)) - elif MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_RAW_TYPES): - try: - with rawpy.imread(str(filepath)) as raw: - rgb = raw.postprocess() - image = Image.new("L", (rgb.shape[1], rgb.shape[0]), color="black") - except ( - rawpy._rawpy.LibRawIOError, - rawpy._rawpy.LibRawFileUnsupportedError, - ): - pass - elif MediaCategories.is_ext_in_category(ext, MediaCategories.AUDIO_TYPES): - self.media_player.show() - self.media_player.play(filepath) - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.VIDEO_TYPES - ) and is_readable_video(filepath): - video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG) - video.set( - cv2.CAP_PROP_POS_FRAMES, - (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2), - ) - success, frame = video.read() - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - image = Image.fromarray(frame) - if success: - self.preview_img.hide() - self.preview_vid.play(filepath, QSize(image.width, image.height)) - self.resizeEvent( - QResizeEvent( - QSize(image.width, image.height), - QSize(image.width, image.height), - ) - ) - self.preview_vid.show() - - # Stats for specific file types are displayed here. - if image and ( - MediaCategories.is_ext_in_category( - ext, MediaCategories.IMAGE_RASTER_TYPES, mime_fallback=True - ) - or MediaCategories.is_ext_in_category( - ext, MediaCategories.VIDEO_TYPES, mime_fallback=True - ) - or MediaCategories.is_ext_in_category( - ext, MediaCategories.IMAGE_RAW_TYPES, mime_fallback=True - ) - ): - self.dimensions_label.setText( - f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}\n" - f"{image.width} x {image.height} px" - ) - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.FONT_TYPES, mime_fallback=True - ): - try: - font = ImageFont.truetype(filepath) - self.dimensions_label.setText( - f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}\n" - f"{font.getname()[0]} ({font.getname()[1]}) " - ) - except OSError: - self.dimensions_label.setText( - f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}" - ) - logger.info( - f"[PreviewPanel][ERROR] Couldn't read font file: {filepath}" - ) - else: - self.dimensions_label.setText(f"{ext.upper()[1:]}") - self.dimensions_label.setText( - f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}" - ) - self.update_date_label(filepath) - - if not filepath.is_file(): - raise FileNotFoundError - - except (FileNotFoundError, cv2.error) as e: - self.dimensions_label.setText(f"{ext.upper()[1:]}") - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) - self.update_date_label() - except ( - UnidentifiedImageError, - DecompressionBombError, - ) as e: - self.dimensions_label.setText( - f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}" - ) - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) - self.update_date_label(filepath) - - if self.preview_img.is_connected: - self.preview_img.clicked.disconnect() - self.preview_img.clicked.connect(lambda checked=False, pth=filepath: open_file(pth)) - self.preview_img.is_connected = True - - self.selected = self.driver.selected - logger.info( - "rendering item fields", - item=item.id, - fields=[x.type_key for x in item.fields], - ) - for idx, field in enumerate(item.fields): - self.write_container(idx, field) - - # Hide leftover containers - if len(self.containers) > len(item.fields): - for i, c in enumerate(self.containers): - if i > (len(item.fields) - 1): - c.setHidden(True) - - self.add_field_button.setHidden(False) - - # Multiple Selected Items - elif len(self.driver.selected) > 1: - self.preview_img.show() - self.preview_gif.hide() - self.preview_vid.stop() - self.preview_vid.hide() - self.media_player.stop() - self.media_player.hide() - self.update_date_label() - if self.selected != self.driver.selected: - self.file_label.setText( - f"{len(self.driver.selected)} Items Selected" - ) # TODO translate - self.file_label.setCursor(Qt.CursorShape.ArrowCursor) - self.file_label.set_file_path("") - self.dimensions_label.setText("") - - self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu) - self.preview_img.setCursor(Qt.CursorShape.ArrowCursor) - - ratio = self.devicePixelRatio() - self.thumb_renderer.render( - time.time(), - "", - (512, 512), - ratio, - is_loading=True, - update_on_ratio_change=True, - ) - if self.preview_img.is_connected: - self.preview_img.clicked.disconnect() - self.preview_img.is_connected = False - - # fill shared fields from first item - first_item = self.driver.frame_content[self.driver.selected[0]] - common_fields = [f for f in first_item.fields] - mixed_fields = [] - - # iterate through other items - for grid_idx in self.driver.selected[1:]: - item = self.driver.frame_content[grid_idx] - item_field_types = {f.type_key for f in item.fields} - for f in common_fields[:]: - if f.type_key not in item_field_types: - common_fields.remove(f) - mixed_fields.append(f) - - self.common_fields = common_fields - self.mixed_fields = sorted(mixed_fields, key=lambda x: x.type.position) - - self.selected = list(self.driver.selected) - logger.info( - "update_widgets common_fields", - common_fields=self.common_fields, - ) - for i, f in enumerate(self.common_fields): - self.write_container(i, f) - - logger.info( - "update_widgets mixed_fields", - mixed_fields=self.mixed_fields, - start=len(self.common_fields), - ) - for i, f in enumerate(self.mixed_fields, start=len(self.common_fields)): - self.write_container(i, f, is_mixed=True) - - # Hide leftover containers - if len(self.containers) > len(self.common_fields) + len(self.mixed_fields): - for i, c in enumerate(self.containers): - if i > (len(self.common_fields) + len(self.mixed_fields) - 1): - c.setHidden(True) - - self.add_field_button.setHidden(False) - - self.initialized = True - - self.setWindowTitle(window_title) - self.show() - return True - - def set_tags_updated_slot(self, slot: object): - """Replacement for tag_callback.""" - if self.is_connected: - self.tags_updated.disconnect() - - logger.info("[UPDATE CONTAINER] Setting tags updated slot") - self.tags_updated.connect(slot) - self.is_connected = True - - def write_container(self, index: int, field: BaseField, is_mixed: bool = False): - """Update/Create data for a FieldContainer. - - Args: - index(int): The container index. - field(BaseField): The type of field to write to. - is_mixed(bool): Relevant when multiple items are selected. - If True, field is not present in all selected items. - """ - # Remove 'Add Field' button from scroll_layout, to be re-added later. - self.scroll_layout.takeAt(self.scroll_layout.count() - 1).widget() - if len(self.containers) < (index + 1): - container = FieldContainer() - self.containers.append(container) - self.scroll_layout.addWidget(container) - else: - container = self.containers[index] - - # TODO this is in severe need of refactoring due to exessive code duplication - if isinstance(field, TagBoxField): - container.set_title(field.type.name) - container.set_inline(False) - title = f"{field.type.name} (Tag Box)" # TODO translate - - if not is_mixed: - inner_container = container.get_inner_widget() - if isinstance(inner_container, TagBoxWidget): - inner_container.set_field(field) - inner_container.set_tags(list(field.tags)) - - try: - inner_container.updated.disconnect() - except RuntimeError: - logger.error("Failed to disconnect inner_container.updated") - - else: - inner_container = TagBoxWidget( - field, - title, - self.driver, - ) - - container.set_inner_widget(inner_container) - - inner_container.updated.connect( - lambda: ( - self.write_container(index, field), - self.update_widgets(), - ) - ) - # NOTE: Tag Boxes have no Edit Button (But will when you can convert field types) - container.set_remove_callback( - lambda: self.remove_message_box( - prompt=self.remove_field_prompt(field.type.name), - callback=lambda: ( - self.remove_field(field), - self.update_selected_entry(self.driver), - # reload entry and its fields - self.update_widgets(), - ), - ) - ) - else: - text = "Mixed Data" # TODO translate - title = f"{field.type.name} (Wacky Tag Box)" # TODO translate - inner_container = TextWidget(title, text) - container.set_inner_widget(inner_container) - - self.tags_updated.emit() - # self.dynamic_widgets.append(inner_container) - elif field.type.type == FieldTypeEnum.TEXT_LINE: - container.set_title(field.type.name) - container.set_inline(False) - - # Normalize line endings in any text content. - if not is_mixed: - assert isinstance(field.value, (str, type(None))) - text = field.value or "" - else: - text = "Mixed Data" # TODO translate - - title = f"{field.type.name} ({field.type.type.value})" - inner_container = TextWidget(title, text) - container.set_inner_widget(inner_container) - if not is_mixed: - modal = PanelModal( - EditTextLine(field.value), - title=title, - window_title=f"Edit {field.type.type.value}", # TODO translate - save_callback=( - lambda content: ( - self.update_field(field, content), - self.update_widgets(), - ) - ), - ) - if "pytest" in sys.modules: - # for better testability - container.modal = modal # type: ignore - - container.set_edit_callback(modal.show) - container.set_remove_callback( - lambda: self.remove_message_box( - prompt=self.remove_field_prompt(field.type.type.value), - callback=lambda: ( - self.remove_field(field), - self.update_widgets(), - ), - ) - ) - - elif field.type.type == FieldTypeEnum.TEXT_BOX: - container.set_title(field.type.name) - # container.set_editable(True) - container.set_inline(False) - # Normalize line endings in any text content. - if not is_mixed: - assert isinstance(field.value, (str, type(None))) - text = (field.value or "").replace("\r", "\n") - else: - text = "Mixed Data" # TODO translate - title = f"{field.type.name} (Text Box)" # TODO translate - inner_container = TextWidget(title, text) - container.set_inner_widget(inner_container) - if not is_mixed: - modal = PanelModal( - EditTextBox(field.value), - title=title, - window_title=f"Edit {field.type.name}", # TODO translate - save_callback=( - lambda content: ( - self.update_field(field, content), - self.update_widgets(), - ) - ), - ) - container.set_edit_callback(modal.show) - container.set_remove_callback( - lambda: self.remove_message_box( - prompt=self.remove_field_prompt(field.type.name), - callback=lambda: ( - self.remove_field(field), - self.update_widgets(), - ), - ) - ) - - elif field.type.type == FieldTypeEnum.DATETIME: - if not is_mixed: - try: - container.set_title(field.type.name) - # container.set_editable(False) - 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)" # TODO translate - inner_container = TextWidget(title, date.strftime("%D - %r")) - container.set_inner_widget(inner_container) - except Exception: - container.set_title(field.type.name) - # container.set_editable(False) - container.set_inline(False) - title = f"{field.type.name} (Date) (Unknown Format)" # TODO translate - inner_container = TextWidget(title, str(field.value)) - container.set_inner_widget(inner_container) - - container.set_remove_callback( - lambda: self.remove_message_box( - prompt=self.remove_field_prompt(field.type.name), - callback=lambda: ( - self.remove_field(field), - self.update_widgets(), - ), - ) - ) - else: - text = "Mixed Data" # TODO translate - title = f"{field.type.name} (Wacky Date)" # TODO translate - inner_container = TextWidget(title, text) - container.set_inner_widget(inner_container) - else: - logger.warning("write_container - unknown field", field=field) - container.set_title(field.type.name) - container.set_inline(False) - title = f"{field.type.name} (Unknown Field Type)" # TODO translate - inner_container = TextWidget(title, field.type.name) - container.set_inner_widget(inner_container) - container.set_remove_callback( - lambda: self.remove_message_box( - prompt=self.remove_field_prompt(field.type.name), - callback=lambda: ( - self.remove_field(field), - self.update_widgets(), - ), - ) - ) - - container.edit_button.setHidden(True) - container.setHidden(False) - self.place_add_field_button() - - def remove_field(self, field: BaseField): - """Remove a field from all selected Entries.""" - logger.info("removing field", field=field, selected=self.selected) - entry_ids = [] - - for grid_idx in self.selected: - entry = self.driver.frame_content[grid_idx] - entry_ids.append(entry.id) - - self.lib.remove_entry_field(field, entry_ids) - - # if the field is meta tags, update the badges - if field.type_key == _FieldID.TAGS_META.value: - self.driver.update_badges(self.selected) - - def update_field(self, field: BaseField, content: str) -> None: - """Update a field in all selected Entries, given a field object.""" - assert isinstance( - field, (TextField, DatetimeField, TagBoxField) - ), f"instance: {type(field)}" - - entry_ids = [] - for grid_idx in self.selected: - entry = self.driver.frame_content[grid_idx] - entry_ids.append(entry.id) - - assert entry_ids, "No entries selected" - self.lib.update_entry_field( - entry_ids, - field, - content, ) - def remove_message_box(self, prompt: str, callback: Callable) -> None: - remove_mb = QMessageBox() - remove_mb.setText(prompt) - remove_mb.setWindowTitle("Remove Field") # TODO translate - remove_mb.setIcon(QMessageBox.Icon.Warning) - cancel_button = remove_mb.addButton( - Translations["generic.cancel_alt"], QMessageBox.ButtonRole.DestructiveRole + self.add_tag_button.clicked.connect( + lambda: ( + self.add_tag_modal.widget.update_tags(), + self.add_tag_modal.show(), + ) ) - remove_mb.addButton("&Remove", QMessageBox.ButtonRole.RejectRole) # TODO translate - # 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 == 3: # TODO - what is this magic number? - callback() diff --git a/tagstudio/src/qt/widgets/tag.py b/tagstudio/src/qt/widgets/tag.py index 46f5fb9bb..efc288bbe 100644 --- a/tagstudio/src/qt/widgets/tag.py +++ b/tagstudio/src/qt/widgets/tag.py @@ -1,4 +1,4 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio @@ -114,8 +114,6 @@ def __init__( self.tag = tag self.has_edit = has_edit self.has_remove = has_remove - # self.bg_label = QLabel() - # self.setStyleSheet('background-color:blue;') # if on_click_callback: self.setCursor(Qt.CursorShape.PointingHandCursor) @@ -148,7 +146,7 @@ def __init__( self.inner_layout.setContentsMargins(2, 2, 2, 2) self.bg_button.setLayout(self.inner_layout) - self.bg_button.setMinimumSize(math.ceil(22 * 1.5), 22) + self.bg_button.setMinimumSize(math.ceil(22 * 2), 22) self.bg_button.setStyleSheet( f"QPushButton{{" diff --git a/tagstudio/src/qt/widgets/tag_box.py b/tagstudio/src/qt/widgets/tag_box.py old mode 100755 new mode 100644 index 8e095d79a..82d664ecb --- a/tagstudio/src/qt/widgets/tag_box.py +++ b/tagstudio/src/qt/widgets/tag_box.py @@ -1,22 +1,16 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -import math import typing import structlog -from PySide6.QtCore import Qt, Signal -from PySide6.QtWidgets import QPushButton -from src.core.constants import TAG_ARCHIVED, TAG_FAVORITE -from src.core.library import Entry, Tag +from PySide6.QtCore import Signal +from src.core.library import Tag from src.core.library.alchemy.enums import FilterState -from src.core.library.alchemy.fields import TagBoxField from src.qt.flowlayout import FlowLayout from src.qt.modals.build_tag import BuildTagPanel -from src.qt.modals.tag_search import TagSearchPanel -from src.qt.translations import Translations from src.qt.widgets.fields import FieldWidget from src.qt.widgets.panel import PanelModal from src.qt.widgets.tag import TagWidget @@ -33,15 +27,13 @@ class TagBoxWidget(FieldWidget): def __init__( self, - field: TagBoxField, + tags: set[Tag], title: str, driver: "QtDriver", ) -> None: super().__init__(title) - assert isinstance(field, TagBoxField), f"field is {type(field)}" - - self.field = field + self.tags: set[Tag] = tags self.driver = ( driver # Used for creating tag click callbacks that search entries for that tag. ) @@ -51,51 +43,13 @@ def __init__( self.base_layout.setContentsMargins(0, 0, 0, 0) self.setLayout(self.base_layout) - self.add_button = QPushButton() - self.add_button.setCursor(Qt.CursorShape.PointingHandCursor) - self.add_button.setMinimumSize(23, 23) - self.add_button.setMaximumSize(23, 23) - self.add_button.setText("+") - self.add_button.setStyleSheet( - f"QPushButton{{" - f"background: #1e1e1e;" - f"color: #FFFFFF;" - f"font-weight: bold;" - f"border-color: #333333;" - f"border-radius: 6px;" - f"border-style:solid;" - f"border-width:{math.ceil(self.devicePixelRatio())}px;" - f"padding-bottom: 5px;" - f"font-size: 20px;" - f"}}" - f"QPushButton::hover" - f"{{" - f"border-color: #CCCCCC;" - f"background: #555555;" - f"}}" - ) - tsp = TagSearchPanel(self.driver.lib) - tsp.tag_chosen.connect(lambda x: self.add_tag_callback(x)) - self.add_modal = PanelModal(tsp, title) - Translations.translate_with_setter(self.add_modal.setWindowTitle, "tag.add.plural") - self.add_button.clicked.connect( - lambda: ( - tsp.update_tags(), - self.add_modal.show(), - ) - ) - - self.set_tags(field.tags) - - def set_field(self, field: TagBoxField): - self.field = field + self.set_tags(self.tags) def set_tags(self, tags: typing.Iterable[Tag]): tags_ = sorted(list(tags), key=lambda tag: tag.name) - is_recycled = False - while self.base_layout.itemAt(0) and self.base_layout.itemAt(1): + logger.info("[TagBoxWidget] Tags:", tags=tags) + while self.base_layout.itemAt(0): self.base_layout.takeAt(0).widget().deleteLater() - is_recycled = True for tag in tags_: tag_widget = TagWidget(tag, has_edit=True, has_remove=True) @@ -115,70 +69,35 @@ def set_tags(self, tags: typing.Iterable[Tag]): tag_widget.on_edit.connect(lambda t=tag: self.edit_tag(t)) self.base_layout.addWidget(tag_widget) - # Move or add the '+' button. - if is_recycled: - self.base_layout.addWidget(self.base_layout.takeAt(0).widget()) - else: - self.base_layout.addWidget(self.add_button) - - # Handles an edge case where there are no more tags and the '+' button - # doesn't move all the way to the left. - if self.base_layout.itemAt(0) and not self.base_layout.itemAt(1): - self.base_layout.update() - def edit_tag(self, tag: Tag): assert isinstance(tag, Tag), f"tag is {type(tag)}" build_tag_panel = BuildTagPanel(self.driver.lib, tag=tag) self.edit_modal = PanelModal( build_tag_panel, - title=tag.name, # TODO - display name including subtags + tag.name, # TODO - display name including parent tags + "Edit Tag", done_callback=self.driver.preview_panel.update_widgets, has_save=True, ) - Translations.translate_with_setter(self.edit_modal.setWindowTitle, "tag.edit") # TODO - this was update_tag() self.edit_modal.saved.connect( lambda: self.driver.lib.update_tag( build_tag_panel.build_tag(), - subtag_ids=set(build_tag_panel.subtag_ids), + parent_ids=set(build_tag_panel.parent_ids), alias_names=set(build_tag_panel.alias_names), alias_ids=set(build_tag_panel.alias_ids), ) ) self.edit_modal.show() - def add_tag_callback(self, tag_id: int): - logger.info("add_tag_callback", tag_id=tag_id, selected=self.driver.selected) - - tag = self.driver.lib.get_tag(tag_id=tag_id) - for idx in self.driver.selected: - entry: Entry = self.driver.frame_content[idx] - - if not self.driver.lib.add_field_tag(entry, tag, self.field.type_key): - # TODO - add some visible error - self.error_occurred.emit(Exception("Failed to add tag")) - - self.updated.emit() - - if tag_id in (TAG_FAVORITE, TAG_ARCHIVED): - self.driver.update_badges() - - def edit_tag_callback(self, tag: Tag): - self.driver.lib.update_tag(tag) - def remove_tag(self, tag_id: int): logger.info( - "remove_tag", + "[TagBoxWidget] remove_tag", selected=self.driver.selected, - field_type=self.field.type, ) - for grid_idx in self.driver.selected: - entry = self.driver.frame_content[grid_idx] - self.driver.lib.remove_field_tag(entry, tag_id, self.field.type_key) + for entry_id in self.driver.selected: + self.driver.lib.remove_tags_from_entry(entry_id, tag_id) - self.updated.emit() - - if tag_id in (TAG_FAVORITE, TAG_ARCHIVED): - self.driver.update_badges() + self.updated.emit() diff --git a/tagstudio/src/qt/widgets/text.py b/tagstudio/src/qt/widgets/text.py index 8238f4c1d..28e21b032 100644 --- a/tagstudio/src/qt/widgets/text.py +++ b/tagstudio/src/qt/widgets/text.py @@ -1,4 +1,4 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio @@ -11,14 +11,11 @@ class TextWidget(FieldWidget): def __init__(self, title, text: str) -> None: super().__init__(title) - # self.item = item self.setObjectName("textBox") - # self.setStyleSheet('background-color:purple;') self.base_layout = QHBoxLayout() self.base_layout.setContentsMargins(0, 0, 0, 0) self.setLayout(self.base_layout) self.text_label = QLabel() - # self.text_label.textFormat(Qt.TextFormat.RichText) self.text_label.setStyleSheet("font-size: 12px") self.text_label.setWordWrap(True) self.text_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) diff --git a/tagstudio/src/qt/widgets/text_box_edit.py b/tagstudio/src/qt/widgets/text_box_edit.py index 44cca3f44..5f554501b 100644 --- a/tagstudio/src/qt/widgets/text_box_edit.py +++ b/tagstudio/src/qt/widgets/text_box_edit.py @@ -1,4 +1,4 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio @@ -10,7 +10,6 @@ class EditTextBox(PanelWidget): def __init__(self, text): super().__init__() - # self.setLayout() self.setMinimumSize(480, 480) self.root_layout = QVBoxLayout(self) self.root_layout.setContentsMargins(6, 0, 6, 0) diff --git a/tagstudio/src/qt/widgets/text_line_edit.py b/tagstudio/src/qt/widgets/text_line_edit.py index 74b65c5b2..09e2681cc 100644 --- a/tagstudio/src/qt/widgets/text_line_edit.py +++ b/tagstudio/src/qt/widgets/text_line_edit.py @@ -1,4 +1,4 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio from typing import Callable @@ -10,7 +10,6 @@ class EditTextLine(PanelWidget): def __init__(self, text): super().__init__() - # self.setLayout() self.setMinimumWidth(480) self.root_layout = QVBoxLayout(self) self.root_layout.setContentsMargins(6, 0, 6, 0) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index b17f519ad..4fea7f659 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -1,4 +1,4 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio @@ -72,7 +72,7 @@ class ThumbRenderer(QObject): """A class for rendering image and file thumbnails.""" rm: ResourceManager = ResourceManager() - updated = Signal(float, QPixmap, QSize, str, str) + updated = Signal(float, QPixmap, QSize, Path, str) updated_ratio = Signal(float) def __init__(self) -> None: @@ -1208,7 +1208,7 @@ def render_unlinked() -> Image.Image: math.ceil(adj_size / pixel_ratio), math.ceil(final.size[1] / pixel_ratio), ), - str(_filepath.name), + _filepath, _filepath.suffix.lower(), ) @@ -1217,6 +1217,6 @@ def render_unlinked() -> Image.Image: timestamp, QPixmap(), QSize(*base_size), - str(_filepath.name), + _filepath, _filepath.suffix.lower(), ) diff --git a/tagstudio/tests/conftest.py b/tagstudio/tests/conftest.py index 7a59d05e1..ae386f80c 100644 --- a/tagstudio/tests/conftest.py +++ b/tagstudio/tests/conftest.py @@ -12,7 +12,6 @@ from src.core.library import Entry, Library, Tag from src.core.library import alchemy as backend from src.core.library.alchemy.enums import TagColor -from src.core.library.alchemy.fields import TagBoxField, _FieldID from src.qt.ts_qt import QtDriver @@ -47,7 +46,7 @@ def file_mediatypes_library(): ) assert lib.add_entries([entry1, entry2, entry3]) - assert len(lib.tags) == 2 + assert len(lib.tags) == 3 return lib @@ -72,47 +71,40 @@ def library(request): ) assert lib.add_tag(tag) - subtag = Tag( + parent_tag = Tag( + id=1500, name="subbar", color=TagColor.YELLOW, ) + assert lib.add_tag(parent_tag) tag2 = Tag( + id=2000, name="bar", color=TagColor.BLUE, - subtags={subtag}, + parent_tags={parent_tag}, ) + assert lib.add_tag(tag2) # default item with deterministic name entry = Entry( + id=1, folder=lib.folder, path=pathlib.Path("foo.txt"), fields=lib.default_fields, ) - - entry.tag_box_fields = [ - TagBoxField(type_key=_FieldID.TAGS.name, tags={tag}, position=0), - TagBoxField( - type_key=_FieldID.TAGS_META.name, - position=0, - ), - ] + assert lib.add_tags_to_entry(entry.id, tag.id) entry2 = Entry( + id=2, folder=lib.folder, path=pathlib.Path("one/two/bar.md"), fields=lib.default_fields, ) - entry2.tag_box_fields = [ - TagBoxField( - tags={tag2}, - type_key=_FieldID.TAGS_META.name, - position=0, - ), - ] + assert lib.add_tags_to_entry(entry2.id, tag2.id) assert lib.add_entries([entry, entry2]) - assert len(lib.tags) == 5 + assert len(lib.tags) == 6 yield lib @@ -150,6 +142,7 @@ class Args: driver.preview_panel = Mock() driver.flow_container = Mock() driver.item_thumbs = [] + driver.autofill_action = Mock() driver.lib = library # TODO - downsize this method and use it diff --git a/tagstudio/tests/fixtures/search_library/.TagStudio/ts_library.sqlite b/tagstudio/tests/fixtures/search_library/.TagStudio/ts_library.sqlite index 449f380b0..2540a4682 100644 Binary files a/tagstudio/tests/fixtures/search_library/.TagStudio/ts_library.sqlite and b/tagstudio/tests/fixtures/search_library/.TagStudio/ts_library.sqlite differ diff --git a/tagstudio/tests/macros/test_sidecar.py b/tagstudio/tests/macros/test_sidecar.py index 2bea7ba8e..3ed35f2c5 100644 --- a/tagstudio/tests/macros/test_sidecar.py +++ b/tagstudio/tests/macros/test_sidecar.py @@ -1,36 +1,37 @@ -import shutil -from pathlib import Path -from tempfile import TemporaryDirectory +# import shutil +# from pathlib import Path +# from tempfile import TemporaryDirectory -import pytest -from src.core.enums import MacroID -from src.core.library.alchemy.fields import _FieldID +# import pytest +# from src.core.enums import MacroID +# from src.core.library.alchemy.fields import _FieldID -@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) +# @pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) def test_sidecar_macro(qt_driver, library, cwd, entry_full): - entry_full.path = Path("newgrounds/foo.txt") - - fixture = cwd / "fixtures/sidecar_newgrounds.json" - dst = library.library_dir / "newgrounds" / (entry_full.path.name + ".json") - dst.parent.mkdir() - shutil.copy(fixture, dst) - - qt_driver.frame_content = [entry_full] - qt_driver.run_macro(MacroID.SIDECAR, 0) - - entry = next(library.get_entries(with_joins=True)) - new_fields = ( - (_FieldID.DESCRIPTION.name, "NG description"), - (_FieldID.ARTIST.name, "NG artist"), - (_FieldID.SOURCE.name, "https://ng.com"), - (_FieldID.TAGS.name, None), - ) - found = [(field.type.key, field.value) for field in entry.fields] - - # `new_fields` should be subset of `found` - for field in new_fields: - assert field in found, f"Field not found: {field} / {found}" - - expected_tags = {"ng_tag", "ng_tag2"} - assert {x.name in expected_tags for x in entry.tags} + # TODO: Rework and finalize sidecar loading + macro systems. + pass + # entry_full.path = Path("newgrounds/foo.txt") + + # fixture = cwd / "fixtures/sidecar_newgrounds.json" + # dst = library.library_dir / "newgrounds" / (entry_full.path.name + ".json") + # dst.parent.mkdir() + # shutil.copy(fixture, dst) + + # qt_driver.frame_content = [entry_full] + # qt_driver.run_macro(MacroID.SIDECAR, entry_full.id) + + # entry = library.get_entry_full(entry_full.id) + # new_fields = ( + # (_FieldID.DESCRIPTION.name, "NG description"), + # (_FieldID.ARTIST.name, "NG artist"), + # (_FieldID.SOURCE.name, "https://ng.com"), + # ) + # found = [(field.type.key, field.value) for field in entry.fields] + + # # `new_fields` should be subset of `found` + # for field in new_fields: + # assert field in found, f"Field not found: {field} / {found}" + + # expected_tags = {"ng_tag", "ng_tag2"} + # assert {x.name in expected_tags for x in entry.tags} diff --git a/tagstudio/tests/qt/test_build_tag_panel.py b/tagstudio/tests/qt/test_build_tag_panel.py index d0cdea5b0..e631d1335 100644 --- a/tagstudio/tests/qt/test_build_tag_panel.py +++ b/tagstudio/tests/qt/test_build_tag_panel.py @@ -11,9 +11,9 @@ def test_build_tag_panel_add_sub_tag_callback(library, generate_tag): panel: BuildTagPanel = BuildTagPanel(library, child) - panel.add_subtag_callback(parent.id) + panel.add_parent_tag_callback(parent.id) - assert len(panel.subtag_ids) == 1 + assert len(panel.parent_ids) == 1 def test_build_tag_panel_remove_subtag_callback(library, generate_tag): @@ -30,9 +30,9 @@ def test_build_tag_panel_remove_subtag_callback(library, generate_tag): panel: BuildTagPanel = BuildTagPanel(library, child) - panel.remove_subtag_callback(parent.id) + panel.remove_parent_tag_callback(parent.id) - assert len(panel.subtag_ids) == 0 + assert len(panel.parent_ids) == 0 import os @@ -73,20 +73,20 @@ def test_build_tag_panel_remove_alias_callback(library, generate_tag): assert alias.name not in panel.alias_names -def test_build_tag_panel_set_subtags(library, generate_tag): +def test_build_tag_panel_set_parent_tags(library, generate_tag): parent = library.add_tag(generate_tag("parent", id=123)) child = library.add_tag(generate_tag("child", id=124)) assert parent assert child - library.add_subtag(child.id, parent.id) + library.add_parent_tag(child.id, parent.id) child = library.get_tag(child.id) panel: BuildTagPanel = BuildTagPanel(library, child) - assert len(panel.subtag_ids) == 1 - assert panel.subtags_scroll_layout.count() == 1 + assert len(panel.parent_ids) == 1 + assert panel.parent_tags_scroll_layout.count() == 1 def test_build_tag_panel_add_aliases(library, generate_tag): diff --git a/tagstudio/tests/qt/test_field_containers.py b/tagstudio/tests/qt/test_field_containers.py new file mode 100644 index 000000000..e501866d8 --- /dev/null +++ b/tagstudio/tests/qt/test_field_containers.py @@ -0,0 +1,172 @@ +from src.qt.widgets.preview_panel import PreviewPanel + + +def test_update_selection_empty(qt_driver, library): + panel = PreviewPanel(library, qt_driver) + + # Clear the library selection (selecting 1 then unselecting 1) + qt_driver.toggle_item_selection(1, append=False, bridge=False) + qt_driver.toggle_item_selection(1, append=True, bridge=False) + panel.update_widgets() + + # FieldContainer should hide all containers + for container in panel.fields.containers: + assert container.isHidden() + + +def test_update_selection_single(qt_driver, library, entry_full): + panel = PreviewPanel(library, qt_driver) + + # Select the single entry + qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) + panel.update_widgets() + + # FieldContainer should show all applicable tags and field containers + for container in panel.fields.containers: + assert not container.isHidden() + + +def test_update_selection_multiple(qt_driver, library): + # TODO: Implement mixed field editing. Currently these containers will be hidden, + # same as the empty selection behavior. + panel = PreviewPanel(library, qt_driver) + + # Select the multiple entries + qt_driver.toggle_item_selection(1, append=False, bridge=False) + qt_driver.toggle_item_selection(2, append=True, bridge=False) + panel.update_widgets() + + # FieldContainer should show mixed field editing + for container in panel.fields.containers: + assert container.isHidden() + + +def test_add_tag_to_selection_single(qt_driver, library, entry_full): + panel = PreviewPanel(library, qt_driver) + + assert {t.id for t in entry_full.tags} == {1000} + + # Select the single entry + qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) + panel.update_widgets() + + # Add new tag + panel.fields.add_tags_to_selected(2000) + + # Then reload entry + refreshed_entry = next(library.get_entries(with_joins=True)) + assert {t.id for t in refreshed_entry.tags} == {1000, 2000} + + +def test_add_same_tag_to_selection_single(qt_driver, library, entry_full): + panel = PreviewPanel(library, qt_driver) + + assert {t.id for t in entry_full.tags} == {1000} + + # Select the single entry + qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) + panel.update_widgets() + + # Add an existing tag + panel.fields.add_tags_to_selected(1000) + + # Then reload entry + refreshed_entry = next(library.get_entries(with_joins=True)) + assert {t.id for t in refreshed_entry.tags} == {1000} + + +def test_add_tag_to_selection_multiple(qt_driver, library): + panel = PreviewPanel(library, qt_driver) + all_entries = library.get_entries(with_joins=True) + + # We want to verify that tag 1000 is on some, but not all entries already. + tag_present_on_some: bool = False + tag_absent_on_some: bool = False + + for e in all_entries: + if 1000 in [t.id for t in e.tags]: + tag_present_on_some = True + else: + tag_absent_on_some = True + + assert tag_present_on_some + assert tag_absent_on_some + + # Select the multiple entries + for i, e in enumerate(library.get_entries(with_joins=True), start=0): + qt_driver.toggle_item_selection(e.id, append=(True if i == 0 else False), bridge=False) # noqa: SIM210 + panel.update_widgets() + + # Add new tag + panel.fields.add_tags_to_selected(1000) + + # Then reload all entries and recheck the presence of tag 1000 + refreshed_entries = library.get_entries(with_joins=True) + tag_present_on_some: bool = False + tag_absent_on_some: bool = False + + for e in refreshed_entries: + if 1000 in [t.id for t in e.tags]: + tag_present_on_some = True + else: + tag_absent_on_some = True + + assert tag_present_on_some + assert not tag_absent_on_some + + +def test_meta_tag_category(qt_driver, library, entry_full): + panel = PreviewPanel(library, qt_driver) + + # Ensure the Favorite tag is on entry_full + library.add_tags_to_entry(1, entry_full.id) + + # Select the single entry + qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) + panel.update_widgets() + + # FieldContainer should hide all containers + assert len(panel.fields.containers) == 3 + for i, container in enumerate(panel.fields.containers): + match i: + case 0: + # Check if the container is the Meta Tags category + assert container.title == f"

{library.get_tag(2).name}

" + case 1: + # Check if the container is the Tags category + assert container.title == "

Tags

" + case 2: + # Make sure the container isn't a duplicate Tags category + assert container.title != "

Tags

" + + +def test_custom_tag_category(qt_driver, library, entry_full): + panel = PreviewPanel(library, qt_driver) + + # Set tag 1000 (foo) as a category + tag = library.get_tag(1000) + tag.is_category = True + library.update_tag( + tag, + ) + + # Ensure the Favorite tag is on entry_full + library.add_tags_to_entry(1, entry_full.id) + + # Select the single entry + qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) + panel.update_widgets() + + # FieldContainer should hide all containers + assert len(panel.fields.containers) == 3 + for i, container in enumerate(panel.fields.containers): + match i: + case 0: + # Check if the container is the Meta Tags category + assert container.title == f"

{library.get_tag(2).name}

" + case 1: + # Check if the container is the custom "foo" category + assert container.title == f"

{tag.name}

" + case 2: + # Make sure the container isn't a plain Tags category + assert container.title != "

Tags

" diff --git a/tagstudio/tests/qt/test_preview_panel.py b/tagstudio/tests/qt/test_preview_panel.py index f8550b865..12b64af91 100644 --- a/tagstudio/tests/qt/test_preview_panel.py +++ b/tagstudio/tests/qt/test_preview_panel.py @@ -1,124 +1,39 @@ -from pathlib import Path -from tempfile import TemporaryDirectory - -import pytest -from src.core.library import Entry -from src.core.library.alchemy.enums import FieldTypeEnum -from src.core.library.alchemy.fields import TextField, _FieldID from src.qt.widgets.preview_panel import PreviewPanel -def test_update_widgets_not_selected(qt_driver, library): - qt_driver.frame_content = list(library.get_entries()) - qt_driver.selected = [] - +def test_update_selection_empty(qt_driver, library): panel = PreviewPanel(library, qt_driver) - panel.update_widgets() - - assert panel.preview_img.isVisible() - assert panel.file_label.text() == "No Items Selected" - -@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) -def test_update_widgets_single_selected(qt_driver, library): - qt_driver.frame_content = list(library.get_entries()) - qt_driver.selected = [0] - - panel = PreviewPanel(library, qt_driver) + # Clear the library selection (selecting 1 then unselecting 1) + qt_driver.toggle_item_selection(1, append=False, bridge=False) + qt_driver.toggle_item_selection(1, append=True, bridge=False) panel.update_widgets() - assert panel.preview_img.isVisible() - + # Panel should disable UI that allows for entry modification + assert not panel.add_tag_button.isEnabled() + assert not panel.add_field_button.isEnabled() -def test_update_widgets_multiple_selected(qt_driver, library): - # entry with no tag fields - entry = Entry( - path=Path("test.txt"), - folder=library.folder, - fields=[TextField(type_key=_FieldID.TITLE.name, position=0)], - ) - - assert not entry.tag_box_fields - - library.add_entries([entry]) - assert library.entries_count == 3 - - qt_driver.frame_content = list(library.get_entries()) - qt_driver.selected = [0, 1, 2] +def test_update_selection_single(qt_driver, library, entry_full): panel = PreviewPanel(library, qt_driver) - panel.update_widgets() - - assert {f.type_key for f in panel.common_fields} == { - _FieldID.TITLE.name, - } - - assert {f.type_key for f in panel.mixed_fields} == { - _FieldID.TAGS.name, - _FieldID.TAGS_META.name, - } - -def test_write_container_text_line(qt_driver, entry_full, library): - # Given - panel = PreviewPanel(library, qt_driver) - - field = entry_full.text_fields[0] - assert len(entry_full.text_fields) == 1 - assert field.type.type == FieldTypeEnum.TEXT_LINE - assert field.type.name == "Title" - - # set any value - field.value = "foo" - panel.write_container(0, field) - panel.selected = [0] - - assert len(panel.containers) == 1 - container = panel.containers[0] - widget = container.get_inner_widget() - # test it's not "mixed data" - assert widget.text_label.text() == "foo" - - # When update and submit modal - modal = panel.containers[0].modal - modal.widget.text_edit.setText("bar") - modal.save_button.click() - - # Then reload entry - entry_full = next(library.get_entries(with_joins=True)) - # the value was updated - assert entry_full.text_fields[0].value == "bar" - - -def test_remove_field(qt_driver, library): - # Given - panel = PreviewPanel(library, qt_driver) - entries = list(library.get_entries(with_joins=True)) - qt_driver.frame_content = entries - - # When second entry is selected - panel.selected = [1] - - field = entries[1].text_fields[0] - panel.write_container(0, field) - panel.remove_field(field) + # Select the single entry + qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) + panel.update_widgets() - entries = list(library.get_entries(with_joins=True)) - assert not entries[1].text_fields + # Panel should enable UI that allows for entry modification + assert panel.add_tag_button.isEnabled() + assert panel.add_field_button.isEnabled() -def test_update_field(qt_driver, library, entry_full): +def test_update_selection_multiple(qt_driver, library): panel = PreviewPanel(library, qt_driver) - # select both entries - qt_driver.frame_content = list(library.get_entries())[:2] - qt_driver.selected = [0, 1] - panel.selected = [0, 1] - - # update field - title_field = entry_full.text_fields[0] - panel.update_field(title_field, "meow") + # Select the multiple entries + qt_driver.toggle_item_selection(1, append=False, bridge=False) + qt_driver.toggle_item_selection(2, append=True, bridge=False) + panel.update_widgets() - for entry in library.get_entries(with_joins=True): - field = [x for x in entry.text_fields if x.type_key == title_field.type_key][0] - assert field.value == "meow" + # Panel should enable UI that allows for entry modification + assert panel.add_tag_button.isEnabled() + assert panel.add_field_button.isEnabled() diff --git a/tagstudio/tests/qt/test_qt_driver.py b/tagstudio/tests/qt/test_qt_driver.py index a8a484d11..c05453732 100644 --- a/tagstudio/tests/qt/test_qt_driver.py +++ b/tagstudio/tests/qt/test_qt_driver.py @@ -1,76 +1,70 @@ -from pathlib import Path -from unittest.mock import Mock - -from src.core.library import Entry from src.core.library.alchemy.enums import FilterState from src.core.library.json.library import ItemType from src.qt.widgets.item_thumb import ItemThumb +# def test_update_thumbs(qt_driver): +# qt_driver.frame_content = [ +# Entry( +# folder=qt_driver.lib.folder, +# path=Path("/tmp/foo"), +# fields=qt_driver.lib.default_fields, +# ) +# ] -def test_update_thumbs(qt_driver): - qt_driver.frame_content = [ - Entry( - folder=qt_driver.lib.folder, - path=Path("/tmp/foo"), - fields=qt_driver.lib.default_fields, - ) - ] - - qt_driver.item_thumbs = [] - for i in range(3): - qt_driver.item_thumbs.append( - ItemThumb( - mode=ItemType.ENTRY, - library=qt_driver.lib, - driver=qt_driver, - thumb_size=(100, 100), - grid_idx=i, - ) - ) +# qt_driver.item_thumbs = [] +# for _ in range(3): +# qt_driver.item_thumbs.append( +# ItemThumb( +# mode=ItemType.ENTRY, +# library=qt_driver.lib, +# driver=qt_driver, +# thumb_size=(100, 100), +# ) +# ) - qt_driver.update_thumbs() +# qt_driver.update_thumbs() - for idx, thumb in enumerate(qt_driver.item_thumbs): - # only first item is visible - assert thumb.isVisible() == (idx == 0) +# for idx, thumb in enumerate(qt_driver.item_thumbs): +# # only first item is visible +# assert thumb.isVisible() == (idx == 0) -def test_select_item_bridge(qt_driver, entry_min): - # mock some props since we're not running `start()` - qt_driver.autofill_action = Mock() - qt_driver.sort_fields_action = Mock() +# def test_toggle_item_selection_bridge(qt_driver, entry_min): +# # mock some props since we're not running `start()` +# qt_driver.autofill_action = Mock() +# qt_driver.sort_fields_action = Mock() - # set the content manually - qt_driver.frame_content = [entry_min] * 3 +# # set the content manually +# qt_driver.frame_content = [entry_min] * 3 - qt_driver.filter.page_size = 3 - qt_driver._init_thumb_grid() - assert len(qt_driver.item_thumbs) == 3 +# qt_driver.filter.page_size = 3 +# qt_driver._init_thumb_grid() +# assert len(qt_driver.item_thumbs) == 3 - # select first item - qt_driver.select_item(0, append=False, bridge=False) - assert qt_driver.selected == [0] +# # select first item +# qt_driver.toggle_item_selection(0, append=False, bridge=False) +# assert qt_driver.selected == [0] - # add second item to selection - qt_driver.select_item(1, append=False, bridge=True) - assert qt_driver.selected == [0, 1] +# # add second item to selection +# qt_driver.toggle_item_selection(1, append=False, bridge=True) +# assert qt_driver.selected == [0, 1] - # add third item to selection - qt_driver.select_item(2, append=False, bridge=True) - assert qt_driver.selected == [0, 1, 2] +# # add third item to selection +# qt_driver.toggle_item_selection(2, append=False, bridge=True) +# assert qt_driver.selected == [0, 1, 2] - # select third item only - qt_driver.select_item(2, append=False, bridge=False) - assert qt_driver.selected == [2] +# # select third item only +# qt_driver.toggle_item_selection(2, append=False, bridge=False) +# assert qt_driver.selected == [2] - qt_driver.select_item(0, append=False, bridge=True) - assert qt_driver.selected == [0, 1, 2] +# qt_driver.toggle_item_selection(0, append=False, bridge=True) +# assert qt_driver.selected == [0, 1, 2] def test_library_state_update(qt_driver): # Given - for idx, entry in enumerate(qt_driver.lib.get_entries(with_joins=True)): - thumb = ItemThumb(ItemType.ENTRY, qt_driver.lib, qt_driver, (100, 100), idx) + for entry in qt_driver.lib.get_entries(with_joins=True): + thumb = ItemThumb(ItemType.ENTRY, qt_driver.lib, qt_driver, (100, 100)) qt_driver.item_thumbs.append(thumb) qt_driver.frame_content.append(entry) @@ -83,21 +77,21 @@ def test_library_state_update(qt_driver): qt_driver.filter_items(state) assert qt_driver.filter.page_size == 10 assert len(qt_driver.frame_content) == 1 - entry = qt_driver.frame_content[0] + 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 assert len(qt_driver.frame_content) == 1 - entry = qt_driver.frame_content[0] + 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") qt_driver.filter_items(state) assert len(qt_driver.frame_content) == 1 - entry = qt_driver.frame_content[0] + entry = qt_driver.lib.get_entry_full(qt_driver.frame_content[0]) assert list(entry.tags)[0].name == "bar" diff --git a/tagstudio/tests/qt/test_tag_panel.py b/tagstudio/tests/qt/test_tag_panel.py index c09d5f777..77f21b2ef 100644 --- a/tagstudio/tests/qt/test_tag_panel.py +++ b/tagstudio/tests/qt/test_tag_panel.py @@ -10,7 +10,7 @@ def test_tag_panel(qtbot, library): def test_add_tag_callback(qt_driver): # Given - assert len(qt_driver.lib.tags) == 5 + assert len(qt_driver.lib.tags) == 6 qt_driver.add_tag_action_callback() # When @@ -20,5 +20,5 @@ def test_add_tag_callback(qt_driver): # Then tags: set[Tag] = qt_driver.lib.tags - assert len(tags) == 6 + assert len(tags) == 7 assert "xxx" in {tag.name for tag in tags} diff --git a/tagstudio/tests/qt/test_tag_widget.py b/tagstudio/tests/qt/test_tag_widget.py deleted file mode 100644 index 86158bf45..000000000 --- a/tagstudio/tests/qt/test_tag_widget.py +++ /dev/null @@ -1,110 +0,0 @@ -from unittest.mock import patch - -from src.core.library.alchemy.fields import _FieldID -from src.qt.modals.build_tag import BuildTagPanel -from src.qt.widgets.tag import TagWidget -from src.qt.widgets.tag_box import TagBoxWidget - - -def test_tag_widget(qtbot, library, qt_driver): - # given - entry = next(library.get_entries(with_joins=True)) - field = entry.tag_box_fields[0] - - tag_widget = TagBoxWidget(field, "title", qt_driver) - - qtbot.add_widget(tag_widget) - - assert not tag_widget.add_modal.isVisible() - - # when/then check no exception is raised - tag_widget.add_button.clicked.emit() - # check `tag_widget.add_modal` is visible - assert tag_widget.add_modal.isVisible() - - -def test_tag_widget_add_existing_raises(library, qt_driver, entry_full): - # Given - tag_field = [f for f in entry_full.tag_box_fields if f.type_key == _FieldID.TAGS.name][0] - assert len(entry_full.tags) == 1 - tag = next(iter(entry_full.tags)) - - # When - tag_widget = TagBoxWidget(tag_field, "title", qt_driver) - tag_widget.driver.frame_content = [entry_full] - tag_widget.driver.selected = [0] - - # Then - with patch.object(tag_widget, "error_occurred") as mocked: - tag_widget.add_modal.widget.tag_chosen.emit(tag.id) - assert mocked.emit.called - - -def test_tag_widget_add_new_pass(qtbot, library, qt_driver, generate_tag): - # Given - entry = next(library.get_entries(with_joins=True)) - field = entry.tag_box_fields[0] - - tag = generate_tag(name="new_tag") - library.add_tag(tag) - - tag_widget = TagBoxWidget(field, "title", qt_driver) - - qtbot.add_widget(tag_widget) - - tag_widget.driver.selected = [0] - with patch.object(tag_widget, "error_occurred") as mocked: - # When - tag_widget.add_modal.widget.tag_chosen.emit(tag.id) - - # Then - assert not mocked.emit.called - - -def test_tag_widget_remove(qtbot, qt_driver, library, entry_full): - tag = list(entry_full.tags)[0] - assert tag - - assert entry_full.tag_box_fields - tag_field = [f for f in entry_full.tag_box_fields if f.type_key == _FieldID.TAGS.name][0] - - tag_widget = TagBoxWidget(tag_field, "title", qt_driver) - tag_widget.driver.selected = [0] - - qtbot.add_widget(tag_widget) - - tag_widget = tag_widget.base_layout.itemAt(0).widget() - assert isinstance(tag_widget, TagWidget) - - tag_widget.remove_button.clicked.emit() - - entry = next(qt_driver.lib.get_entries(with_joins=True)) - assert not entry.tag_box_fields[0].tags - - -def test_tag_widget_edit(qtbot, qt_driver, library, entry_full): - # Given - entry = next(library.get_entries(with_joins=True)) - library.add_tag(list(entry.tags)[0]) - tag = library.get_tag(list(entry.tags)[0].id) - assert tag - - assert entry_full.tag_box_fields - tag_field = [f for f in entry_full.tag_box_fields if f.type_key == _FieldID.TAGS.name][0] - - tag_box_widget = TagBoxWidget(tag_field, "title", qt_driver) - tag_box_widget.driver.selected = [0] - - qtbot.add_widget(tag_box_widget) - - tag_widget = tag_box_widget.base_layout.itemAt(0).widget() - assert isinstance(tag_widget, TagWidget) - - # When - tag_box_widget.edit_tag(tag) - - # Then - panel = tag_box_widget.edit_modal.widget - assert isinstance(panel, BuildTagPanel) - assert panel.tag.name == tag.name - assert panel.name_field.text() == tag.name diff --git a/tagstudio/tests/test_library.py b/tagstudio/tests/test_library.py index 82f9522e8..ef88fe8bd 100644 --- a/tagstudio/tests/test_library.py +++ b/tagstudio/tests/test_library.py @@ -13,14 +13,11 @@ def test_library_add_alias(library, generate_tag): tag = library.add_tag(generate_tag("xxx", id=123)) assert tag - subtag_ids: set[int] = set() + parent_ids: set[int] = set() alias_ids: set[int] = set() alias_names: set[str] = set() alias_names.add("test_alias") - library.update_tag(tag, subtag_ids, alias_names, alias_ids) - - # Note: ask if it is expected behaviour that you need to re-request - # for the tag. Or if the tag in memory should be updated + library.update_tag(tag, parent_ids, alias_names, alias_ids) alias_ids = library.get_tag(tag.id).alias_ids assert len(alias_ids) == 1 @@ -30,12 +27,11 @@ def test_library_get_alias(library, generate_tag): tag = library.add_tag(generate_tag("xxx", id=123)) assert tag - subtag_ids: set[int] = set() + parent_ids: set[int] = set() alias_ids: set[int] = set() alias_names: set[str] = set() alias_names.add("test_alias") - library.update_tag(tag, subtag_ids, alias_names, alias_ids) - + library.update_tag(tag, parent_ids, alias_names, alias_ids) alias_ids = library.get_tag(tag.id).alias_ids assert library.get_alias(tag.id, alias_ids[0]).name == "test_alias" @@ -45,23 +41,20 @@ def test_library_update_alias(library, generate_tag): tag: Tag = library.add_tag(generate_tag("xxx", id=123)) assert tag - subtag_ids: set[int] = set() + parent_ids: set[int] = set() alias_ids: set[int] = set() alias_names: set[str] = set() alias_names.add("test_alias") - library.update_tag(tag, subtag_ids, alias_names, alias_ids) - - tag = library.get_tag(tag.id) - alias_ids = tag.alias_ids + library.update_tag(tag, parent_ids, alias_names, alias_ids) + alias_ids = library.get_tag(tag.id).alias_ids assert library.get_alias(tag.id, alias_ids[0]).name == "test_alias" alias_names.remove("test_alias") alias_names.add("alias_update") - library.update_tag(tag, subtag_ids, alias_names, alias_ids) + library.update_tag(tag, parent_ids, alias_names, alias_ids) tag = library.get_tag(tag.id) - assert len(tag.alias_ids) == 1 assert library.get_alias(tag.id, tag.alias_ids[0]).name == "alias_update" @@ -77,9 +70,7 @@ def test_library_add_file(library): ) assert not library.has_path_entry(entry.path) - assert library.add_entries([entry]) - assert library.has_path_entry(entry.path) @@ -96,7 +87,7 @@ def test_create_tag(library, generate_tag): assert tag_inc.id > 1000 -def test_tag_subtag_itself(library, generate_tag): +def test_tag_self_parent(library, generate_tag): # tag already exists assert not library.add_tag(generate_tag("foo", id=1000)) @@ -107,7 +98,7 @@ def test_tag_subtag_itself(library, generate_tag): library.update_tag(tag, {tag.id}, {}, {}) tag = library.get_tag(tag.id) - assert len(tag.subtag_ids) == 0 + assert len(tag.parent_ids) == 0 def test_library_search(library, generate_tag, entry_full): @@ -121,23 +112,13 @@ def test_library_search(library, generate_tag, entry_full): assert results.total_count == 1 assert len(results) == 1 - entry = results[0] - assert {x.name for x in entry.tags} == { - "foo", - } - - assert entry.tag_box_fields - def test_tag_search(library): tag = library.tags[0] assert library.search_tags(tag.name.lower()) - assert library.search_tags(tag.name.upper()) - assert library.search_tags(tag.name[2:-2]) - assert not library.search_tags(tag.name * 2) @@ -145,7 +126,7 @@ def test_get_entry(library: Library, entry_min): assert entry_min.id result = library.get_entry_full(entry_min.id) assert result - assert result.tags + assert len(result.tags) == 1 def test_entries_count(library): @@ -159,58 +140,22 @@ def test_entries_count(library): assert len(results) == 5 -def test_add_field_to_entry(library): +def test_parents_add(library, generate_tag): # Given - entry = Entry( - folder=library.folder, - path=Path("xxx"), - fields=library.default_fields, - ) - # meta tags + content tags - assert len(entry.tag_box_fields) == 2 - - assert library.add_entries([entry]) - - # When - library.add_entry_field_type(entry.id, field_id=_FieldID.TAGS) - - # Then - entry = [x for x in library.get_entries(with_joins=True) if x.path == entry.path][0] - # meta tags and tags field present - assert len(entry.tag_box_fields) == 3 - - -def test_add_field_tag(library: Library, entry_full, generate_tag): - # Given - tag_name = "xxx" - tag = generate_tag(tag_name) - tag_field = entry_full.tag_box_fields[0] - - # When - library.add_field_tag(entry_full, tag, tag_field.type_key) - - # Then - result = library.get_entry_full(entry_full.id) - tag_field = result.tag_box_fields[0] - assert [x.name for x in tag_field.tags if x.name == tag_name] - - -def test_subtags_add(library, generate_tag): - # Given - tag = library.tags[0] + tag: Tag = library.tags[0] assert tag.id is not None - subtag = generate_tag("subtag1") - subtag = library.add_tag(subtag) - assert subtag.id is not None + parent_tag = generate_tag("parent_tag_01") + parent_tag = library.add_tag(parent_tag) + assert parent_tag.id is not None # When - assert library.add_subtag(tag.id, subtag.id) + assert library.add_parent_tag(tag.id, parent_tag.id) # Then assert tag.id is not None tag = library.get_tag(tag.id) - assert tag.subtag_ids + assert tag.parent_ids def test_remove_tag(library, generate_tag): @@ -286,7 +231,7 @@ def test_remove_field_entry_with_multiple_field(library, entry_full): # When # add identical field - assert library.add_entry_field_type(entry_full.id, field_id=title_field.type_key) + assert library.add_field_to_entry(entry_full.id, field_id=title_field.type_key) # remove entry field library.remove_entry_field(title_field, [entry_full.id]) @@ -315,7 +260,7 @@ def test_update_entry_with_multiple_identical_fields(library, entry_full): # When # add identical field - library.add_entry_field_type(entry_full.id, field_id=title_field.type_key) + library.add_field_to_entry(entry_full.id, field_id=title_field.type_key) # update one of the fields library.update_entry_field( @@ -357,25 +302,21 @@ def test_mirror_entry_fields(library: Library, entry_full): entry = library.get_entry_full(entry_id) # make sure fields are there after getting it from the library again - assert len(entry.fields) == 4 + assert len(entry.fields) == 2 assert {x.type_key for x in entry.fields} == { _FieldID.TITLE.name, _FieldID.NOTES.name, - _FieldID.TAGS_META.name, - _FieldID.TAGS.name, } -def test_remove_tag_from_field(library, entry_full): - for field in entry_full.tag_box_fields: - for tag in field.tags: - removed_tag = tag.name - library.remove_tag_from_field(tag, field) - break +def test_remove_tag_from_entry(library, entry_full): + removed_tag_id = -1 + for tag in entry_full.tags: + removed_tag_id = tag.id + library.remove_tags_from_entry(entry_full.id, tag.id) entry = next(library.get_entries(with_joins=True)) - for field in entry.tag_box_fields: - assert removed_tag not in [tag.name for tag in field.tags] + assert removed_tag_id not in [t.id for t in entry.tags] @pytest.mark.parametrize( @@ -398,8 +339,8 @@ def test_update_field_order(library, entry_full): title_field = entry_full.text_fields[0] # When add two more fields - library.add_entry_field_type(entry_full.id, field_id=title_field.type_key, value="first") - library.add_entry_field_type(entry_full.id, field_id=title_field.type_key, value="second") + library.add_field_to_entry(entry_full.id, field_id=title_field.type_key, value="first") + library.add_field_to_entry(entry_full.id, field_id=title_field.type_key, value="second") # remove the one on first position assert title_field.position == 0 diff --git a/tagstudio/tests/test_search.py b/tagstudio/tests/test_search.py index 9f3754b43..3af487995 100644 --- a/tagstudio/tests/test_search.py +++ b/tagstudio/tests/test_search.py @@ -13,12 +13,12 @@ def verify_count(lib: Library, query: str, count: int): @pytest.mark.parametrize( ["query", "count"], [ - ("", 29), - ("path:*", 29), + ("", 31), + ("path:*", 31), ("path:*inherit*", 24), ("path:*comp*", 5), - ("special:untagged", 1), - ("filetype:png", 23), + ("special:untagged", 2), + ("filetype:png", 25), ("filetype:jpg", 6), ("filetype:'jpg'", 6), ("tag_id:1011", 5), @@ -68,7 +68,7 @@ def test_and(search_library: Library, query: str, count: int): ("circle or green", 14), ("green or circle", 14), ("filetype:jpg or tag:orange", 11), - ("red or filetype:png", 25), + ("red or filetype:png", 28), ("filetype:jpg or path:*comp*", 11), ], ) @@ -79,22 +79,22 @@ def test_or(search_library: Library, query: str, count: int): @pytest.mark.parametrize( ["query", "count"], [ - ("not unexistant", 29), + ("not unexistant", 31), ("not path:*", 0), - ("not not path:*", 29), - ("not special:untagged", 28), + ("not not path:*", 31), + ("not special:untagged", 29), ("not filetype:png", 6), - ("not filetype:jpg", 23), - ("not tag_id:1011", 24), - ("not tag_id:1038", 18), - ("not green", 24), + ("not filetype:jpg", 25), + ("not tag_id:1011", 26), + ("not tag_id:1038", 20), + ("not green", 26), ("tag:favorite", 0), - ("not circle", 18), - ("not tag:square", 18), + ("not circle", 20), + ("not tag:square", 20), ("circle and not square", 6), ("not circle and square", 6), - ("special:untagged or not filetype:jpg", 24), - ("not square or green", 20), + ("special:untagged or not filetype:jpg", 25), + ("not square or green", 22), ], ) def test_not(search_library: Library, query: str, count: int): @@ -108,7 +108,7 @@ def test_not(search_library: Library, query: str, count: int): ("(((tag_id:1041)))", 11), ("not (not tag_id:1041)", 11), ("((circle) and (not square))", 6), - ("(not ((square) OR (green)))", 15), + ("(not ((square) OR (green)))", 17), ("filetype:png and (tag:square or green)", 12), ], ) @@ -121,7 +121,7 @@ def test_parentheses(search_library: Library, query: str, count: int): [ ("ellipse", 17), ("yellow", 15), - ("color", 24), + ("color", 25), ("shape", 24), ("yellow not green", 10), ],