From 260a4cf12ee676d5c4c37adc80c7186367a3160c Mon Sep 17 00:00:00 2001 From: python357-1 Date: Sun, 1 Dec 2024 22:25:30 -0600 Subject: [PATCH 01/68] refactor: remove TagBoxField and TagField (NOT WORKING) --- tagstudio/src/core/library/alchemy/fields.py | 52 ++-- tagstudio/src/core/library/alchemy/joins.py | 14 +- tagstudio/src/core/library/alchemy/library.py | 230 +++++++++--------- tagstudio/src/core/library/alchemy/models.py | 53 +--- tagstudio/src/qt/widgets/migration_modal.py | 52 ++-- tagstudio/src/qt/widgets/preview_panel.py | 112 ++++----- tagstudio/src/qt/widgets/tag_box.py | 3 +- 7 files changed, 246 insertions(+), 270 deletions(-) diff --git a/tagstudio/src/core/library/alchemy/fields.py b/tagstudio/src/core/library/alchemy/fields.py index d0252ad9b..c4618b3e7 100644 --- a/tagstudio/src/core/library/alchemy/fields.py +++ b/tagstudio/src/core/library/alchemy/fields.py @@ -11,7 +11,7 @@ from .enums import FieldTypeEnum if TYPE_CHECKING: - from .models import Entry, Tag, ValueType + from .models import Entry, ValueType class BaseField(Base): @@ -75,31 +75,32 @@ 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 +# TODO: Remove +# 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): @@ -133,9 +134,10 @@ 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) + # TODO: Remove (i think) + # 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..640b97fcc 100644 --- a/tagstudio/src/core/library/alchemy/joins.py +++ b/tagstudio/src/core/library/alchemy/joins.py @@ -11,8 +11,16 @@ class TagSubtag(Base): child_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True) -class TagField(Base): - __tablename__ = "tag_fields" +# TODO: Remove +# class TagField(Base): +# __tablename__ = "tag_fields" +# +# 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) + + +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 845ae3a84..f1fb30af7 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -46,11 +46,10 @@ from .fields import ( BaseField, DatetimeField, - TagBoxField, TextField, _FieldID, ) -from .joins import TagField, TagSubtag +from .joins import TagSubtag from .models import Entry, Folder, Preferences, Tag, TagAlias, ValueType from .visitors import SQLBoolExpressionBuilder @@ -356,42 +355,43 @@ 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 + # TODO: Remove (i think) + # 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.""" @@ -438,14 +438,13 @@ def get_entries(self, with_joins: bool = False) -> Iterator[Entry]: if with_joins: # load Entry with all joins and all tags stmt = ( - stmt.outerjoin(Entry.text_fields) - .outerjoin(Entry.datetime_fields) - .outerjoin(Entry.tag_box_fields) + stmt.outerjoin(Entry.text_fields).outerjoin(Entry.datetime_fields) + # .outerjoin(Entry.tag_box_fields) ) 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.tag_box_fields).selectinload(TagBoxField.tags), ) stmt = stmt.distinct() @@ -567,7 +566,7 @@ def search_library( selectinload(Entry.text_fields), selectinload(Entry.datetime_fields), selectinload(Entry.tag_box_fields) - .joinedload(TagBoxField.tags) + # .joinedload(TagBoxField.tags) .options(selectinload(Tag.aliases), selectinload(Tag.subtags)), ) @@ -697,16 +696,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], @@ -836,24 +825,24 @@ def add_entry_field_type( field_id = field_id.name field = self.get_value_type(field_id) - field_model: TextField | DatetimeField | TagBoxField + field_model: TextField | DatetimeField # | TagBoxField 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.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( @@ -934,60 +923,61 @@ 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 - - try: - if not field: - field = TagBoxField( - type_key=field_key, - entry_id=entry.id, - position=0, - ) - session.add(field) - 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.exception(e) - session.rollback() - - return False + # TODO: Delete + # 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 + # + # try: + # if not field: + # field = TagBoxField( + # type_key=field_key, + # entry_id=entry.id, + # position=0, + # ) + # session.add(field) + # 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.exception(e) + # session.rollback() + # + # return False def save_library_backup_to_disk(self) -> Path: assert isinstance(self.library_dir, Path) diff --git a/tagstudio/src/core/library/alchemy/models.py b/tagstudio/src/core/library/alchemy/models.py index 1c06e0fd3..fe60fb36f 100644 --- a/tagstudio/src/core/library/alchemy/models.py +++ b/tagstudio/src/core/library/alchemy/models.py @@ -11,9 +11,7 @@ BooleanField, DatetimeField, FieldTypeEnum, - TagBoxField, TextField, - _FieldID, ) from .joins import TagSubtag @@ -125,6 +123,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,43 +133,27 @@ 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 + for tag in self.tags: + if tag.id == TAG_FAVORITE: + return True return False @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 + for tag in self.tags: + if tag.id == TAG_ARCHIVED: + return True return False def __init__( @@ -189,27 +173,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 +209,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 +216,7 @@ def as_field(self) -> BaseField: FieldClass = { # noqa: N806 FieldTypeEnum.TEXT_LINE: TextField, FieldTypeEnum.TEXT_BOX: TextField, - FieldTypeEnum.TAGS: TagBoxField, + # FieldTypeEnum.TAGS: TagBoxField, FieldTypeEnum.DATETIME: DatetimeField, FieldTypeEnum.BOOLEAN: BooleanField, } diff --git a/tagstudio/src/qt/widgets/migration_modal.py b/tagstudio/src/qt/widgets/migration_modal.py index 6e8aeb06b..e191e5970 100644 --- a/tagstudio/src/qt/widgets/migration_modal.py +++ b/tagstudio/src/qt/widgets/migration_modal.py @@ -17,15 +17,19 @@ 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.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.fields import TagBoxField, _FieldID +from src.core.library.alchemy.fields import _FieldID + +# from src.core.library.alchemy.joins import TagField, TagSubtag +from src.core.library.alchemy.joins import TagSubtag 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.qt.helpers.custom_runnable import CustomRunnable from src.qt.helpers.function_iterator import FunctionIterator @@ -501,25 +505,25 @@ 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, - ) - ) - ) - ) - - return set(tags) if tags else None - else: - return value if value else None + # 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, + # ) + # ) + # ) + # ) + # + # return set(tags) if tags else None + # else: + return value if value else None def sanitize_json_field(value): if isinstance(value, list): diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index 0bb1c1812..88798e9e9 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -40,7 +40,7 @@ BaseField, DatetimeField, FieldTypeEnum, - TagBoxField, + # TagBoxField, TextField, _FieldID, ) @@ -56,7 +56,6 @@ 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 @@ -877,59 +876,59 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): 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: + # if isinstance(field, TagBoxField): + # container.set_title(field.type.name) + # container.set_inline(False) + # title = f"{field.type.name} (Tag Box)" + # + # 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" + # title = f"{field.type.name} (Wacky Tag Box)" + # 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: + if field.type.type == FieldTypeEnum.TEXT_LINE: container.set_title(field.type.name) container.set_inline(False) @@ -1078,7 +1077,8 @@ def remove_field(self, field: BaseField): 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) + field, + (TextField, DatetimeField), # , TagBoxField) ), f"instance: {type(field)}" entry_ids = [] diff --git a/tagstudio/src/qt/widgets/tag_box.py b/tagstudio/src/qt/widgets/tag_box.py index 8e095d79a..e38052321 100755 --- a/tagstudio/src/qt/widgets/tag_box.py +++ b/tagstudio/src/qt/widgets/tag_box.py @@ -12,7 +12,8 @@ from src.core.constants import TAG_ARCHIVED, TAG_FAVORITE from src.core.library import Entry, Tag from src.core.library.alchemy.enums import FilterState -from src.core.library.alchemy.fields import TagBoxField + +# 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 ab59fc4a5041a6e8800bfabb4bc05e706701dc79 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Sun, 8 Dec 2024 14:28:47 -0800 Subject: [PATCH 02/68] refactor: remove tag field types --- tagstudio/src/core/constants.py | 3 +- tagstudio/src/core/library/alchemy/db.py | 4 +- tagstudio/src/core/library/alchemy/fields.py | 4 - tagstudio/src/core/library/alchemy/library.py | 145 +++---- tagstudio/src/core/library/alchemy/models.py | 13 +- tagstudio/src/qt/widgets/item_thumb.py | 22 +- tagstudio/src/qt/widgets/migration_modal.py | 129 +++--- tagstudio/src/qt/widgets/preview_panel.py | 10 +- tagstudio/src/qt/widgets/tag_box.py | 377 +++++++++--------- tagstudio/tests/conftest.py | 29 +- tagstudio/tests/qt/test_tag_widget.py | 152 +++---- tagstudio/tests/test_json_migration.py | 3 +- 12 files changed, 432 insertions(+), 459 deletions(-) mode change 100755 => 100644 tagstudio/src/qt/widgets/tag_box.py diff --git a/tagstudio/src/core/constants.py b/tagstudio/src/core/constants.py index 335df5530..5b0f17303 100644 --- a/tagstudio/src/core/constants.py +++ b/tagstudio/src/core/constants.py @@ -11,7 +11,8 @@ ) FONT_SAMPLE_SIZES: list[int] = [10, 15, 20] -TAG_FAVORITE = 1 TAG_ARCHIVED = 0 +TAG_FAVORITE = 1 +TAG_META = 2 RESERVED_TAG_START = 0 RESERVED_TAG_END = 999 diff --git a/tagstudio/src/core/library/alchemy/db.py b/tagstudio/src/core/library/alchemy/db.py index f48ed2b5b..938ae79f5 100644 --- a/tagstudio/src/core/library/alchemy/db.py +++ b/tagstudio/src/core/library/alchemy/db.py @@ -44,7 +44,9 @@ 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( + f"INSERT INTO tags (id, name, color, is_category) VALUES ({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 c4618b3e7..9f180cfce 100644 --- a/tagstudio/src/core/library/alchemy/fields.py +++ b/tagstudio/src/core/library/alchemy/fields.py @@ -134,10 +134,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) - # TODO: Remove (i think) - # 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/library.py b/tagstudio/src/core/library/alchemy/library.py index f1fb30af7..2e21e261a 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -7,7 +7,6 @@ from datetime import UTC, datetime from os import makedirs from pathlib import Path -from typing import Any from uuid import uuid4 import structlog @@ -38,6 +37,7 @@ BACKUP_FOLDER_NAME, TAG_ARCHIVED, TAG_FAVORITE, + TAG_META, TS_FOLDER_NAME, ) from ...enums import LibraryPrefs @@ -70,13 +70,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")}, + subtags={meta_tag}, color=TagColor.RED, ) - favorite_tag = Tag( id=TAG_FAVORITE, name="Favorite", @@ -84,10 +90,15 @@ def get_default_tags() -> tuple[Tag, ...]: TagAlias(name="Favorited"), TagAlias(name="Favorites"), }, + subtags={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()) - 2 @dataclass(frozen=True) @@ -156,14 +167,18 @@ def migrate_json_to_sqlite(self, json_lib: JsonLibrary): # Tags for tag in json_lib.tags: - self.add_tag( - Tag( - id=tag.id, - name=tag.name, - shorthand=tag.shorthand, - color=TagColor.get_color_from_str(tag.color), + if tag.id == TAG_ARCHIVED or tag.id == TAG_FAVORITE: + # Update built-in + pass + else: + self.add_tag( + Tag( + id=tag.id, + name=tag.name, + shorthand=tag.shorthand, + color=TagColor.get_color_from_str(tag.color), + ) ) - ) # Tag Aliases for tag in json_lib.tags: @@ -192,11 +207,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 {6, 7, 8}: + self.add_tags_to_entry(entry_id=entry.id + 1, tag_ids=v) + else: + 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, + ) # Preferences self.set_prefs(LibraryPrefs.EXTENSION_LIST, [x.strip(".") for x in json_lib.ext_list]) @@ -232,9 +251,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), @@ -255,14 +272,13 @@ def open_sqlite_library( with Session(self.engine) as session: make_tables(self.engine) - if add_default_data: - tags = get_default_tags() - try: - session.add_all(tags) - session.commit() - except IntegrityError: - # default tags may exist already - session.rollback() + 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 if not is_new: @@ -565,9 +581,9 @@ def search_library( 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.subtags) + ), ) statement = statement.distinct(Entry.id) @@ -923,61 +939,18 @@ def add_tag( session.rollback() return None - # TODO: Delete - # 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 - # - # try: - # if not field: - # field = TagBoxField( - # type_key=field_key, - # entry_id=entry.id, - # position=0, - # ) - # session.add(field) - # 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.exception(e) - # session.rollback() - # - # return False + def add_tags_to_entry(self, entry_id: int, tag_ids: int | list[int] | set[int]): + 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_: + session.add(TagEntry(tag_id=tag_id, entry_id=entry_id)) + session.flush() + session.commit() + except IntegrityError as e: + logger.exception(e) + session.rollback() + return None def save_library_backup_to_disk(self) -> Path: assert isinstance(self.library_dir, Path) @@ -1120,12 +1093,12 @@ def update_subtags(self, tag, subtag_ids, session): ) session.add(subtag) - 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 diff --git a/tagstudio/src/core/library/alchemy/models.py b/tagstudio/src/core/library/alchemy/models.py index fe60fb36f..a56e8b4c8 100644 --- a/tagstudio/src/core/library/alchemy/models.py +++ b/tagstudio/src/core/library/alchemy/models.py @@ -44,6 +44,7 @@ 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") @@ -84,6 +85,7 @@ def __init__( 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() @@ -92,6 +94,7 @@ def __init__( self.color = color self.icon = icon self.shorthand = shorthand + self.is_category = is_category assert not self.id self.id = id super().__init__() @@ -144,17 +147,11 @@ def fields(self) -> list[BaseField]: @property def is_favorited(self) -> bool: - for tag in self.tags: - if tag.id == TAG_FAVORITE: - return True - return False + return any(tag.id == TAG_FAVORITE for tag in self.tags) @property def is_archived(self) -> bool: - for tag in self.tags: - if tag.id == TAG_ARCHIVED: - return True - return False + return any(tag.id == TAG_ARCHIVED for tag in self.tags) def __init__( self, diff --git a/tagstudio/src/qt/widgets/item_thumb.py b/tagstudio/src/qt/widgets/item_thumb.py index f79708ad4..1a74304b8 100644 --- a/tagstudio/src/qt/widgets/item_thumb.py +++ b/tagstudio/src/qt/widgets/item_thumb.py @@ -25,7 +25,6 @@ TAG_FAVORITE, ) from src.core.library import Entry, ItemType, Library -from src.core.library.alchemy.fields import _FieldID from src.core.media_types import MediaCategories, MediaType from src.qt.flowlayout import FlowWidget from src.qt.helpers.file_opener import FileOpenerHelper @@ -508,9 +507,7 @@ def on_badge_check(self, badge_type: BadgeType): 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 - ) + self.toggle_item_tag(entry.id, toggle_value, tag_id) # update the entry self.driver.frame_content[idx] = self.lib.get_entry_full(entry.id) @@ -518,25 +515,16 @@ def on_badge_check(self, badge_type: BadgeType): 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_tag_from_entry(entry_id, tag_id) if self.driver.preview_panel.is_open: self.driver.preview_panel.update_widgets() diff --git a/tagstudio/src/qt/widgets/migration_modal.py b/tagstudio/src/qt/widgets/migration_modal.py index e191e5970..d141e5d34 100644 --- a/tagstudio/src/qt/widgets/migration_modal.py +++ b/tagstudio/src/qt/widgets/migration_modal.py @@ -24,10 +24,8 @@ from src.core.library.alchemy.enums import TagColor # from src.core.library.alchemy.fields import TagBoxField, _FieldID -from src.core.library.alchemy.fields import _FieldID - -# from src.core.library.alchemy.joins import TagField, TagSubtag from src.core.library.alchemy.joins import TagSubtag +from src.core.library.alchemy.library import DEFAULT_TAG_DIFF from src.core.library.alchemy.library import Library as SqliteLibrary from src.core.library.alchemy.models import Entry, TagAlias from src.core.library.json.library import Library as JsonLibrary # type: ignore @@ -395,7 +393,7 @@ def migration_iterator(self): except Exception as e: yield f"Error: {type(e).__name__}" - self.done = True + self.done = True def update_parity_ui(self): """Update all parity values UI.""" @@ -416,7 +414,7 @@ def update_sql_value_ui(self, show_msg_box: bool = True): ) self.update_sql_value( self.tags_row, - len(self.sql_lib.tags), + (len(self.sql_lib.tags) - DEFAULT_TAG_DIFF), self.old_tag_count, ) self.update_sql_value( @@ -551,13 +549,16 @@ def sanitize_json_field(value): return self.field_parity for sf in sql_entry.fields: - sql_fields.append( - ( - sql_entry.id, - sf.type.key, - sanitize_field(session, sql_entry, sf.value, sf.type.type, sf.type_key), + if sf.type.type.value not in {6, 7, 8}: + sql_fields.append( + ( + sql_entry.id, + sf.type.key, + sanitize_field( + session, 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 @@ -565,56 +566,51 @@ def sanitize_json_field(value): # 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 + # 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_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 + int_key: int = list(jf.keys())[0] 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: + if int_key in {6, 7, 8}: tags_count += 1 merged_tags = merged_tags.union(value or []) + pass else: - # JSON IDs start at 0 instead of 1 + key: str = self.sql_lib.get_field_name_from_id(int_key).name 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, - ) - ) + # TODO: DO NOT IGNORE TAGS + # 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, + # "TAGS", + # merged_tags if merged_tags else None, + # ) + # ) json_fields.sort() if not ( @@ -653,14 +649,17 @@ def check_subtag_parity(self) -> bool: with Session(self.sql_lib.engine) as session: for tag in self.sql_lib.tags: + if tag.id in range(0, 1000): + break 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_subtags = sql_subtags.difference([x for x in range(0, 1000)]) + # 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_subtags = set(self.json_lib.get_tag(tag_id).subtag_ids) + json_subtags.discard(tag_id) logger.info( "[Subtag Parity]", @@ -675,7 +674,8 @@ def check_subtag_parity(self) -> bool: and (sql_subtags == json_subtags) ): 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_subtags}\nNEW (SQL):{sql_subtags}" ) self.subtag_parity = False return self.subtag_parity @@ -693,6 +693,8 @@ def check_alias_parity(self) -> bool: with Session(self.sql_lib.engine) as session: for tag in self.sql_lib.tags: + if tag.id in range(0, 1000): + break tag_id = tag.id # Tag IDs start at 0 sql_aliases = set( session.scalars(select(TagAlias.name).where(TagAlias.tag_id == tag.id)) @@ -711,7 +713,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 @@ -725,6 +728,8 @@ def check_shorthand_parity(self) -> bool: json_shorthand: str = None for tag in self.sql_lib.tags: + if tag.id in range(0, 1000): + break tag_id = tag.id # Tag IDs start at 0 sql_shorthand = tag.shorthand json_shorthand = self.json_lib.get_tag(tag_id).shorthand @@ -742,7 +747,8 @@ def check_shorthand_parity(self) -> bool: and (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,11 +762,13 @@ def check_color_parity(self) -> bool: json_color: str = None for tag in self.sql_lib.tags: + if tag.id in range(0, 1000): + break tag_id = tag.id # Tag IDs start at 0 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 ) @@ -773,7 +781,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_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index 88798e9e9..5bf232d66 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -42,7 +42,6 @@ FieldTypeEnum, # TagBoxField, TextField, - _FieldID, ) from src.core.library.alchemy.library import Library from src.core.media_types import MediaCategories @@ -907,7 +906,8 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): # self.update_widgets(), # ) # ) - # # NOTE: Tag Boxes have no Edit Button (But will when you can convert field types) + # # 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), @@ -1070,9 +1070,9 @@ def remove_field(self, field: BaseField): 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) + # # 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.""" 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 e38052321..d31cefbbb --- a/tagstudio/src/qt/widgets/tag_box.py +++ b/tagstudio/src/qt/widgets/tag_box.py @@ -1,185 +1,192 @@ -# Copyright (C) 2024 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 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 - -if typing.TYPE_CHECKING: - from src.qt.ts_qt import QtDriver - -logger = structlog.get_logger(__name__) - - -class TagBoxWidget(FieldWidget): - updated = Signal() - error_occurred = Signal(Exception) - - def __init__( - self, - field: TagBoxField, - title: str, - driver: "QtDriver", - ) -> None: - super().__init__(title) - - assert isinstance(field, TagBoxField), f"field is {type(field)}" - - self.field = field - self.driver = ( - driver # Used for creating tag click callbacks that search entries for that tag. - ) - self.setObjectName("tagBox") - self.base_layout = FlowLayout() - self.base_layout.enable_grid_optimizations(value=False) - 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 - - 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): - self.base_layout.takeAt(0).widget().deleteLater() - is_recycled = True - - for tag in tags_: - tag_widget = TagWidget(tag, has_edit=True, has_remove=True) - tag_widget.on_click.connect( - lambda tag_id=tag.id: ( - self.driver.main_window.searchField.setText(f"tag_id:{tag_id}"), - self.driver.filter_items(FilterState.from_tag_id(tag_id)), - ) - ) - - tag_widget.on_remove.connect( - lambda tag_id=tag.id: ( - self.remove_tag(tag_id), - self.driver.preview_panel.update_widgets(), - ) - ) - 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 - 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), - 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", - 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) - - self.updated.emit() - - if tag_id in (TAG_FAVORITE, TAG_ARCHIVED): - self.driver.update_badges() +# # Copyright (C) 2024 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 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 + + +# if typing.TYPE_CHECKING: +# from src.qt.ts_qt import QtDriver + +# logger = structlog.get_logger(__name__) + + +# class TagBoxWidget(FieldWidget): +# updated = Signal() +# error_occurred = Signal(Exception) + +# def __init__( +# self, +# field: TagBoxField, +# title: str, +# driver: "QtDriver", +# ) -> None: +# super().__init__(title) + +# assert isinstance(field, TagBoxField), f"field is {type(field)}" + +# self.field = field +# self.driver = ( +# driver # Used for creating tag click callbacks that search entries for that tag. +# ) +# self.setObjectName("tagBox") +# self.base_layout = FlowLayout() +# self.base_layout.enable_grid_optimizations(value=False) +# 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 + +# 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): +# self.base_layout.takeAt(0).widget().deleteLater() +# is_recycled = True + +# for tag in tags_: +# tag_widget = TagWidget(tag, has_edit=True, has_remove=True) +# tag_widget.on_click.connect( +# lambda tag_id=tag.id: ( +# self.driver.main_window.searchField.setText(f"tag_id:{tag_id}"), +# self.driver.filter_items(FilterState.from_tag_id(tag_id)), +# ) +# ) + + +# tag_widget.on_remove.connect( +# lambda tag_id=tag.id: ( +# self.remove_tag(tag_id), +# self.driver.preview_panel.update_widgets(), +# ) +# ) +# 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() + + +# self.edit_modal = PanelModal( +# build_tag_panel, +# title=tag.name, # TODO - display name including subtags +# 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), +# alias_names=set(build_tag_panel.alias_names), +# alias_ids=set(build_tag_panel.alias_ids), +# ) +# ) +# self.edit_modal.show() + +# def edit_tag(self, tag: Tag): +# assert isinstance(tag, Tag), f"tag is {type(tag)}" +# build_tag_panel = BuildTagPanel(self.driver.lib, tag=tag) + + +# 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", +# 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) + +# self.updated.emit() + +# if tag_id in (TAG_FAVORITE, TAG_ARCHIVED): +# self.driver.update_badges() diff --git a/tagstudio/tests/conftest.py b/tagstudio/tests/conftest.py index 7a59d05e1..72924e201 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 @@ -90,26 +89,26 @@ def library(request): 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, - ), - ] + # entry.tag_box_fields = [ + # TagBoxField(type_key=_FieldID.TAGS.name, tags={tag}, position=0), + # TagBoxField( + # type_key=_FieldID.TAGS_META.name, + # position=0, + # ), + # ] entry2 = Entry( 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, - ), - ] + # entry2.tag_box_fields = [ + # TagBoxField( + # tags={tag2}, + # type_key=_FieldID.TAGS_META.name, + # position=0, + # ), + # ] assert lib.add_entries([entry, entry2]) assert len(lib.tags) == 5 diff --git a/tagstudio/tests/qt/test_tag_widget.py b/tagstudio/tests/qt/test_tag_widget.py index 86158bf45..2e402f50a 100644 --- a/tagstudio/tests/qt/test_tag_widget.py +++ b/tagstudio/tests/qt/test_tag_widget.py @@ -1,110 +1,110 @@ -from unittest.mock import patch +# 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 +# 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] +# 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) +# tag_widget = TagBoxWidget(field, "title", qt_driver) - qtbot.add_widget(tag_widget) +# qtbot.add_widget(tag_widget) - assert not tag_widget.add_modal.isVisible() +# 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() +# # 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)) +# 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] +# # 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 +# # 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] +# 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 = generate_tag(name="new_tag") +# library.add_tag(tag) - tag_widget = TagBoxWidget(field, "title", qt_driver) +# tag_widget = TagBoxWidget(field, "title", qt_driver) - qtbot.add_widget(tag_widget) +# 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) +# 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 +# # 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 +# 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] +# 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] +# tag_widget = TagBoxWidget(tag_field, "title", qt_driver) +# tag_widget.driver.selected = [0] - qtbot.add_widget(tag_widget) +# qtbot.add_widget(tag_widget) - tag_widget = tag_widget.base_layout.itemAt(0).widget() - assert isinstance(tag_widget, TagWidget) +# tag_widget = tag_widget.base_layout.itemAt(0).widget() +# assert isinstance(tag_widget, TagWidget) - tag_widget.remove_button.clicked.emit() +# tag_widget.remove_button.clicked.emit() - entry = next(qt_driver.lib.get_entries(with_joins=True)) - assert not entry.tag_box_fields[0].tags +# 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 +# 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] +# 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] +# tag_box_widget = TagBoxWidget(tag_field, "title", qt_driver) +# tag_box_widget.driver.selected = [0] - qtbot.add_widget(tag_box_widget) +# qtbot.add_widget(tag_box_widget) - tag_widget = tag_box_widget.base_layout.itemAt(0).widget() - assert isinstance(tag_widget, TagWidget) +# tag_widget = tag_box_widget.base_layout.itemAt(0).widget() +# assert isinstance(tag_widget, TagWidget) - # When - tag_box_widget.edit_tag(tag) +# # 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 +# # 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_json_migration.py b/tagstudio/tests/test_json_migration.py index c8ad58e6c..62078a07e 100644 --- a/tagstudio/tests/test_json_migration.py +++ b/tagstudio/tests/test_json_migration.py @@ -6,6 +6,7 @@ from time import time from src.core.enums import LibraryPrefs +from src.core.library.alchemy.library import DEFAULT_TAG_DIFF from src.qt.widgets.migration_modal import JsonMigrationModal CWD = pathlib.Path(__file__) @@ -29,7 +30,7 @@ def test_json_migration(): # Tags ===================================================================== # Count - assert len(modal.json_lib.tags) == len(modal.sql_lib.tags) + assert len(modal.json_lib.tags) == (len(modal.sql_lib.tags) - DEFAULT_TAG_DIFF) # Shorthand Parity assert modal.check_shorthand_parity() # Subtag/Parent Tag Parity From f431cf7a6b10c1c0579323ca3712e50bf5d27133 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Sun, 8 Dec 2024 15:26:51 -0800 Subject: [PATCH 03/68] ci: fix mypy and ruff tests --- tagstudio/src/qt/modals/folders_to_tags.py | 12 +++++------ tagstudio/src/qt/widgets/collage_icon.py | 23 +--------------------- tagstudio/tests/conftest.py | 2 +- 3 files changed, 7 insertions(+), 30 deletions(-) diff --git a/tagstudio/src/qt/modals/folders_to_tags.py b/tagstudio/src/qt/modals/folders_to_tags.py index f680fdc18..2557829e9 100644 --- a/tagstudio/src/qt/modals/folders_to_tags.py +++ b/tagstudio/src/qt/modals/folders_to_tags.py @@ -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 @@ -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") @@ -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/widgets/collage_icon.py b/tagstudio/src/qt/widgets/collage_icon.py index e15cdd52f..23df2bde4 100644 --- a/tagstudio/src/qt/widgets/collage_icon.py +++ b/tagstudio/src/qt/widgets/collage_icon.py @@ -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/tests/conftest.py b/tagstudio/tests/conftest.py index 72924e201..cf8b06f09 100644 --- a/tagstudio/tests/conftest.py +++ b/tagstudio/tests/conftest.py @@ -76,7 +76,7 @@ def library(request): color=TagColor.YELLOW, ) - tag2 = Tag( + Tag( name="bar", color=TagColor.BLUE, subtags={subtag}, From d17e279aff38aad15a8a23528f7de3f2ea88aba2 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Sat, 21 Dec 2024 20:06:07 -0800 Subject: [PATCH 04/68] refactor: split up preview_panel --- tagstudio/src/core/enums.py | 2 +- tagstudio/src/qt/ts_qt.py | 4 +- .../qt/widgets/preview/field_containers.py | 720 +++++++++++ .../src/qt/widgets/preview/file_attributes.py | 232 ++++ .../src/qt/widgets/preview/preview_thumb.py | 300 +++++ .../qt/widgets/preview/recent_libraries.py | 133 ++ tagstudio/src/qt/widgets/preview_panel.py | 1102 +---------------- 7 files changed, 1448 insertions(+), 1045 deletions(-) create mode 100644 tagstudio/src/qt/widgets/preview/field_containers.py create mode 100644 tagstudio/src/qt/widgets/preview/file_attributes.py create mode 100644 tagstudio/src/qt/widgets/preview/preview_thumb.py create mode 100644 tagstudio/src/qt/widgets/preview/recent_libraries.py diff --git a/tagstudio/src/core/enums.py b/tagstudio/src/core/enums.py index 416e8c645..781bba0ad 100644 --- a/tagstudio/src/core/enums.py +++ b/tagstudio/src/core/enums.py @@ -65,4 +65,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/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 6b6ee83a6..56513e8a8 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -138,6 +138,8 @@ def __init__(self, backend, args): self.args = args self.frame_content = [] self.filter = FilterState.show_all() + self.frame_content: list[Entry] = [] + self.filter = FilterState().show_all() self.pages_count = 0 self.scrollbar_pos = 0 @@ -642,7 +644,7 @@ def close_library(self, is_shutdown: bool = False): self.main_window.setWindowTitle(self.base_title) - self.selected = [] + self.selected: list[int] = [] self.frame_content = [] [x.set_mode(None) for x in self.item_thumbs] 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..c05950f7c --- /dev/null +++ b/tagstudio/src/qt/widgets/preview/field_containers.py @@ -0,0 +1,720 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import typing + +import structlog +from PySide6.QtCore import Signal +from PySide6.QtWidgets import ( + QWidget, +) +from src.core.library.alchemy.library import Library +from src.qt.translations import Translations + +if typing.TYPE_CHECKING: + from src.qt.ts_qt import QtDriver + +logger = structlog.get_logger(__name__) + + +class FieldContainers(QWidget): + """The Preview Panel Widget.""" + + tags_updated = Signal() + + 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.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(6, 1, 6, 6) + +# 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(6) + +# 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) + +# self.afb_container = QWidget() +# self.afb_layout = QVBoxLayout(self.afb_container) +# self.afb_layout.setContentsMargins(0, 12, 0, 0) + +# self.add_field_button = QPushButtonWrapper() +# self.add_field_button.setCursor(Qt.CursorShape.PointingHandCursor) +# self.add_field_button.setMinimumSize(96, 28) +# self.add_field_button.setMaximumSize(96, 28) +# 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() + +# def remove_field_prompt(self, name: str) -> str: +# return Translations.translate_formatted("library.field.confirm_remove", name=name) + +# def place_add_field_button(self): +# self.scroll_layout.addWidget(self.afb_container) +# self.scroll_layout.setAlignment(self.afb_container, Qt.AlignmentFlag.AlignHCenter) + +# if self.add_field_modal.is_connected: +# 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()) +# ) +# 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 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] + +# # if isinstance(field, TagBoxField): +# # container.set_title(field.type.name) +# # container.set_inline(False) +# # title = f"{field.type.name} (Tag Box)" +# # +# # 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" +# # title = f"{field.type.name} (Wacky Tag Box)" +# # 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: +# 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_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}", +# 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" +# title = f"{field.type.name} (Text Box)" +# 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}", +# 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)" +# 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)" +# 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" +# title = f"{field.type.name} (Wacky Date)" +# 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)" +# 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") +# 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.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() + +# 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 update_widgets(self): +# """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") +# 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] +# results = self.lib.search_library(FilterState(id=entry.id)) +# logger.info( +# "found item", +# entries=len(results.items), +# grid_idx=grid_idx, +# lookup_id=entry.id, +# ) +# self.driver.frame_content[grid_idx] = results[0] + +# 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") +# 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 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..23f207205 --- /dev/null +++ b/tagstudio/src/qt/widgets/preview/file_attributes.py @@ -0,0 +1,232 @@ +# Copyright (C) 2024 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 pathlib import Path + +import cv2 +import structlog +from humanfriendly import format_size +from PIL import Image, ImageFont, UnidentifiedImageError +from PIL.Image import DecompressionBombError +from PySide6.QtCore import Qt, Signal +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 +from src.qt.translations import Translations + +if typing.TYPE_CHECKING: + from src.qt.ts_qt import QtDriver + +logger = structlog.get_logger(__name__) + + +class FileAttributes(QWidget): + """The Preview Panel Widget.""" + + tags_updated = Signal() + + label_bg_color = ( + Theme.COLOR_BG_DARK.value + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else Theme.COLOR_DARK_LABEL.value + ) + + # panel_bg_color = ( + # Theme.COLOR_BG_DARK.value + # if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + # else Theme.COLOR_BG_LIGHT.value + # ) + + file_label_style = "font-size: 12px" + 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;" + ) + date_style = "font-size:12px;" + + 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 + + root_layout = QVBoxLayout(self) + root_layout.setContentsMargins(0, 0, 0, 0) + root_layout.setSpacing(6) + + 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(FileAttributes.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(FileAttributes.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(FileAttributes.date_style) + + self.dimensions_label = QLabel() + self.dimensions_label.setObjectName("dimensionsLabel") + self.dimensions_label.setWordWrap(True) + self.dimensions_label.setStyleSheet(FileAttributes.properties_style) + + 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.""" + logger.info(filepath) + 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")}" + ) + self.date_modified_label.setText( + f"Date Modified: {dt.strftime(modified, "%a, %x, %X")}" + ) + self.date_created_label.setHidden(False) + self.date_modified_label.setHidden(False) + elif filepath: + self.date_created_label.setText("Date Created: N/A") + self.date_modified_label.setText("Date Modified: N/A") + 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): + """Render the panel widgets with the newest data from the Library.""" + logger.info("update_stats", selected=filepath) + + if not filepath: + self.file_label.setText("No Items Selected") + self.file_label.set_file_path("") + self.file_label.setCursor(Qt.CursorShape.ArrowCursor) + self.dimensions_label.setText("") + else: + self.file_label.set_file_path(filepath) + + 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) + # 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) + + # TODO: Do this all somewhere else, this is just here temporarily. + ext: str = filepath.suffix.lower() + try: + image: Image.Image = Image.open(filepath) + # 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, # noqa: F821 + ) 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) + + def update_multi_selection(self, count: int): + # Multiple Selected Items + self.file_label.setText(f"{count} Items Selected") + self.file_label.setCursor(Qt.CursorShape.ArrowCursor) + self.file_label.set_file_path("") + self.dimensions_label.setText("") 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..f64fbfc57 --- /dev/null +++ b/tagstudio/src/qt/widgets/preview/preview_thumb.py @@ -0,0 +1,300 @@ +# Copyright (C) 2024 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 PIL.Image import DecompressionBombError +from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt, Signal +from PySide6.QtGui import QMovie, QResizeEvent +from PySide6.QtWidgets import ( + QHBoxLayout, + QLabel, + QWidget, +) +from src.core.library.alchemy.library import Library +from src.core.library.alchemy.models import Entry +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.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.""" + + tags_updated = Signal() + + 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 + + # 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) + + # self.open_file_action = QAction("Open file", self) + # 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.image_container.size().width(), + self.image_container.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.image_container.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.image_container.size().width(), + self.image_container.size().height(), + ) + + def update_preview(self, entry: Entry, filepath: Path) -> dict: + """Render a single file preview.""" + # self.tag_callback = tag_callback if tag_callback else None + + # # update list of libraries + # self.fill_libs_widget(self.libs_layout) + + # 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.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) + + # reload entry and fill it into the grid again + # 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. + ratio = self.devicePixelRatio() + self.thumb_renderer.render( + time.time(), + filepath, + (512, 512), + ratio, + update_on_ratio_change=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) + + # 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(str(filepath), QSize(image.width, image.height)) + self.resizeEvent( + QResizeEvent( + QSize(image.width, image.height), + QSize(image.width, image.height), + ) + ) + self.preview_vid.show() + + except (FileNotFoundError, cv2.error, UnidentifiedImageError, DecompressionBombError) as e: + 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 + logger.error(f"Preview thumb error: {e} - {filepath}") + + return {} diff --git a/tagstudio/src/qt/widgets/preview/recent_libraries.py b/tagstudio/src/qt/widgets/preview/recent_libraries.py new file mode 100644 index 000000000..f47c21635 --- /dev/null +++ b/tagstudio/src/qt/widgets/preview/recent_libraries.py @@ -0,0 +1,133 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import typing + +import structlog +from PySide6.QtWidgets import ( + QWidget, +) +from src.core.library.alchemy.library import Library +from src.qt.translations import Translations + +if typing.TYPE_CHECKING: + from src.qt.ts_qt import QtDriver + +logger = structlog.get_logger(__name__) + + +class RecentLibraries(QWidget): + """The Recent Libraries Widget.""" + + def __init__(self, library: Library, driver: "QtDriver"): + super().__init__() + + +# # keep list of rendered libraries to avoid needless re-rendering +# self.render_libs: set = set() +# self.library = library +# self.driver = driver +# layout = QVBoxLayout() + +# settings = 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.setLayout(layout) + +# 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)) + +# 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;"]) + +# 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) diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index 5bf232d66..41b474759 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -2,64 +2,23 @@ # 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 typing -from collections.abc import Callable -from datetime import datetime as dt from pathlib import Path -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, Signal +from PySide6.QtGui import QResizeEvent 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, -) 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.qt.modals.add_field import AddFieldModal -from src.qt.platform_strings import PlatformStrings +from src.core.library.alchemy.models import Entry +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 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.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 if typing.TYPE_CHECKING: from src.qt.ts_qt import QtDriver @@ -79,214 +38,38 @@ def __init__(self, library: Library, driver: "QtDriver"): 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.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.image_container.size().width(), - self.image_container.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.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.selected: list[int] = [] # New way of tracking items - scroll_container: QWidget = QWidget() - scroll_container.setObjectName("entryScrollContainer") - scroll_container.setLayout(self.scroll_layout) + self.thumb = PreviewThumb(library, driver) + self.file_attrs = FileAttributes(library, driver) + self.fields = FieldContainers(library, driver) - info_section = QWidget() - info_layout = QVBoxLayout(info_section) - info_layout.setContentsMargins(0, 0, 0, 0) - info_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() + # info_layout.addWidget(self.file_attrs) + # info_layout.addWidget(self.fields.scroll_area) 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.splitterMoved.connect( + # lambda: self.thumb.update_image_size( + # ( + # self.thumb.image_container.size().width(), + # self.thumb.image_container.size().height(), + # ) + # ) + # ) + + # splitter.addWidget(self.thumb.image_container) + # splitter.addWidget(self.thumb.media_player) + splitter.addWidget(self.file_attrs) + # splitter.addWidget(self.libs_flow_container) splitter.setStretchFactor(1, 2) - self.afb_container = QWidget() - self.afb_layout = QVBoxLayout(self.afb_container) - self.afb_layout.setContentsMargins(0, 12, 0, 0) - - self.add_field_button = QPushButtonWrapper() - self.add_field_button.setCursor(Qt.CursorShape.PointingHandCursor) - self.add_field_button.setMinimumSize(96, 28) - self.add_field_button.setMaximumSize(96, 28) - 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) @@ -302,810 +85,43 @@ def update_selected_entry(self, driver: "QtDriver"): ) 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)) - - 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;"]) - - 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()) - ) + # self.thumb.update_image_size( + # (self.thumb.image_container.size().width(), self.thumb.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) - - 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 place_add_field_button(self): - self.scroll_layout.addWidget(self.afb_container) - self.scroll_layout.setAlignment(self.afb_container, Qt.AlignmentFlag.AlignHCenter) - - if self.add_field_modal.is_connected: - 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()) - ) - 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_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_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) - + # No Items Selected + items: list[Entry] = [self.driver.frame_content[x] for x in self.driver.selected] + if len(self.driver.selected) == 0: + # TODO: Clear everything to default + # self.file_attrs.update_blank() + self.file_attrs.update_stats() + self.file_attrs.update_date_label() + pass + elif len(self.driver.selected) == 1: + entry: Entry = items[0] + filepath: Path = self.lib.library_dir / entry.path + + stats: dict = self.thumb.update_preview(entry, filepath) + self.file_attrs.update_stats(filepath) + self.file_attrs.update_date_label(filepath) + # TODO: Render regular single selection + # TODO: Return known attributes from thumb, and give those to field_attrs + # self.file_attrs.update_filename() + pass # 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 + # Render mixed selection + self.file_attrs.update_multi_selection(len(self.driver.selected)) + self.file_attrs.update_date_label() + # self.file_attrs.update_selection_count() + pass - # 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 = [] + # self.thumb.update_widgets() + # # self.file_attrs.update_widgets() + # self.fields.update_widgets() - # 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] - - # if isinstance(field, TagBoxField): - # container.set_title(field.type.name) - # container.set_inline(False) - # title = f"{field.type.name} (Tag Box)" - # - # 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" - # title = f"{field.type.name} (Wacky Tag Box)" - # 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: - 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" # 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 - ) - 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() From 3dd740fb0bf23edf39e5f87f53c29058cf1e2667 Mon Sep 17 00:00:00 2001 From: Jann Stute <46534683+Computerdores@users.noreply.github.com> Date: Sun, 22 Dec 2024 22:41:42 +0100 Subject: [PATCH 05/68] fix: search now uses `TagEntry` (#656) --- tagstudio/src/core/library/alchemy/library.py | 5 ++- .../src/core/library/alchemy/visitors.py | 31 +++++++++---------- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index 2e21e261a..b7d97c0af 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -49,7 +49,7 @@ TextField, _FieldID, ) -from .joins import TagSubtag +from .joins import TagEntry, TagSubtag from .models import Entry, Folder, Preferences, Tag, TagAlias, ValueType from .visitors import SQLBoolExpressionBuilder @@ -559,8 +559,7 @@ def search_library( if search.ast: start_time = time.time() - - statement = statement.outerjoin(Entry.tag_box_fields).where( + statement = statement.outerjoin(TagEntry).where( SQLBoolExpressionBuilder(self).visit(search.ast) ) diff --git a/tagstudio/src/core/library/alchemy/visitors.py b/tagstudio/src/core/library/alchemy/visitors.py index 5eed4580f..3117b5ae2 100644 --- a/tagstudio/src/core/library/alchemy/visitors.py +++ b/tagstudio/src/core/library/alchemy/visitors.py @@ -8,8 +8,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: @@ -72,19 +72,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 +101,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 +140,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 +153,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)) From d16dd57e8a3fb3874f9505c6702fcd398e0e65a1 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sun, 29 Dec 2024 23:44:16 -0800 Subject: [PATCH 06/68] fix: move theme check inside class --- .../src/qt/widgets/preview/file_attributes.py | 62 +++++++++---------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/tagstudio/src/qt/widgets/preview/file_attributes.py b/tagstudio/src/qt/widgets/preview/file_attributes.py index 23f207205..b564f6e15 100644 --- a/tagstudio/src/qt/widgets/preview/file_attributes.py +++ b/tagstudio/src/qt/widgets/preview/file_attributes.py @@ -37,33 +37,6 @@ class FileAttributes(QWidget): tags_updated = Signal() - label_bg_color = ( - Theme.COLOR_BG_DARK.value - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else Theme.COLOR_DARK_LABEL.value - ) - - # panel_bg_color = ( - # Theme.COLOR_BG_DARK.value - # if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - # else Theme.COLOR_BG_LIGHT.value - # ) - - file_label_style = "font-size: 12px" - 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;" - ) - date_style = "font-size:12px;" - def __init__(self, library: Library, driver: "QtDriver"): super().__init__() # self.is_connected = False @@ -76,29 +49,56 @@ def __init__(self, library: Library, driver: "QtDriver"): root_layout.setContentsMargins(0, 0, 0, 0) root_layout.setSpacing(6) + label_bg_color = ( + Theme.COLOR_BG_DARK.value + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else Theme.COLOR_DARK_LABEL.value + ) + + # panel_bg_color = ( + # Theme.COLOR_BG_DARK.value + # if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + # else Theme.COLOR_BG_LIGHT.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(FileAttributes.file_label_style) + 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(FileAttributes.date_style) + self.date_created_label.setStyleSheet(self.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(FileAttributes.date_style) + self.date_modified_label.setStyleSheet(self.date_style) self.dimensions_label = QLabel() self.dimensions_label.setObjectName("dimensionsLabel") self.dimensions_label.setWordWrap(True) - self.dimensions_label.setStyleSheet(FileAttributes.properties_style) + self.dimensions_label.setStyleSheet(self.properties_style) self.date_container = QWidget() date_layout = QVBoxLayout(self.date_container) From 3d7e0cb1bf7a9aadb73736ab5dca76df5c26dc22 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Tue, 31 Dec 2024 00:14:18 -0800 Subject: [PATCH 07/68] refactor: reimplement file previews --- tagstudio/src/core/library/alchemy/db.py | 3 +- tagstudio/src/core/library/alchemy/library.py | 8 +- tagstudio/src/qt/ts_qt.py | 3 +- .../src/qt/widgets/preview/file_attributes.py | 38 +- .../src/qt/widgets/preview/preview_thumb.py | 441 +++++++++++++----- tagstudio/src/qt/widgets/preview_panel.py | 22 +- 6 files changed, 353 insertions(+), 162 deletions(-) diff --git a/tagstudio/src/core/library/alchemy/db.py b/tagstudio/src/core/library/alchemy/db.py index 938ae79f5..9bcefdf47 100644 --- a/tagstudio/src/core/library/alchemy/db.py +++ b/tagstudio/src/core/library/alchemy/db.py @@ -45,7 +45,8 @@ def make_tables(engine: Engine) -> None: if not autoincrement_val or autoincrement_val <= RESERVED_TAG_END: conn.execute( text( - f"INSERT INTO tags (id, name, color, is_category) VALUES ({RESERVED_TAG_END}, 'temp', 1, false)" + "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}")) diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index b7d97c0af..5ab12d954 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -426,14 +426,14 @@ def get_entry_full(self, entry_id: int) -> Entry | None: 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.subtags) + ), ) entry = session.scalar(statement) if not entry: diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 56513e8a8..003502ca8 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -136,7 +136,6 @@ 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[Entry] = [] self.filter = FilterState().show_all() @@ -644,7 +643,7 @@ def close_library(self, is_shutdown: bool = False): self.main_window.setWindowTitle(self.base_title) - self.selected: list[int] = [] + self.selected = [] self.frame_content = [] [x.set_mode(None) for x in self.item_thumbs] diff --git a/tagstudio/src/qt/widgets/preview/file_attributes.py b/tagstudio/src/qt/widgets/preview/file_attributes.py index b564f6e15..0fcc46ba1 100644 --- a/tagstudio/src/qt/widgets/preview/file_attributes.py +++ b/tagstudio/src/qt/widgets/preview/file_attributes.py @@ -11,7 +11,7 @@ import cv2 import structlog from humanfriendly import format_size -from PIL import Image, ImageFont, UnidentifiedImageError +from PIL import ImageFont, UnidentifiedImageError from PIL.Image import DecompressionBombError from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QGuiApplication @@ -138,10 +138,13 @@ def update_date_label(self, filepath: Path | None = None) -> None: self.date_created_label.setHidden(True) self.date_modified_label.setHidden(True) - def update_stats(self, filepath: Path | None = None): + def update_stats(self, filepath: Path | None = None, stats: dict = None): """Render the panel widgets with the newest data from the Library.""" logger.info("update_stats", selected=filepath) + if not stats: + stats = {} + if not filepath: self.file_label.setText("No Items Selected") self.file_label.set_file_path("") @@ -169,24 +172,11 @@ def update_stats(self, filepath: Path | None = None): # TODO: Do this all somewhere else, this is just here temporarily. ext: str = filepath.suffix.lower() try: - image: Image.Image = Image.open(filepath) - # 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( + self.dimensions_label.setText( + f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}\n" + f"{stats.get("width")} x {stats.get("height")} px" + ) + if MediaCategories.is_ext_in_category( ext, MediaCategories.FONT_TYPES, mime_fallback=True ): try: @@ -205,7 +195,7 @@ def update_stats(self, filepath: Path | None = None): self.dimensions_label.setText( f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}" ) - self.update_date_label(filepath) + # self.update_date_label(filepath) if not filepath.is_file(): raise FileNotFoundError @@ -213,7 +203,7 @@ def update_stats(self, filepath: Path | None = None): 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() + # self.update_date_label() except ( UnidentifiedImageError, DecompressionBombError, # noqa: F821 @@ -222,7 +212,9 @@ def update_stats(self, filepath: Path | None = None): 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) + # self.update_date_label(filepath) + + return stats def update_multi_selection(self, count: int): # Multiple Selected Items diff --git a/tagstudio/src/qt/widgets/preview/preview_thumb.py b/tagstudio/src/qt/widgets/preview/preview_thumb.py index f64fbfc57..23cb56fb0 100644 --- a/tagstudio/src/qt/widgets/preview/preview_thumb.py +++ b/tagstudio/src/qt/widgets/preview/preview_thumb.py @@ -11,21 +11,20 @@ import rawpy import structlog from PIL import Image, UnidentifiedImageError -from PIL.Image import DecompressionBombError from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt, Signal -from PySide6.QtGui import QMovie, QResizeEvent +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.library.alchemy.models import Entry 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 @@ -58,26 +57,26 @@ def __init__(self, library: Library, driver: "QtDriver"): # else Theme.COLOR_BG_LIGHT.value # ) - self.image_container = QWidget() - image_layout = QHBoxLayout(self.image_container) + # self.image_container = QWidget() + image_layout = QHBoxLayout(self) image_layout.setContentsMargins(0, 0, 0, 0) - # self.open_file_action = QAction("Open file", self) - # self.open_explorer_action = QAction(PlatformStrings.open_file_str, self) + self.open_file_action = QAction("Open file", self) + 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_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.addAction(self.open_file_action) + self.preview_gif.addAction(self.open_explorer_action) self.preview_gif.hide() self.gif_buffer: QBuffer = QBuffer() @@ -90,8 +89,8 @@ def __init__(self, library: Library, driver: "QtDriver"): self.set_image_ratio(ratio), self.update_image_size( ( - self.image_container.size().width(), - self.image_container.size().height(), + self.size().width(), + self.size().height(), ), ratio, ), @@ -107,7 +106,7 @@ def __init__(self, library: Library, driver: "QtDriver"): 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.setMinimumSize(*self.img_button_size) def set_image_ratio(self, ratio: float): self.image_ratio = ratio @@ -150,12 +149,217 @@ def update_image_size(self, size: tuple[int, int], ratio: float = None): def get_preview_size(self) -> tuple[int, int]: return ( - self.image_container.size().width(), - self.image_container.size().height(), + self.size().width(), + self.size().height(), ) - def update_preview(self, entry: Entry, filepath: Path) -> dict: + 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 _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: + logger.error("welp", filepath=filepath) + 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() + + 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.switch_preview("image") + self.thumb_renderer.render( + time.time(), + filepath, + (512, 512), + self.devicePixelRatio(), + update_on_ratio_change=True, + ) + self.preview_img.show() + 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 + 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() + return stats + + def update_preview(self, filepath: Path) -> dict: """Render a single file preview.""" + stats: dict = {} + ext: str = filepath.suffix.lower() + stats["ext"] = ext + + if MediaCategories.is_ext_in_category( + ext, MediaCategories.VIDEO_TYPES, mime_fallback=True + ) and is_readable_video(filepath): + stats = self._update_video_legacy(filepath) + + 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, + ) + + elif MediaCategories.is_ext_in_category( + ext, MediaCategories.IMAGE_ANIMATED_TYPES, mime_fallback=True + ): + stats = self._update_animation(filepath, ext) + + 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 + # self.tag_callback = tag_callback if tag_callback else None # # update list of libraries @@ -189,112 +393,109 @@ def update_preview(self, entry: Entry, filepath: Path) -> dict: # 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. - ratio = self.devicePixelRatio() - self.thumb_renderer.render( - time.time(), - filepath, - (512, 512), - ratio, - update_on_ratio_change=True, - ) + # ratio = self.devicePixelRatio() + # self.thumb_renderer.render( + # time.time(), + # filepath, + # (512, 512), + # ratio, + # update_on_ratio_change=True, + # ) - self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) - self.preview_img.setCursor(Qt.CursorShape.PointingHandCursor) + # self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + # self.preview_img.setCursor(Qt.CursorShape.PointingHandCursor) - self.opener = FileOpenerHelper(filepath) + # 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(str(filepath), QSize(image.width, image.height)) - self.resizeEvent( - QResizeEvent( - QSize(image.width, image.height), - QSize(image.width, image.height), - ) - ) - self.preview_vid.show() - - except (FileNotFoundError, cv2.error, UnidentifiedImageError, DecompressionBombError) as e: - 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 - logger.error(f"Preview thumb error: {e} - {filepath}") - - return {} + # # 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(str(filepath), QSize(image.width, image.height)) + # # self.resizeEvent( + # # QResizeEvent( + # # QSize(image.width, image.height), + # # QSize(image.width, image.height), + # # ) + # # ) + # self.preview_vid.show() + + # except (FileNotFoundError, cv2.error, UnidentifiedImageError, DecompressionBombError) as e: + # 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 + # logger.error(f"Preview thumb error: {e} - {filepath}") + + # return stats + + 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 41b474759..638eacb12 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -7,12 +7,7 @@ import structlog from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QResizeEvent -from PySide6.QtWidgets import ( - QHBoxLayout, - QSplitter, - QWidget, -) +from PySide6.QtWidgets import QHBoxLayout, QSplitter, QWidget from src.core.library.alchemy.library import Library from src.core.library.alchemy.models import Entry from src.qt.widgets.preview.field_containers import FieldContainers @@ -66,6 +61,8 @@ def __init__(self, library: Library, driver: "QtDriver"): # splitter.addWidget(self.thumb.image_container) # splitter.addWidget(self.thumb.media_player) + splitter.addWidget(self.thumb) + splitter.addWidget(self.thumb.media_player) splitter.addWidget(self.file_attrs) # splitter.addWidget(self.libs_flow_container) splitter.setStretchFactor(1, 2) @@ -85,11 +82,11 @@ def update_selected_entry(self, driver: "QtDriver"): ) self.driver.frame_content[grid_idx] = result - def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 - # self.thumb.update_image_size( - # (self.thumb.image_container.size().width(), self.thumb.image_container.size().height()) - # ) - return super().resizeEvent(event) + # def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 + # # self.thumb.update_image_size( + # # (self.thumb.image_container.size().width(), self.thumb.image_container.size().height()) + # # ) + # return super().resizeEvent(event) def update_widgets(self) -> bool: """Render the panel widgets with the newest data from the Library.""" @@ -105,7 +102,8 @@ def update_widgets(self) -> bool: entry: Entry = items[0] filepath: Path = self.lib.library_dir / entry.path - stats: dict = self.thumb.update_preview(entry, filepath) + stats: dict = self.thumb.update_preview(filepath) + logger.info(stats) self.file_attrs.update_stats(filepath) self.file_attrs.update_date_label(filepath) # TODO: Render regular single selection From eba55833929eec7049f70c6e3adf390ac4aae752 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Tue, 31 Dec 2024 03:27:27 -0800 Subject: [PATCH 08/68] refactor: modularize `file_attributes.py` --- .../src/qt/widgets/preview/file_attributes.py | 111 ++++---- .../src/qt/widgets/preview/preview_thumb.py | 254 +++++------------- tagstudio/src/qt/widgets/preview_panel.py | 13 +- 3 files changed, 130 insertions(+), 248 deletions(-) diff --git a/tagstudio/src/qt/widgets/preview/file_attributes.py b/tagstudio/src/qt/widgets/preview/file_attributes.py index 0fcc46ba1..96151abcf 100644 --- a/tagstudio/src/qt/widgets/preview/file_attributes.py +++ b/tagstudio/src/qt/widgets/preview/file_attributes.py @@ -6,13 +6,12 @@ import platform import typing from datetime import datetime as dt +from datetime import timedelta from pathlib import Path -import cv2 import structlog from humanfriendly import format_size -from PIL import ImageFont, UnidentifiedImageError -from PIL.Image import DecompressionBombError +from PIL import ImageFont from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QGuiApplication from PySide6.QtWidgets import ( @@ -138,7 +137,7 @@ def update_date_label(self, filepath: Path | None = None) -> None: self.date_created_label.setHidden(True) self.date_modified_label.setHidden(True) - def update_stats(self, filepath: Path | None = None, stats: dict = None): + def update_stats(self, filepath: Path | None = None, ext: str = ".", stats: dict = None): """Render the panel widgets with the newest data from the Library.""" logger.info("update_stats", selected=filepath) @@ -169,52 +168,68 @@ def update_stats(self, filepath: Path | None = None, stats: dict = None): # Translations.translate_qobject(self.open_file_action, "file.open_file") # self.open_explorer_action = QAction(PlatformStrings.open_file_str, self) - # TODO: Do this all somewhere else, this is just here temporarily. - ext: str = filepath.suffix.lower() - try: - self.dimensions_label.setText( - f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}\n" - f"{stats.get("width")} x {stats.get("height")} px" - ) - if MediaCategories.is_ext_in_category( - ext, MediaCategories.FONT_TYPES, mime_fallback=True - ): - try: + # 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) - 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)}" + 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 ) - # 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, # noqa: F821 - ) 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) - - return stats + + # Format and display any stat variables + def add_newline(stats_label_text: str) -> str: + logger.info(stats_label_text[-2:]) + 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): # Multiple Selected Items diff --git a/tagstudio/src/qt/widgets/preview/preview_thumb.py b/tagstudio/src/qt/widgets/preview/preview_thumb.py index 23cb56fb0..057551d99 100644 --- a/tagstudio/src/qt/widgets/preview/preview_thumb.py +++ b/tagstudio/src/qt/widgets/preview/preview_thumb.py @@ -51,13 +51,6 @@ def __init__(self, library: Library, driver: "QtDriver"): self.img_button_size: tuple[int, int] = (266, 266) self.image_ratio: float = 1.0 - # 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_layout.setContentsMargins(0, 0, 0, 0) @@ -171,6 +164,22 @@ def switch_preview(self, preview: str): 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 = {} @@ -199,8 +208,8 @@ def _update_image(self, filepath: Path, ext: str) -> dict: image = Image.open(str(filepath)) stats["width"] = image.width stats["height"] = image.height - except UnidentifiedImageError: - logger.error("welp", filepath=filepath) + 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 ): @@ -219,51 +228,48 @@ def _update_animation(self, filepath: Path, ext: str) -> dict: self.preview_gif.movie().stop() self.gif_buffer.close() - 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.switch_preview("image") - self.thumb_renderer.render( - time.time(), - filepath, - (512, 512), - self.devicePixelRatio(), - update_on_ratio_change=True, + 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, ) - self.preview_img.show() - 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), + 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() + 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) - stats["duration"] = movie.frameCount() // 60 return stats def _update_video_legacy(self, filepath: Path) -> dict: @@ -302,20 +308,20 @@ def _update_media(self, filepath: Path) -> dict: self.media_player.show() self.media_player.play(filepath) - stats["duration"] = self.media_player.player.duration() + stats["duration"] = self.media_player.player.duration() * 1000 return stats - def update_preview(self, filepath: Path) -> dict: + def update_preview(self, filepath: Path, ext: str) -> dict: """Render a single file preview.""" stats: dict = {} - ext: str = filepath.suffix.lower() - stats["ext"] = ext + # 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 ): @@ -329,11 +335,13 @@ def update_preview(self, filepath: Path) -> dict: 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) @@ -360,142 +368,6 @@ def update_preview(self, filepath: Path) -> dict: return stats - # self.tag_callback = tag_callback if tag_callback else None - - # # update list of libraries - # self.fill_libs_widget(self.libs_layout) - - # 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.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) - - # reload entry and fill it into the grid again - # 1 Selected Entry - # selected_idx = self.driver.selected[0] - # item = self.driver.frame_content[selected_idx] - - # If a new selection is made, update the thumbnail and filepath. - # ratio = self.devicePixelRatio() - # self.thumb_renderer.render( - # time.time(), - # filepath, - # (512, 512), - # ratio, - # update_on_ratio_change=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) - - # # 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(str(filepath), QSize(image.width, image.height)) - # # self.resizeEvent( - # # QResizeEvent( - # # QSize(image.width, image.height), - # # QSize(image.width, image.height), - # # ) - # # ) - # self.preview_vid.show() - - # except (FileNotFoundError, cv2.error, UnidentifiedImageError, DecompressionBombError) as e: - # 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 - # logger.error(f"Preview thumb error: {e} - {filepath}") - - # return stats - 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 638eacb12..cb8a50f8a 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -82,12 +82,6 @@ def update_selected_entry(self, driver: "QtDriver"): ) self.driver.frame_content[grid_idx] = result - # def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 - # # self.thumb.update_image_size( - # # (self.thumb.image_container.size().width(), self.thumb.image_container.size().height()) - # # ) - # return super().resizeEvent(event) - def update_widgets(self) -> bool: """Render the panel widgets with the newest data from the Library.""" # No Items Selected @@ -101,10 +95,11 @@ def update_widgets(self) -> bool: elif len(self.driver.selected) == 1: entry: Entry = items[0] filepath: Path = self.lib.library_dir / entry.path + ext: str = filepath.suffix.lower() - stats: dict = self.thumb.update_preview(filepath) - logger.info(stats) - self.file_attrs.update_stats(filepath) + stats: dict = self.thumb.update_preview(filepath, ext) + logger.info("stats", stats=stats, ext=ext) + self.file_attrs.update_stats(filepath, ext, stats) self.file_attrs.update_date_label(filepath) # TODO: Render regular single selection # TODO: Return known attributes from thumb, and give those to field_attrs From 3123dffd3775b0a8c408fd1e44212899e72d2122 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Tue, 31 Dec 2024 06:57:43 -0800 Subject: [PATCH 09/68] ui: show fields in preview panel known issues: - fields to not visually update after being edited until the entries are reloaded from the thumbnail grid (yes, the thumbnail grid) - add field button currently non-functional - surprise segfaults --- tagstudio/src/qt/widgets/item_thumb.py | 1 + .../qt/widgets/preview/field_containers.py | 1457 +++++++++-------- .../qt/widgets/preview/recent_libraries.py | 82 +- tagstudio/src/qt/widgets/preview_panel.py | 23 +- 4 files changed, 822 insertions(+), 741 deletions(-) diff --git a/tagstudio/src/qt/widgets/item_thumb.py b/tagstudio/src/qt/widgets/item_thumb.py index 1a74304b8..31144349c 100644 --- a/tagstudio/src/qt/widgets/item_thumb.py +++ b/tagstudio/src/qt/widgets/item_thumb.py @@ -524,6 +524,7 @@ def toggle_item_tag( if toggle_value: self.lib.add_tags_to_entry(entry_id, tag_id) else: + # TODO: Implement self.lib.remove_tag_from_entry(entry_id, tag_id) if self.driver.preview_panel.is_open: diff --git a/tagstudio/src/qt/widgets/preview/field_containers.py b/tagstudio/src/qt/widgets/preview/field_containers.py index c05950f7c..5415f42a1 100644 --- a/tagstudio/src/qt/widgets/preview/field_containers.py +++ b/tagstudio/src/qt/widgets/preview/field_containers.py @@ -2,15 +2,40 @@ # 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 import structlog -from PySide6.QtCore import Signal +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.enums import Theme +from src.core.library.alchemy.fields import ( + BaseField, + DatetimeField, + FieldTypeEnum, + TextField, +) from src.core.library.alchemy.library import Library from src.qt.translations import Translations +from src.core.library.alchemy.models import Entry +from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper +from src.qt.modals.add_field import AddFieldModal +from src.qt.widgets.fields import FieldContainer +from src.qt.widgets.panel import PanelModal +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 @@ -26,695 +51,741 @@ class FieldContainers(QWidget): 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.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(6, 1, 6, 6) - -# 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(6) - -# 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) - -# self.afb_container = QWidget() -# self.afb_layout = QVBoxLayout(self.afb_container) -# self.afb_layout.setContentsMargins(0, 12, 0, 0) - -# self.add_field_button = QPushButtonWrapper() -# self.add_field_button.setCursor(Qt.CursorShape.PointingHandCursor) -# self.add_field_button.setMinimumSize(96, 28) -# self.add_field_button.setMaximumSize(96, 28) -# 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() - -# def remove_field_prompt(self, name: str) -> str: -# return Translations.translate_formatted("library.field.confirm_remove", name=name) - -# def place_add_field_button(self): -# self.scroll_layout.addWidget(self.afb_container) -# self.scroll_layout.setAlignment(self.afb_container, Qt.AlignmentFlag.AlignHCenter) - -# if self.add_field_modal.is_connected: -# 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()) -# ) -# 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 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] - -# # if isinstance(field, TagBoxField): -# # container.set_title(field.type.name) -# # container.set_inline(False) -# # title = f"{field.type.name} (Tag Box)" -# # -# # 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" -# # title = f"{field.type.name} (Wacky Tag Box)" -# # 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: -# 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_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}", -# 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" -# title = f"{field.type.name} (Text Box)" -# 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}", -# 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)" -# 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)" -# 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" -# title = f"{field.type.name} (Wacky Date)" -# 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)" -# 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") -# 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.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() - -# 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 update_widgets(self): -# """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") -# 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] -# results = self.lib.search_library(FilterState(id=entry.id)) -# logger.info( -# "found item", -# entries=len(results.items), -# grid_idx=grid_idx, -# lookup_id=entry.id, -# ) -# self.driver.frame_content[grid_idx] = results[0] - -# 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") -# 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 + 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[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(6, 1, 6, 6) + + 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(6) + + 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) + + self.afb_container = QWidget() + self.afb_layout = QVBoxLayout(self.afb_container) + self.afb_layout.setContentsMargins(0, 12, 0, 0) + + self.add_field_button = QPushButtonWrapper() + self.add_field_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.add_field_button.setMinimumSize(96, 28) + self.add_field_button.setMaximumSize(96, 28) + 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() + + root_layout = QHBoxLayout(self) + root_layout.setContentsMargins(0, 0, 0, 0) + root_layout.addWidget(self.scroll_area) + + def update_from_entry(self, entry: Entry): + """Update tags and fields from a single Entry source.""" + self.selected = [entry] + logger.info( + "[Field Containers] Updating Selection", + entry=entry, + fields=entry.fields, + tags=entry.tags, + ) + + # # 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] + # results = self.lib.search_library(FilterState(id=entry.id)) + # logger.info( + # "found item", + # entries=len(results.items), + # grid_idx=grid_idx, + # lookup_id=entry.id, + # ) + # self.driver.frame_content[grid_idx] = results[0] + + # for index in self.driver.selected: + # self.driver.frame_content[index] = self.lib.get_entry(self.selected[0].id) + + for idx, field in enumerate(entry.fields): + self.write_container(idx, field, is_mixed=False) + if entry.tags: + # TODO: Display the tag categories + pass + + # Hide leftover containers + if len(self.containers) > len(entry.fields): + for i, c in enumerate(self.containers): + if i > (len(entry.fields) - 1): + c.setHidden(True) + + self.add_field_button.setHidden(False) + + def remove_field_prompt(self, name: str) -> str: + return Translations.translate_formatted("library.field.confirm_remove", name=name) + + def place_add_field_button(self): + self.scroll_layout.addWidget(self.afb_container) + self.scroll_layout.setAlignment(self.afb_container, Qt.AlignmentFlag.AlignHCenter) + + if self.add_field_modal.is_connected: + 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()) + # ) + 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 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] + + # if isinstance(field, TagBoxField): + # container.set_title(field.type.name) + # container.set_inline(False) + # title = f"{field.type.name} (Tag Box)" + # + # 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" + # title = f"{field.type.name} (Wacky Tag Box)" + # 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: + 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_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}", + save_callback=( + lambda content: ( + self.update_field(field, content), + self.update_from_entry(self.selected[0]), + ) + ), + ) + 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.selected[0]), + ), + ) + ) + + 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" + title = f"{field.type.name} (Text Box)" + 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}", + save_callback=( + lambda content: ( + self.update_field(field, content), + self.update_from_entry(self.selected[0]), + ) + ), + ) + 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.selected[0]), + ), + ) + ) + + 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)" + 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)" + 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_from_entry(self.selected[0]), + ), + ) + ) + else: + text = "Mixed Data" + title = f"{field.type.name} (Wacky Date)" + 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)" + 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_from_entry(self.selected[0]), + ), + ) + ) + + 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) + for entry in self.selected: + 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") + 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.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() + + 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 update_widgets(self): + # """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") + # # # 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] + # results = self.lib.search_library(FilterState(id=entry.id)) + # logger.info( + # "found item", + # entries=len(results.items), + # grid_idx=grid_idx, + # lookup_id=entry.id, + # ) + # self.driver.frame_content[grid_idx] = results[0] + + # 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): #noqa: E501 + # 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") #noqa: E501 + # 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)) #noqa: E501 + # 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") + # 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 diff --git a/tagstudio/src/qt/widgets/preview/recent_libraries.py b/tagstudio/src/qt/widgets/preview/recent_libraries.py index f47c21635..fd2fb2b26 100644 --- a/tagstudio/src/qt/widgets/preview/recent_libraries.py +++ b/tagstudio/src/qt/widgets/preview/recent_libraries.py @@ -23,46 +23,50 @@ class RecentLibraries(QWidget): def __init__(self, library: Library, driver: "QtDriver"): super().__init__() + # # keep list of rendered libraries to avoid needless re-rendering + # self.render_libs: set = set() + # self.library = library + # self.driver = driver + # layout = QVBoxLayout() + + # settings = 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.setLayout(layout) + + # 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 -# # keep list of rendered libraries to avoid needless re-rendering -# self.render_libs: set = set() -# self.library = library -# self.driver = driver -# layout = QVBoxLayout() - -# settings = 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.setLayout(layout) - -# 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) diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index cb8a50f8a..fc07b8cd9 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -2,6 +2,7 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio +import traceback import typing from pathlib import Path @@ -64,8 +65,9 @@ def __init__(self, library: Library, driver: "QtDriver"): splitter.addWidget(self.thumb) splitter.addWidget(self.thumb.media_player) splitter.addWidget(self.file_attrs) + splitter.addWidget(self.fields) # splitter.addWidget(self.libs_flow_container) - splitter.setStretchFactor(1, 2) + splitter.setStretchFactor(3, 2) root_layout = QHBoxLayout(self) root_layout.setContentsMargins(0, 0, 0, 0) @@ -91,7 +93,8 @@ def update_widgets(self) -> bool: # self.file_attrs.update_blank() self.file_attrs.update_stats() self.file_attrs.update_date_label() - pass + + # One Item Selected elif len(self.driver.selected) == 1: entry: Entry = items[0] filepath: Path = self.lib.library_dir / entry.path @@ -99,19 +102,21 @@ def update_widgets(self) -> bool: stats: dict = self.thumb.update_preview(filepath, ext) logger.info("stats", stats=stats, ext=ext) - self.file_attrs.update_stats(filepath, ext, stats) - self.file_attrs.update_date_label(filepath) - # TODO: Render regular single selection - # TODO: Return known attributes from thumb, and give those to field_attrs - # self.file_attrs.update_filename() - pass + try: + self.file_attrs.update_stats(filepath, ext, stats) + self.file_attrs.update_date_label(filepath) + self.fields.update_from_entry(entry) + except Exception as e: + logger.error("[Preview Panel] Error updating selection", error=e) + traceback.print_exc() + # Multiple Selected Items elif len(self.driver.selected) > 1: # Render mixed selection self.file_attrs.update_multi_selection(len(self.driver.selected)) self.file_attrs.update_date_label() + # self.fields.update_from_entries(items) # self.file_attrs.update_selection_count() - pass # self.thumb.update_widgets() # # self.file_attrs.update_widgets() From f58332eddcc4e61fc22646907705ed03d1dd8f78 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Tue, 31 Dec 2024 15:53:16 -0800 Subject: [PATCH 10/68] search: remove TagEntry join --- tagstudio/src/core/library/alchemy/library.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index 5ab12d954..23a2d66bf 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -559,12 +559,8 @@ def search_library( if search.ast: start_time = time.time() - statement = statement.outerjoin(TagEntry).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)})" ) From 82e06ecc2bf30a280a1d97e0d7eb4b03c0c05e36 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Tue, 31 Dec 2024 16:00:16 -0800 Subject: [PATCH 11/68] fix: remove extra `self.filter` assignment --- tagstudio/src/qt/ts_qt.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 003502ca8..c035b5872 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -138,7 +138,6 @@ def __init__(self, backend, args): self.args = args self.filter = FilterState.show_all() self.frame_content: list[Entry] = [] - self.filter = FilterState().show_all() self.pages_count = 0 self.scrollbar_pos = 0 From a306d68f10dc072d9bd72e9e8459aab7e00ede0e Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Tue, 31 Dec 2024 16:26:01 -0800 Subject: [PATCH 12/68] add success return flag to `add_tags_to_entry()` --- tagstudio/src/core/library/alchemy/library.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index 23a2d66bf..447b5d696 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -934,7 +934,7 @@ def add_tag( session.rollback() return None - def add_tags_to_entry(self, entry_id: int, tag_ids: int | list[int] | set[int]): + def add_tags_to_entry(self, entry_id: int, tag_ids: int | list[int] | set[int]) -> bool: tag_ids_ = [tag_ids] if isinstance(tag_ids, int) else tag_ids with Session(self.engine, expire_on_commit=False) as session: try: @@ -942,10 +942,11 @@ def add_tags_to_entry(self, entry_id: int, tag_ids: int | list[int] | set[int]): session.add(TagEntry(tag_id=tag_id, entry_id=entry_id)) session.flush() session.commit() + return True except IntegrityError as e: logger.exception(e) session.rollback() - return None + return False def save_library_backup_to_disk(self) -> Path: assert isinstance(self.library_dir, Path) From b79c59dae54d12b4b16d7445019d057f195bd49b Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Wed, 1 Jan 2025 15:07:17 -0800 Subject: [PATCH 13/68] refactor: use entry IDs instead of objects and indices - fixes preview panel not updating after entry edits - fixes slow selection performance - fixes double render call --- tagstudio/src/core/library/alchemy/library.py | 31 ++- tagstudio/src/core/library/alchemy/models.py | 2 +- tagstudio/src/qt/ts_qt.py | 188 +++++++++--------- tagstudio/src/qt/widgets/item_thumb.py | 70 +++---- .../qt/widgets/preview/field_containers.py | 51 ++--- .../src/qt/widgets/preview/file_attributes.py | 1 - tagstudio/src/qt/widgets/preview_panel.py | 45 ++--- 7 files changed, 188 insertions(+), 200 deletions(-) diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index 447b5d696..7c21c9cd9 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -420,7 +420,7 @@ def get_entry(self, entry_id: int) -> Entry | None: return entry def get_entry_full(self, entry_id: int) -> Entry | None: - """Load entry an join with all joins and all tags.""" + """Load entry and join with all joins and all tags.""" with Session(self.engine) as session: statement = select(Entry).where(Entry.id == entry_id) statement = ( @@ -454,7 +454,9 @@ def get_entries(self, with_joins: bool = False) -> Iterator[Entry]: if with_joins: # load Entry with all joins and all tags stmt = ( - stmt.outerjoin(Entry.text_fields).outerjoin(Entry.datetime_fields) + stmt.outerjoin(Entry.text_fields) + .outerjoin(Entry.datetime_fields) + .outerjoin(Entry.tags) # .outerjoin(Entry.tag_box_fields) ) stmt = stmt.options( @@ -935,6 +937,7 @@ def add_tag( return None 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: @@ -943,6 +946,30 @@ def add_tags_to_entry(self, entry_id: int, tag_ids: int | list[int] | set[int]) session.flush() session.commit() 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() diff --git a/tagstudio/src/core/library/alchemy/models.py b/tagstudio/src/core/library/alchemy/models.py index a56e8b4c8..7213cec30 100644 --- a/tagstudio/src/core/library/alchemy/models.py +++ b/tagstudio/src/core/library/alchemy/models.py @@ -146,7 +146,7 @@ def fields(self) -> list[BaseField]: return fields @property - def is_favorited(self) -> bool: + def is_favorite(self) -> bool: return any(tag.id == TAG_FAVORITE for tag in self.tags) @property diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index c035b5872..f32e8793a 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -16,7 +16,6 @@ import time import webbrowser from collections.abc import Sequence -from itertools import zip_longest from pathlib import Path from queue import Queue @@ -53,8 +52,6 @@ QWidget, ) from src.core.constants import ( - TAG_ARCHIVED, - TAG_FAVORITE, VERSION, VERSION_BRANCH, ) @@ -137,7 +134,7 @@ def __init__(self, backend, args): self.rm: ResourceManager = ResourceManager() self.args = args self.filter = FilterState.show_all() - self.frame_content: list[Entry] = [] + self.frame_content: list[int] = [] # List of Entry IDs on the current page self.pages_count = 0 self.scrollbar_pos = 0 @@ -151,9 +148,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) @@ -589,11 +584,13 @@ def init_library_window(self): 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() + # TODO: Reimplement or remove + # if value: + # self.preview_panel.libs_flow_container.show() + # else: + # self.preview_panel.libs_flow_container.hide() + # self.preview_panel.update() + pass def show_grid_filenames(self, value: bool): for thumb in self.item_thumbs: @@ -695,10 +692,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() @@ -829,14 +828,14 @@ def new_file_macros_runnable(self, new_ids): # 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() @@ -845,14 +844,14 @@ 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) @@ -949,13 +948,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) ), @@ -972,44 +970,55 @@ def _init_thumb_grid(self): sa.setWidgetResizable(True) sa.setWidget(self.flow_container) - def select_item(self, grid_index: int, append: bool, bridge: bool): + def select_item(self, item_id: 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) + logger.info("[QtDriver] Selecting Items:", item_id=item_id, 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) + 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: - self.selected.remove(grid_index) - self.item_thumbs[grid_index].thumb_button.set_selected(False) + self.selected.remove(item_id) + for it in self.item_thumbs: + if it.item_id == item_id: + it.thumb_button.set_selected(False) elif bridge and self.selected: - select_from = min(self.selected) - select_to = max(self.selected) - - if select_to < grid_index: - index_range = range(select_from, grid_index + 1) - else: - index_range = range(grid_index, select_to + 1) - - self.selected = list(index_range) + 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) - 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: + self.selected.clear() + self.selected.append(item_id) for it in self.item_thumbs: - if it.item_id == id: - self.preview_panel.set_tags_updated_slot(it.refresh_badge) + if it.item_id == item_id: + it.thumb_button.set_selected(True) + else: + it.thumb_button.set_selected(False) + + # # 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 == item_id: + # self.preview_panel.set_tags_updated_slot(it.refresh_badge) self.set_macro_menu_viability() self.preview_panel.update_widgets() @@ -1106,18 +1115,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: Grab all entries at once + entries: list[Entry] = [self.lib.get_entry_full(e_id) for e_id in 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) + item_thumb.set_item_id(entry.id) # TODO - show after item is rendered item_thumb.show() - is_loading = True self.thumb_job_queue.put( ( @@ -1127,29 +1144,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.select_item( + item_id, append=( QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ControlModifier @@ -1165,24 +1182,15 @@ def update_thumbs(self): is_selected = (item_thumb.mode, 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, grid_item_ids: Sequence[int] = None): - if not grid_item_ids: + def update_badges(self, item_ids: Sequence[int] = None): + if not 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) + item_ids = range(min(len(self.item_thumbs), len(self.frame_content))) - 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) + item_ids_ = set(item_ids) + for it in self.item_thumbs: + if it.item_id in item_ids_: + it.refresh_badge() def filter_items(self, filter: FilterState | None = None) -> None: if not self.lib.library_dir: @@ -1217,7 +1225,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 diff --git a/tagstudio/src/qt/widgets/item_thumb.py b/tagstudio/src/qt/widgets/item_thumb.py index 31144349c..20dacfc76 100644 --- a/tagstudio/src/qt/widgets/item_thumb.py +++ b/tagstudio/src/qt/widgets/item_thumb.py @@ -24,7 +24,7 @@ TAG_ARCHIVED, TAG_FAVORITE, ) -from src.core.library import Entry, ItemType, Library +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 @@ -118,11 +118,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 @@ -205,10 +203,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), ) ) @@ -399,6 +397,7 @@ def set_count(self, count: str) -> None: self.count_badge.setHidden(True) def set_filename_text(self, filename: Path | str | None): + self.set_item_path(filename) self.file_label.setText(str(filename)) def set_filename_visibility(self, set_visible: bool): @@ -444,24 +443,19 @@ def update_clickable(self, clickable: typing.Callable): self.thumb_button.pressed.connect(clickable) self.thumb_button.is_connected = True - def refresh_badge(self, entry: Entry | None = None): + def refresh_badge(self, entry_id: int | None = None): + entry = self.lib.get_entry_full(self.item_id) if not entry: - if not self.item_id: - logger.error("missing both entry and item_id") - return None - - entry = self.lib.get_entry(self.item_id) - if not entry: - logger.error("Entry not found", item_id=self.item_id) - return - + return self.assign_badge(BadgeType.ARCHIVED, entry.is_archived) - self.assign_badge(BadgeType.FAVORITE, entry.is_favorited) + self.assign_badge(BadgeType.FAVORITE, entry.is_favorite) + + def set_item_id(self, item_id: int): + self.item_id = item_id - 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 @@ -500,18 +494,15 @@ def on_badge_check(self, badge_type: BadgeType): 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 + if self.item_id in self.driver.selected: + items_to_update = self.driver.selected else: - update_items = [self.grid_idx] + items_to_update = [self.item_id] - for idx in update_items: - entry = self.driver.frame_content[idx] - self.toggle_item_tag(entry.id, toggle_value, tag_id) - # update the entry - self.driver.frame_content[idx] = self.lib.get_entry_full(entry.id) + for item_id in items_to_update: + self.toggle_item_tag(item_id, toggle_value, tag_id) - self.driver.update_badges(update_items) + self.driver.update_badges(items_to_update) def toggle_item_tag( self, @@ -524,8 +515,7 @@ def toggle_item_tag( if toggle_value: self.lib.add_tags_to_entry(entry_id, tag_id) else: - # TODO: Implement - self.lib.remove_tag_from_entry(entry_id, tag_id) + self.lib.remove_tags_from_entry(entry_id, tag_id) if self.driver.preview_panel.is_open: self.driver.preview_panel.update_widgets() @@ -538,13 +528,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 @@ -554,4 +544,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/preview/field_containers.py b/tagstudio/src/qt/widgets/preview/field_containers.py index 5415f42a1..c69eeac2c 100644 --- a/tagstudio/src/qt/widgets/preview/field_containers.py +++ b/tagstudio/src/qt/widgets/preview/field_containers.py @@ -119,7 +119,7 @@ def __init__(self, library: Library, driver: "QtDriver"): def update_from_entry(self, entry: Entry): """Update tags and fields from a single Entry source.""" - self.selected = [entry] + self.selected = [self.lib.get_entry_full(entry.id)] logger.info( "[Field Containers] Updating Selection", entry=entry, @@ -127,33 +127,17 @@ def update_from_entry(self, entry: Entry): tags=entry.tags, ) - # # 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] - # results = self.lib.search_library(FilterState(id=entry.id)) - # logger.info( - # "found item", - # entries=len(results.items), - # grid_idx=grid_idx, - # lookup_id=entry.id, - # ) - # self.driver.frame_content[grid_idx] = results[0] - - # for index in self.driver.selected: - # self.driver.frame_content[index] = self.lib.get_entry(self.selected[0].id) - - for idx, field in enumerate(entry.fields): + entry_ = self.selected[0] + for idx, field in enumerate(entry_.fields): self.write_container(idx, field, is_mixed=False) - if entry.tags: + if entry_.tags: # TODO: Display the tag categories pass # Hide leftover containers - if len(self.containers) > len(entry.fields): + if len(self.containers) > len(entry_.fields): for i, c in enumerate(self.containers): - if i > (len(entry.fields) - 1): + if i > (len(entry_.fields) - 1): c.setHidden(True) self.add_field_button.setHidden(False) @@ -170,17 +154,16 @@ def place_add_field_button(self): 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()) - # ) + self.add_field_modal.done.connect( + lambda f: (self.add_field_to_selected(f), self.update_from_entry(self.selected[0])) + ) 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 entry in self.selected: for field_item in field_list: self.lib.add_entry_field_type( entry.id, @@ -393,12 +376,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): 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) - + entry_ids = [e.id for e in self.selected] self.lib.remove_entry_field(field, entry_ids) # # if the field is meta tags, update the badges @@ -412,12 +390,7 @@ def update_field(self, field: BaseField, content: str) -> None: (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) - for entry in self.selected: - entry_ids.append(entry.id) + entry_ids = [e.id for e in self.selected] assert entry_ids, "No entries selected" self.lib.update_entry_field( diff --git a/tagstudio/src/qt/widgets/preview/file_attributes.py b/tagstudio/src/qt/widgets/preview/file_attributes.py index 96151abcf..ed572750c 100644 --- a/tagstudio/src/qt/widgets/preview/file_attributes.py +++ b/tagstudio/src/qt/widgets/preview/file_attributes.py @@ -200,7 +200,6 @@ def update_stats(self, filepath: Path | None = None, ext: str = ".", stats: dict # Format and display any stat variables def add_newline(stats_label_text: str) -> str: - logger.info(stats_label_text[-2:]) if stats_label_text and stats_label_text[-2:] != "\n": return stats_label_text + "\n" return stats_label_text diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index fc07b8cd9..a34c7bcf0 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -8,7 +8,7 @@ import structlog from PySide6.QtCore import Qt, Signal -from PySide6.QtWidgets import QHBoxLayout, QSplitter, QWidget +from PySide6.QtWidgets import QHBoxLayout, QSplitter, QVBoxLayout, QWidget from src.core.library.alchemy.library import Library from src.core.library.alchemy.models import Entry from src.qt.widgets.preview.field_containers import FieldContainers @@ -40,13 +40,15 @@ def __init__(self, library: Library, driver: "QtDriver"): self.file_attrs = FileAttributes(library, driver) self.fields = FieldContainers(library, driver) - # info_section = QWidget() - # info_layout = QVBoxLayout(info_section) - # info_layout.setContentsMargins(0, 0, 0, 0) - # info_layout.setSpacing(6) + preview_section = QWidget() + preview_layout = QVBoxLayout(preview_section) + preview_layout.setContentsMargins(0, 0, 0, 0) + preview_layout.setSpacing(6) - # info_layout.addWidget(self.file_attrs) - # info_layout.addWidget(self.fields.scroll_area) + info_section = QWidget() + info_layout = QVBoxLayout(info_section) + info_layout.setContentsMargins(0, 0, 0, 0) + info_layout.setSpacing(6) splitter = QSplitter() splitter.setOrientation(Qt.Orientation.Vertical) @@ -59,35 +61,24 @@ def __init__(self, library: Library, driver: "QtDriver"): # ) # ) # ) + preview_layout.addWidget(self.thumb) + preview_layout.addWidget(self.thumb.media_player) + info_layout.addWidget(self.file_attrs) + info_layout.addWidget(self.fields) - # splitter.addWidget(self.thumb.image_container) - # splitter.addWidget(self.thumb.media_player) - splitter.addWidget(self.thumb) - splitter.addWidget(self.thumb.media_player) - splitter.addWidget(self.file_attrs) - splitter.addWidget(self.fields) + splitter.addWidget(preview_section) + splitter.addWidget(info_section) # splitter.addWidget(self.libs_flow_container) - splitter.setStretchFactor(3, 2) + splitter.setStretchFactor(1, 2) root_layout = QHBoxLayout(self) root_layout.setContentsMargins(0, 0, 0, 0) root_layout.addWidget(splitter) - 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 update_widgets(self) -> bool: """Render the panel widgets with the newest data from the Library.""" # No Items Selected - items: list[Entry] = [self.driver.frame_content[x] for x in self.driver.selected] + # items: list[Entry] = [self.driver.frame_content[x] for x in self.driver.selected] if len(self.driver.selected) == 0: # TODO: Clear everything to default # self.file_attrs.update_blank() @@ -96,7 +87,7 @@ def update_widgets(self) -> bool: # One Item Selected elif len(self.driver.selected) == 1: - entry: Entry = items[0] + entry: Entry = self.lib.get_entry_full(self.driver.selected[0]) filepath: Path = self.lib.library_dir / entry.path ext: str = filepath.suffix.lower() From c320af247b5ace71ea16ef120d380b579294e650 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Thu, 2 Jan 2025 22:19:20 -0800 Subject: [PATCH 14/68] feat: add tag categories to preview panel --- tagstudio/src/core/library/alchemy/library.py | 4 +- .../qt/widgets/preview/field_containers.py | 587 +++++------------- .../src/qt/widgets/preview/file_attributes.py | 3 - tagstudio/src/qt/widgets/preview_panel.py | 75 ++- tagstudio/src/qt/widgets/tag_box.py | 368 ++++++----- 5 files changed, 392 insertions(+), 645 deletions(-) diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index 7c21c9cd9..e43f19e56 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -432,7 +432,9 @@ def get_entry_full(self, entry_id: int) -> Entry | None: selectinload(Entry.text_fields), selectinload(Entry.datetime_fields), selectinload(Entry.tags).options( - selectinload(Tag.aliases), selectinload(Tag.subtags) + selectinload(Tag.aliases), + selectinload(Tag.subtags), + selectinload(Tag.parent_tags), ), ) entry = session.scalar(statement) diff --git a/tagstudio/src/qt/widgets/preview/field_containers.py b/tagstudio/src/qt/widgets/preview/field_containers.py index c69eeac2c..89b258e07 100644 --- a/tagstudio/src/qt/widgets/preview/field_containers.py +++ b/tagstudio/src/qt/widgets/preview/field_containers.py @@ -31,8 +31,10 @@ from src.core.library.alchemy.models import Entry from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper from src.qt.modals.add_field import AddFieldModal +from src.core.library.alchemy.models import Entry, Tag 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 @@ -100,19 +102,6 @@ def __init__(self, library: Library, driver: "QtDriver"): ) self.scroll_area.setWidget(scroll_container) - self.afb_container = QWidget() - self.afb_layout = QVBoxLayout(self.afb_container) - self.afb_layout.setContentsMargins(0, 12, 0, 0) - - self.add_field_button = QPushButtonWrapper() - self.add_field_button.setCursor(Qt.CursorShape.PointingHandCursor) - self.add_field_button.setMinimumSize(96, 28) - self.add_field_button.setMaximumSize(96, 28) - 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() - root_layout = QHBoxLayout(self) root_layout.setContentsMargins(0, 0, 0, 0) root_layout.addWidget(self.scroll_area) @@ -121,48 +110,81 @@ def update_from_entry(self, entry: Entry): """Update tags and fields from a single Entry source.""" self.selected = [self.lib.get_entry_full(entry.id)] logger.info( - "[Field Containers] Updating Selection", + "[FieldContainers] Updating Selection", entry=entry, fields=entry.fields, tags=entry.tags, ) entry_ = self.selected[0] - for idx, field in enumerate(entry_.fields): - self.write_container(idx, field, is_mixed=False) + container_len: int = len(entry_.fields) + # for index, tag in enumerate(entry_.tags): + # self.write_tag_container(index, ) + # TODO: Break up into categories + container_index = 0 if entry_.tags: - # TODO: Display the tag categories - pass + 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 + for index, field in enumerate(entry_.fields, start=container_index): + self.write_container(index, field, is_mixed=False) # Hide leftover containers - if len(self.containers) > len(entry_.fields): + if len(self.containers) > container_len: for i, c in enumerate(self.containers): - if i > (len(entry_.fields) - 1): + if i > (container_len - 1): c.setHidden(True) - self.add_field_button.setHidden(False) + def get_tag_categories(self, tags: set[Tag]) -> dict[Tag, set[Tag | None]]: + cats: dict[Tag, set[Tag | None]] = {} + cats[None] = set() + + # Initialize all categories from parents + for tag in tags: + for p_tag in list(tag.subtags) + [tag]: + logger.info(f"[{tag.name}] is {p_tag.name} a category? ({p_tag.is_category})") + if p_tag.is_category: + cats[p_tag] = set() + logger.info("Blank Tag Categories", cats=cats) + + for tag in tags: + is_general = True + for p_tag in list(cats.keys()): + logger.info(f"[{tag.name}] Checking category tag key {p_tag}") + if not p_tag: + pass + elif p_tag in tag.subtags: + cats[p_tag].add(tag) + is_general = False + elif tag == p_tag: + cats[p_tag].add(tag) + is_general = False + pass + if is_general: + cats[None].add(tag) + + 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("Tag Categories", cats=cats) + return cats def remove_field_prompt(self, name: str) -> str: return Translations.translate_formatted("library.field.confirm_remove", name=name) - def place_add_field_button(self): - self.scroll_layout.addWidget(self.afb_container) - self.scroll_layout.setAlignment(self.afb_container, Qt.AlignmentFlag.AlignHCenter) - - if self.add_field_modal.is_connected: - 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_from_entry(self.selected[0])) - ) - 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) + logger.info( + "[FieldContainers][add_field_to_selected]", selected=self.selected, fields=field_list + ) for entry in self.selected: for field_item in field_list: self.lib.add_entry_field_type( @@ -170,6 +192,15 @@ def add_field_to_selected(self, field_list: list): field_id=field_item.data(Qt.ItemDataRole.UserRole), ) + def add_tags_to_selected(self, tags: list[int]): + """Add list of tags to one or more selected items.""" + logger.info("[FieldContainers][add_tags_to_selected]", selected=self.selected, tags=tags) + for entry in self.selected: + self.lib.add_tags_to_entry( + entry.id, + tag_ids=tags, + ) + def write_container(self, index: int, field: BaseField, is_mixed: bool = False): """Update/Create data for a FieldContainer. @@ -180,8 +211,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): 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() + logger.info("[write_field_container]", index=index) if len(self.containers) < (index + 1): container = FieldContainer() self.containers.append(container) @@ -189,59 +219,6 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): else: container = self.containers[index] - # if isinstance(field, TagBoxField): - # container.set_title(field.type.name) - # container.set_inline(False) - # title = f"{field.type.name} (Tag Box)" - # - # 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" - # title = f"{field.type.name} (Wacky Tag Box)" - # 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: if field.type.type == FieldTypeEnum.TEXT_LINE: container.set_title(field.type.name) container.set_inline(False) @@ -254,8 +231,8 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): text = "Mixed Data" title = f"{field.type.name} ({field.type.type.value})" - inner_container = TextWidget(title, text) - container.set_inner_widget(inner_container) + inner_widget = TextWidget(title, text) + container.set_inner_widget(inner_widget) if not is_mixed: modal = PanelModal( EditTextLine(field.value), @@ -294,8 +271,8 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): else: text = "Mixed Data" title = f"{field.type.name} (Text Box)" - inner_container = TextWidget(title, text) - container.set_inner_widget(inner_container) + inner_widget = TextWidget(title, text) + container.set_inner_widget(inner_widget) if not is_mixed: modal = PanelModal( EditTextBox(field.value), @@ -328,15 +305,15 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = 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_container = TextWidget(title, date.strftime("%D - %r")) - container.set_inner_widget(inner_container) + inner_widget = TextWidget(title, date.strftime("%D - %r")) + container.set_inner_widget(inner_widget) except Exception: container.set_title(field.type.name) # container.set_editable(False) container.set_inline(False) title = f"{field.type.name} (Date) (Unknown Format)" - inner_container = TextWidget(title, str(field.value)) - container.set_inner_widget(inner_container) + inner_widget = TextWidget(title, str(field.value)) + container.set_inner_widget(inner_widget) container.set_remove_callback( lambda: self.remove_message_box( @@ -350,15 +327,15 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): else: text = "Mixed Data" title = f"{field.type.name} (Wacky Date)" - inner_container = TextWidget(title, text) - container.set_inner_widget(inner_container) + inner_widget = TextWidget(title, text) + container.set_inner_widget(inner_widget) else: - logger.warning("write_container - unknown field", field=field) + 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_container = TextWidget(title, field.type.name) - container.set_inner_widget(inner_container) + 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), @@ -371,11 +348,67 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): container.edit_button.setHidden(True) container.setHidden(False) - self.place_add_field_button() + + 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("[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) + try: + inner_widget.updated.disconnect() + except RuntimeError: + logger.error("[FieldContainers] Failed to disconnect inner_container.updated") + + else: + inner_widget = TagBoxWidget( + tags, + "Tags", + self.driver, + ) + container.set_inner_widget(inner_widget) + + inner_widget.updated.connect( + lambda: ( + self.write_tag_container(index, tags, category_tag), + self.update_from_entry(self.selected[0]), + ) + ) + else: + text = "Mixed Data" + inner_widget = TextWidget("Mixed Tags", text) + container.set_inner_widget(inner_widget) + + self.tags_updated.emit() + container.edit_button.setHidden(True) + container.setHidden(False) def remove_field(self, field: BaseField): """Remove a field from all selected Entries.""" - logger.info("removing field", field=field, selected=self.selected) + logger.info("[FieldContainers] Removing Field", field=field, selected=self.selected) entry_ids = [e.id for e in self.selected] self.lib.remove_entry_field(field, entry_ids) @@ -421,344 +454,6 @@ def set_tags_updated_slot(self, slot: object): if self.is_connected: self.tags_updated.disconnect() - logger.info("[UPDATE CONTAINER] Setting tags updated slot") + logger.info("[FieldContainers][set_tags_updated_slot] Setting tags updated slot") self.tags_updated.connect(slot) self.is_connected = True - - # def update_widgets(self): - # """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") - # # # 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] - # results = self.lib.search_library(FilterState(id=entry.id)) - # logger.info( - # "found item", - # entries=len(results.items), - # grid_idx=grid_idx, - # lookup_id=entry.id, - # ) - # self.driver.frame_content[grid_idx] = results[0] - - # 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): #noqa: E501 - # 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") #noqa: E501 - # 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)) #noqa: E501 - # 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") - # 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 diff --git a/tagstudio/src/qt/widgets/preview/file_attributes.py b/tagstudio/src/qt/widgets/preview/file_attributes.py index ed572750c..19820115b 100644 --- a/tagstudio/src/qt/widgets/preview/file_attributes.py +++ b/tagstudio/src/qt/widgets/preview/file_attributes.py @@ -112,7 +112,6 @@ def __init__(self, library: Library, driver: "QtDriver"): def update_date_label(self, filepath: Path | None = None) -> None: """Update the "Date Created" and "Date Modified" file property labels.""" - logger.info(filepath) if filepath and filepath.is_file(): created: dt = None if platform.system() == "Windows" or platform.system() == "Darwin": @@ -139,8 +138,6 @@ def update_date_label(self, filepath: Path | None = None) -> None: def update_stats(self, filepath: Path | None = None, ext: str = ".", stats: dict = None): """Render the panel widgets with the newest data from the Library.""" - logger.info("update_stats", selected=filepath) - if not stats: stats = {} diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index a34c7bcf0..f651b187a 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -11,6 +11,12 @@ from PySide6.QtWidgets import QHBoxLayout, QSplitter, QVBoxLayout, QWidget from src.core.library.alchemy.library import Library from src.core.library.alchemy.models import Entry +from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper +from src.qt.modals.add_field import AddFieldModal +from src.qt.modals.tag_search import TagSearchPanel +from src.qt.widgets.panel import PanelModal + +# from src.qt.modals.add_tag import AddTagModal 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 @@ -34,12 +40,18 @@ def __init__(self, library: Library, driver: "QtDriver"): self.driver: QtDriver = driver self.initialized = False self.is_open: bool = False - # self.selected: list[int] = [] # New way of tracking items self.thumb = PreviewThumb(library, driver) self.file_attrs = FileAttributes(library, driver) self.fields = FieldContainers(library, driver) + tsp = TagSearchPanel(self.driver.lib) + # tsp.tag_chosen.connect(lambda x: self.add_tag_callback(x)) + self.add_tag_modal = PanelModal(tsp, "Add Tags", "Add Tags") + + # self.add_tag_modal = AddTagModal(self.lib) + self.add_field_modal = AddFieldModal(self.lib) + preview_section = QWidget() preview_layout = QVBoxLayout(preview_section) preview_layout.setContentsMargins(0, 0, 0, 0) @@ -61,6 +73,22 @@ def __init__(self, library: Library, driver: "QtDriver"): # ) # ) # ) + 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.add_tag_button = QPushButtonWrapper() + self.add_tag_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.add_tag_button.setText("Add Tag") + + self.add_field_button = QPushButtonWrapper() + self.add_field_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.add_field_button.setText("Add Field") + + add_buttons_layout.addWidget(self.add_tag_button) + add_buttons_layout.addWidget(self.add_field_button) + preview_layout.addWidget(self.thumb) preview_layout.addWidget(self.thumb.media_player) info_layout.addWidget(self.file_attrs) @@ -71,9 +99,10 @@ def __init__(self, library: Library, driver: "QtDriver"): # splitter.addWidget(self.libs_flow_container) splitter.setStretchFactor(1, 2) - root_layout = QHBoxLayout(self) + root_layout = QVBoxLayout(self) root_layout.setContentsMargins(0, 0, 0, 0) root_layout.addWidget(splitter) + root_layout.addWidget(add_buttons_container) def update_widgets(self) -> bool: """Render the panel widgets with the newest data from the Library.""" @@ -92,11 +121,12 @@ def update_widgets(self) -> bool: ext: str = filepath.suffix.lower() stats: dict = self.thumb.update_preview(filepath, ext) - logger.info("stats", stats=stats, ext=ext) try: self.file_attrs.update_stats(filepath, ext, stats) self.file_attrs.update_date_label(filepath) self.fields.update_from_entry(entry) + self.update_add_tag_button(entry) + self.update_add_field_button(entry) except Exception as e: logger.error("[Preview Panel] Error updating selection", error=e) traceback.print_exc() @@ -114,3 +144,42 @@ def update_widgets(self) -> bool: # self.fields.update_widgets() return True + + def update_add_field_button(self, entry: Entry): + if self.add_field_modal.is_connected: + 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.fields.add_field_to_selected(f), + self.fields.update_from_entry(entry), + ) + ) + self.add_field_modal.is_connected = True + self.add_field_button.clicked.connect(self.add_field_modal.show) + + def update_add_tag_button(self, entry: Entry): + # if self.add_tag_modal.is_connected: + # self.add_tag_modal.done.disconnect() + if self.add_tag_button.is_connected: + self.add_tag_button.clicked.disconnect() + + self.add_tag_modal.widget.tag_chosen.connect( + lambda t: ( + self.fields.add_tags_to_selected(t), + self.fields.update_from_entry(entry), + ) + ) + # self.add_tag_modal.is_connected = True + + self.add_tag_button.clicked.connect( + lambda: ( + # self.add_tag_modal.widget.update_tags(), + self.add_tag_modal.show(), + ) + ) + self.add_tag_button.is_connected = True + + # self.add_field_button.clicked.connect(self.add_tag_modal.show) diff --git a/tagstudio/src/qt/widgets/tag_box.py b/tagstudio/src/qt/widgets/tag_box.py index d31cefbbb..42b034c2f 100644 --- a/tagstudio/src/qt/widgets/tag_box.py +++ b/tagstudio/src/qt/widgets/tag_box.py @@ -1,192 +1,176 @@ -# # Copyright (C) 2024 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 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 - - -# if typing.TYPE_CHECKING: -# from src.qt.ts_qt import QtDriver - -# logger = structlog.get_logger(__name__) - - -# class TagBoxWidget(FieldWidget): -# updated = Signal() -# error_occurred = Signal(Exception) - -# def __init__( -# self, -# field: TagBoxField, -# title: str, -# driver: "QtDriver", -# ) -> None: -# super().__init__(title) - -# assert isinstance(field, TagBoxField), f"field is {type(field)}" - -# self.field = field -# self.driver = ( -# driver # Used for creating tag click callbacks that search entries for that tag. -# ) -# self.setObjectName("tagBox") -# self.base_layout = FlowLayout() -# self.base_layout.enable_grid_optimizations(value=False) -# 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 - -# 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): -# self.base_layout.takeAt(0).widget().deleteLater() -# is_recycled = True - -# for tag in tags_: -# tag_widget = TagWidget(tag, has_edit=True, has_remove=True) -# tag_widget.on_click.connect( -# lambda tag_id=tag.id: ( -# self.driver.main_window.searchField.setText(f"tag_id:{tag_id}"), -# self.driver.filter_items(FilterState.from_tag_id(tag_id)), -# ) -# ) - - -# tag_widget.on_remove.connect( -# lambda tag_id=tag.id: ( -# self.remove_tag(tag_id), -# self.driver.preview_panel.update_widgets(), -# ) -# ) -# 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() - - -# self.edit_modal = PanelModal( -# build_tag_panel, -# title=tag.name, # TODO - display name including subtags -# 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), -# alias_names=set(build_tag_panel.alias_names), -# alias_ids=set(build_tag_panel.alias_ids), -# ) -# ) -# self.edit_modal.show() - -# def edit_tag(self, tag: Tag): -# assert isinstance(tag, Tag), f"tag is {type(tag)}" -# build_tag_panel = BuildTagPanel(self.driver.lib, tag=tag) - - -# 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", -# 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) - -# self.updated.emit() - -# if tag_id in (TAG_FAVORITE, TAG_ARCHIVED): -# self.driver.update_badges() +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + + +import typing + +import structlog +from PySide6.QtCore import Signal +from src.core.constants import TAG_ARCHIVED, TAG_FAVORITE +from src.core.library import Tag +from src.core.library.alchemy.enums import FilterState +from src.qt.flowlayout import FlowLayout +from src.qt.modals.build_tag import BuildTagPanel +from src.qt.widgets.fields import FieldWidget +from src.qt.widgets.panel import PanelModal +from src.qt.widgets.tag import TagWidget + +if typing.TYPE_CHECKING: + from src.qt.ts_qt import QtDriver + +logger = structlog.get_logger(__name__) + + +class TagBoxWidget(FieldWidget): + updated = Signal() + error_occurred = Signal(Exception) + + def __init__( + self, + tags: set[Tag], + title: str, + driver: "QtDriver", + ) -> None: + super().__init__(title) + + self.tags: set[Tag] = tags + self.driver = ( + driver # Used for creating tag click callbacks that search entries for that tag. + ) + self.setObjectName("tagBox") + self.base_layout = FlowLayout() + self.base_layout.enable_grid_optimizations(value=False) + 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, "Add Tags") + # self.add_button.clicked.connect( + # lambda: ( + # tsp.update_tags(), + # self.add_modal.show(), + # ) + # ) + + self.set_tags(self.tags) + + def set_tags(self, tags: typing.Iterable[Tag]): + tags_ = sorted(list(tags), key=lambda tag: tag.name) + logger.info("[TagBoxWidget] Tags:", tags=tags) + # is_recycled = False + 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) + tag_widget.on_click.connect( + lambda tag_id=tag.id: ( + self.driver.main_window.searchField.setText(f"tag_id:{tag_id}"), + self.driver.filter_items(FilterState.from_tag_id(tag_id)), + ) + ) + + tag_widget.on_remove.connect( + lambda tag_id=tag.id: ( + self.remove_tag(tag_id), + self.driver.preview_panel.update_widgets(), + ) + ) + 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, + tag.name, # TODO - display name including subtags + "Edit Tag", + done_callback=self.driver.preview_panel.update_widgets, + has_save=True, + ) + # 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), + 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("[TagBoxWidget] add_tag_callback", tag_id=tag_id, selected=self.driver.selected) + + # tag = self.driver.lib.get_tag(tag_id=tag_id) + for entry_id in self.driver.selected: + # entry: Entry = self.driver.frame_content[entry.id] + self.driver.lib.add_tags_to_entry(entry_id, tag_id) + + if not self.driver.lib.add_tags_to_entry(entry_id, tag_id): + # 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( + "[TagBoxWidget] remove_tag", + selected=self.driver.selected, + # field_type=self.field.type, + ) + + for entry_id in self.driver.selected: + # entry = self.driver.frame_content[entry_id] + self.driver.lib.remove_tags_from_entry(entry_id, tag_id) + # self.driver.lib.remove_field_tag(entry, tag_id, self.field.type_key) + + self.updated.emit() + + if tag_id in (TAG_FAVORITE, TAG_ARCHIVED): + self.driver.update_badges() From 2d7e89d19df394555cdc47077a839ef3e9e47983 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Thu, 2 Jan 2025 22:55:09 -0800 Subject: [PATCH 15/68] ui: add "is category" checkbox in tag panel --- tagstudio/src/qt/modals/build_tag.py | 73 ++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 14 deletions(-) diff --git a/tagstudio/src/qt/modals/build_tag.py b/tagstudio/src/qt/modals/build_tag.py index e0ce6615a..5cf9f56a5 100644 --- a/tagstudio/src/qt/modals/build_tag.py +++ b/tagstudio/src/qt/modals/build_tag.py @@ -10,8 +10,10 @@ from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import ( QApplication, + QCheckBox, QComboBox, QFrame, + QHBoxLayout, QLabel, QLineEdit, QPushButton, @@ -115,8 +117,7 @@ def __init__(self, library: Library, tag: Tag | None = None): self.alias_add_button.clicked.connect(self.add_alias_callback) - # Subtags ------------------------------------------------------------ - + # Parent Tags ---------------------------------------------------------- self.subtags_widget = QWidget() self.subtags_layout = QVBoxLayout(self.subtags_widget) self.subtags_layout.setStretch(1, 1) @@ -159,11 +160,11 @@ def __init__(self, library: Library, tag: Tag | None = None): Translations.translate_with_setter(self.add_tag_modal.setWindowTitle, "tag.parent_tags.add") self.subtags_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,6 +191,50 @@ 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"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: 20p+x;" + "}" + # 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) + + # Keyboard Actions ===================================================== + remove_selected_alias_action = QAction("remove selected alias", self) + remove_selected_alias_action.triggered.connect(self.remove_selected_alias) + remove_selected_alias_action.setShortcut( + QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier), + QtCore.Qt.Key.Key_D, + ) + ) + self.addAction(remove_selected_alias_action) + # Add Widgets to Layout ================================================ self.root_layout.addWidget(self.name_widget) self.root_layout.addWidget(self.shorthand_widget) @@ -198,6 +243,9 @@ def __init__(self, library: Library, tag: Tag | None = None): self.root_layout.addWidget(self.alias_add_button) self.root_layout.addWidget(self.subtags_widget) self.root_layout.addWidget(self.color_widget) + self.root_layout.addWidget(QLabel("

Properties

")) + self.root_layout.addWidget(self.cat_widget) + # self.parent().done.connect(self.update_tag) self.subtag_ids: set[int] = set() self.alias_ids: list[int] = [] @@ -349,21 +397,17 @@ 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) + for alias_id in tag.alias_ids: + self.alias_ids.add(alias_id) + self.set_subtags() # select item in self.color_field where the userData value matched tag.color @@ -372,6 +416,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 +432,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 From b03211e7e7953ef20651d69daaf64f5befc5bc13 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Thu, 2 Jan 2025 23:14:56 -0800 Subject: [PATCH 16/68] fix: tags can be compared for name sorting --- tagstudio/src/core/library/alchemy/models.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tagstudio/src/core/library/alchemy/models.py b/tagstudio/src/core/library/alchemy/models.py index 7213cec30..5363213a7 100644 --- a/tagstudio/src/core/library/alchemy/models.py +++ b/tagstudio/src/core/library/alchemy/models.py @@ -105,6 +105,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" From 56a80ff13c59f8992c522f005a7dc64a25eaa31a Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 3 Jan 2025 01:39:06 -0800 Subject: [PATCH 17/68] fix: don't add tags to previous selections --- tagstudio/src/core/library/alchemy/library.py | 16 ++-------- .../qt/widgets/preview/field_containers.py | 31 ++++++++++++++----- tagstudio/src/qt/widgets/preview_panel.py | 9 +++--- 3 files changed, 29 insertions(+), 27 deletions(-) diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index e43f19e56..28dcf7897 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -780,7 +780,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] @@ -840,24 +840,12 @@ def add_entry_field_type( 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( diff --git a/tagstudio/src/qt/widgets/preview/field_containers.py b/tagstudio/src/qt/widgets/preview/field_containers.py index 89b258e07..5ce97dcca 100644 --- a/tagstudio/src/qt/widgets/preview/field_containers.py +++ b/tagstudio/src/qt/widgets/preview/field_containers.py @@ -111,17 +111,16 @@ def update_from_entry(self, entry: Entry): self.selected = [self.lib.get_entry_full(entry.id)] logger.info( "[FieldContainers] Updating Selection", - entry=entry, + path=entry.path, fields=entry.fields, tags=entry.tags, ) entry_ = self.selected[0] container_len: int = len(entry_.fields) - # for index, tag in enumerate(entry_.tags): - # self.write_tag_container(index, ) - # TODO: Break up into categories 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)): @@ -130,16 +129,18 @@ def update_from_entry(self, entry: Entry): ) container_index += 1 container_len += 1 + # Write field container(s) for index, field in enumerate(entry_.fields, start=container_index): self.write_container(index, field, is_mixed=False) - # Hide leftover containers + # 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 get_tag_categories(self, tags: set[Tag]) -> dict[Tag, set[Tag | None]]: + """Get a dictionary of category tags mapped to their respective tags.""" cats: dict[Tag, set[Tag | None]] = {} cats[None] = set() @@ -151,6 +152,7 @@ def get_tag_categories(self, tags: set[Tag]) -> dict[Tag, set[Tag | None]]: cats[p_tag] = set() logger.info("Blank Tag Categories", cats=cats) + # Add tags to any applicable categories for tag in tags: is_general = True for p_tag in list(cats.keys()): @@ -167,6 +169,7 @@ def get_tag_categories(self, tags: set[Tag]) -> dict[Tag, set[Tag | None]]: if is_general: cats[None].add(tag) + # Remove unused categories empty: list[Tag] = [] for k, v in list(cats.items()): if not v: @@ -183,7 +186,9 @@ def remove_field_prompt(self, name: str) -> str: def add_field_to_selected(self, field_list: list): """Add list of entry fields to one or more selected items.""" logger.info( - "[FieldContainers][add_field_to_selected]", selected=self.selected, fields=field_list + "[FieldContainers][add_field_to_selected]", + selected=[x.path for x in self.selected], + fields=field_list, ) for entry in self.selected: for field_item in field_list: @@ -194,7 +199,11 @@ def add_field_to_selected(self, field_list: list): def add_tags_to_selected(self, tags: list[int]): """Add list of tags to one or more selected items.""" - logger.info("[FieldContainers][add_tags_to_selected]", selected=self.selected, tags=tags) + logger.info( + "[FieldContainers][add_tags_to_selected]", + selected=[x.path for x in self.selected], + tags=tags, + ) for entry in self.selected: self.lib.add_tags_to_entry( entry.id, @@ -377,6 +386,7 @@ def write_tag_container( inner_widget = container.get_inner_widget() if isinstance(inner_widget, TagBoxWidget): + logger.warning("recycling") inner_widget.set_tags(tags) try: inner_widget.updated.disconnect() @@ -384,6 +394,7 @@ def write_tag_container( logger.error("[FieldContainers] Failed to disconnect inner_container.updated") else: + logger.warning("creating new") inner_widget = TagBoxWidget( tags, "Tags", @@ -408,7 +419,11 @@ def write_tag_container( def remove_field(self, field: BaseField): """Remove a field from all selected Entries.""" - logger.info("[FieldContainers] Removing Field", field=field, selected=self.selected) + logger.info( + "[FieldContainers] Removing Field", + field=field, + selected=[x.path for x in self.selected], + ) entry_ids = [e.id for e in self.selected] self.lib.remove_entry_field(field, entry_ids) diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index f651b187a..3aa11dddb 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -5,6 +5,7 @@ import traceback import typing from pathlib import Path +from warnings import catch_warnings import structlog from PySide6.QtCore import Qt, Signal @@ -161,9 +162,8 @@ def update_add_field_button(self, entry: Entry): self.add_field_button.clicked.connect(self.add_field_modal.show) def update_add_tag_button(self, entry: Entry): - # if self.add_tag_modal.is_connected: - # self.add_tag_modal.done.disconnect() - if self.add_tag_button.is_connected: + with catch_warnings(record=True): + self.add_tag_modal.widget.tag_chosen.disconnect() self.add_tag_button.clicked.disconnect() self.add_tag_modal.widget.tag_chosen.connect( @@ -172,11 +172,10 @@ def update_add_tag_button(self, entry: Entry): self.fields.update_from_entry(entry), ) ) - # self.add_tag_modal.is_connected = True self.add_tag_button.clicked.connect( lambda: ( - # self.add_tag_modal.widget.update_tags(), + self.add_tag_modal.widget.update_tags(), self.add_tag_modal.show(), ) ) From feda1509f9b48619c7617713387481d034bcaab9 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 3 Jan 2025 02:48:55 -0800 Subject: [PATCH 18/68] fix: badges now properly update --- tagstudio/src/qt/ts_qt.py | 16 ++-- tagstudio/src/qt/widgets/item_thumb.py | 2 +- .../qt/widgets/preview/field_containers.py | 10 +-- tagstudio/src/qt/widgets/preview_panel.py | 9 ++- tagstudio/src/qt/widgets/tag_box.py | 73 +------------------ 5 files changed, 20 insertions(+), 90 deletions(-) diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index f32e8793a..a484e5007 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -1011,14 +1011,14 @@ def select_item(self, item_id: int, append: bool, bridge: bool): else: it.thumb_button.set_selected(False) - # # 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 == item_id: - # self.preview_panel.set_tags_updated_slot(it.refresh_badge) + # 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 == item_id: + self.preview_panel.fields.set_tags_updated_slot(it.refresh_badge) self.set_macro_menu_viability() self.preview_panel.update_widgets() diff --git a/tagstudio/src/qt/widgets/item_thumb.py b/tagstudio/src/qt/widgets/item_thumb.py index 20dacfc76..344035c27 100644 --- a/tagstudio/src/qt/widgets/item_thumb.py +++ b/tagstudio/src/qt/widgets/item_thumb.py @@ -322,7 +322,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: diff --git a/tagstudio/src/qt/widgets/preview/field_containers.py b/tagstudio/src/qt/widgets/preview/field_containers.py index 5ce97dcca..ec07281b2 100644 --- a/tagstudio/src/qt/widgets/preview/field_containers.py +++ b/tagstudio/src/qt/widgets/preview/field_containers.py @@ -6,6 +6,7 @@ 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 @@ -53,7 +54,6 @@ class FieldContainers(QWidget): def __init__(self, library: Library, driver: "QtDriver"): super().__init__() - self.is_connected = False self.lib = library self.driver: QtDriver = driver self.initialized = False @@ -209,6 +209,7 @@ def add_tags_to_selected(self, tags: list[int]): entry.id, tag_ids=tags, ) + self.tags_updated.emit() def write_container(self, index: int, field: BaseField, is_mixed: bool = False): """Update/Create data for a FieldContainer. @@ -388,10 +389,8 @@ def write_tag_container( if isinstance(inner_widget, TagBoxWidget): logger.warning("recycling") inner_widget.set_tags(tags) - try: + with catch_warnings(record=True): inner_widget.updated.disconnect() - except RuntimeError: - logger.error("[FieldContainers] Failed to disconnect inner_container.updated") else: logger.warning("creating new") @@ -466,9 +465,8 @@ def remove_message_box(self, prompt: str, callback: Callable) -> None: def set_tags_updated_slot(self, slot: object): """Replacement for tag_callback.""" - if self.is_connected: + with catch_warnings(record=True): self.tags_updated.disconnect() logger.info("[FieldContainers][set_tags_updated_slot] Setting tags updated slot") self.tags_updated.connect(slot) - self.is_connected = True diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index 3aa11dddb..009ce5240 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -36,11 +36,10 @@ class PreviewPanel(QWidget): def __init__(self, library: Library, driver: "QtDriver"): super().__init__() - self.is_connected = False self.lib = library self.driver: QtDriver = driver self.initialized = False - self.is_open: bool = False + self.is_open: bool = True self.thumb = PreviewThumb(library, driver) self.file_attrs = FileAttributes(library, driver) @@ -147,10 +146,12 @@ def update_widgets(self) -> bool: return True def update_add_field_button(self, entry: Entry): - if self.add_field_modal.is_connected: + with catch_warnings(record=True): self.add_field_modal.done.disconnect() - if self.add_field_button.is_connected: self.add_field_button.clicked.disconnect() + # TODO: Remove all "is_connected" instances across the codebase + self.add_field_modal.is_connected = False + self.add_field_button.is_connected = False self.add_field_modal.done.connect( lambda f: ( diff --git a/tagstudio/src/qt/widgets/tag_box.py b/tagstudio/src/qt/widgets/tag_box.py index 42b034c2f..8802dafaf 100644 --- a/tagstudio/src/qt/widgets/tag_box.py +++ b/tagstudio/src/qt/widgets/tag_box.py @@ -44,48 +44,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, "Add Tags") - # self.add_button.clicked.connect( - # lambda: ( - # tsp.update_tags(), - # self.add_modal.show(), - # ) - # ) - self.set_tags(self.tags) def set_tags(self, tags: typing.Iterable[Tag]): tags_ = sorted(list(tags), key=lambda tag: tag.name) logger.info("[TagBoxWidget] Tags:", tags=tags) - # is_recycled = False 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) @@ -105,17 +70,6 @@ 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) @@ -138,39 +92,16 @@ def edit_tag(self, tag: Tag): ) self.edit_modal.show() - def add_tag_callback(self, tag_id: int): - logger.info("[TagBoxWidget] add_tag_callback", tag_id=tag_id, selected=self.driver.selected) - - # tag = self.driver.lib.get_tag(tag_id=tag_id) - for entry_id in self.driver.selected: - # entry: Entry = self.driver.frame_content[entry.id] - self.driver.lib.add_tags_to_entry(entry_id, tag_id) - - if not self.driver.lib.add_tags_to_entry(entry_id, tag_id): - # 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( "[TagBoxWidget] remove_tag", selected=self.driver.selected, - # field_type=self.field.type, ) for entry_id in self.driver.selected: - # entry = self.driver.frame_content[entry_id] self.driver.lib.remove_tags_from_entry(entry_id, tag_id) - # self.driver.lib.remove_field_tag(entry, tag_id, self.field.type_key) - self.updated.emit() + self.updated.emit() if tag_id in (TAG_FAVORITE, TAG_ARCHIVED): - self.driver.update_badges() + self.driver.update_badges(self.driver.selected) From 0e7f75ec2912f6198daf5834b19476f95fb779cb Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 3 Jan 2025 03:08:45 -0800 Subject: [PATCH 19/68] ui: hide sizeGrip --- tagstudio/src/qt/main_window.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tagstudio/src/qt/main_window.py b/tagstudio/src/qt/main_window.py index ac0280d34..9bddfe8d9 100644 --- a/tagstudio/src/qt/main_window.py +++ b/tagstudio/src/qt/main_window.py @@ -171,6 +171,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) From 2c91cf62ec8154487d6b100db0d5df1a347521d9 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 3 Jan 2025 04:23:29 -0800 Subject: [PATCH 20/68] ui: add blue ui color --- tagstudio/src/core/palette.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tagstudio/src/core/palette.py b/tagstudio/src/core/palette.py index 82710b55b..45c095610 100644 --- a/tagstudio/src/core/palette.py +++ b/tagstudio/src/core/palette.py @@ -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", From ff04802f12eedfdd6305d859804987ab1cdc21f6 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 3 Jan 2025 04:25:50 -0800 Subject: [PATCH 21/68] ui: display empty selection; better multi-selection --- tagstudio/src/core/enums.py | 9 +- .../qt/widgets/preview/field_containers.py | 53 +++++---- .../src/qt/widgets/preview/file_attributes.py | 14 ++- .../src/qt/widgets/preview/preview_thumb.py | 4 + .../qt/widgets/preview/recent_libraries.py | 2 +- tagstudio/src/qt/widgets/preview_panel.py | 106 +++++++++++------- 6 files changed, 123 insertions(+), 65 deletions(-) diff --git a/tagstudio/src/core/enums.py b/tagstudio/src/core/enums.py index 781bba0ad..749428cae 100644 --- a/tagstudio/src/core/enums.py +++ b/tagstudio/src/core/enums.py @@ -20,10 +20,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): diff --git a/tagstudio/src/qt/widgets/preview/field_containers.py b/tagstudio/src/qt/widgets/preview/field_containers.py index ec07281b2..ae00a6c07 100644 --- a/tagstudio/src/qt/widgets/preview/field_containers.py +++ b/tagstudio/src/qt/widgets/preview/field_containers.py @@ -60,7 +60,7 @@ def __init__(self, library: Library, driver: "QtDriver"): self.is_open: bool = False self.common_fields: list = [] self.mixed_fields: list = [] - self.selected: list[Entry] = [] + self.cached_entries: list[Entry] = [] self.containers: list[FieldContainer] = [] self.panel_bg_color = ( @@ -108,7 +108,7 @@ def __init__(self, library: Library, driver: "QtDriver"): def update_from_entry(self, entry: Entry): """Update tags and fields from a single Entry source.""" - self.selected = [self.lib.get_entry_full(entry.id)] + self.cached_entries = [self.lib.get_entry_full(entry.id)] logger.info( "[FieldContainers] Updating Selection", path=entry.path, @@ -116,7 +116,7 @@ def update_from_entry(self, entry: Entry): tags=entry.tags, ) - entry_ = self.selected[0] + entry_ = self.cached_entries[0] container_len: int = len(entry_.fields) container_index = 0 @@ -139,6 +139,11 @@ def update_from_entry(self, entry: Entry): 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, set[Tag | None]]: """Get a dictionary of category tags mapped to their respective tags.""" cats: dict[Tag, set[Tag | None]] = {} @@ -184,29 +189,35 @@ 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.""" + """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=[x.path for x in self.selected], + selected=self.driver.selected, fields=field_list, ) - for entry in self.selected: + for entry_id in self.driver.selected: for field_item in field_list: self.lib.add_entry_field_type( - entry.id, + entry_id, field_id=field_item.data(Qt.ItemDataRole.UserRole), ) def add_tags_to_selected(self, tags: list[int]): - """Add list of tags to one or more selected items.""" + """Add list of tags to one or more selected items. + + Uses the current driver selection, NOT the field containers cache. + """ logger.info( "[FieldContainers][add_tags_to_selected]", - selected=[x.path for x in self.selected], + selected=self.driver.selected, tags=tags, ) - for entry in self.selected: + for entry_id in self.driver.selected: self.lib.add_tags_to_entry( - entry.id, + entry_id, tag_ids=tags, ) self.tags_updated.emit() @@ -251,7 +262,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): save_callback=( lambda content: ( self.update_field(field, content), - self.update_from_entry(self.selected[0]), + self.update_from_entry(self.cached_entries[0]), ) ), ) @@ -265,7 +276,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): prompt=self.remove_field_prompt(field.type.type.value), callback=lambda: ( self.remove_field(field), - self.update_from_entry(self.selected[0]), + self.update_from_entry(self.cached_entries[0]), ), ) ) @@ -291,7 +302,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): save_callback=( lambda content: ( self.update_field(field, content), - self.update_from_entry(self.selected[0]), + self.update_from_entry(self.cached_entries[0]), ) ), ) @@ -301,7 +312,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): prompt=self.remove_field_prompt(field.type.name), callback=lambda: ( self.remove_field(field), - self.update_from_entry(self.selected[0]), + self.update_from_entry(self.cached_entries[0]), ), ) ) @@ -330,7 +341,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): prompt=self.remove_field_prompt(field.type.name), callback=lambda: ( self.remove_field(field), - self.update_from_entry(self.selected[0]), + self.update_from_entry(self.cached_entries[0]), ), ) ) @@ -351,7 +362,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): prompt=self.remove_field_prompt(field.type.name), callback=lambda: ( self.remove_field(field), - self.update_from_entry(self.selected[0]), + self.update_from_entry(self.cached_entries[0]), ), ) ) @@ -404,7 +415,7 @@ def write_tag_container( inner_widget.updated.connect( lambda: ( self.write_tag_container(index, tags, category_tag), - self.update_from_entry(self.selected[0]), + self.update_from_entry(self.cached_entries[0]), ) ) else: @@ -421,9 +432,9 @@ def remove_field(self, field: BaseField): logger.info( "[FieldContainers] Removing Field", field=field, - selected=[x.path for x in self.selected], + selected=[x.path for x in self.cached_entries], ) - entry_ids = [e.id for e in self.selected] + entry_ids = [e.id for e in self.cached_entries] self.lib.remove_entry_field(field, entry_ids) # # if the field is meta tags, update the badges @@ -437,7 +448,7 @@ def update_field(self, field: BaseField, content: str) -> None: (TextField, DatetimeField), # , TagBoxField) ), f"instance: {type(field)}" - entry_ids = [e.id for e in self.selected] + entry_ids = [e.id for e in self.cached_entries] assert entry_ids, "No entries selected" self.lib.update_entry_field( diff --git a/tagstudio/src/qt/widgets/preview/file_attributes.py b/tagstudio/src/qt/widgets/preview/file_attributes.py index 19820115b..6a99b9455 100644 --- a/tagstudio/src/qt/widgets/preview/file_attributes.py +++ b/tagstudio/src/qt/widgets/preview/file_attributes.py @@ -46,7 +46,7 @@ def __init__(self, library: Library, driver: "QtDriver"): root_layout = QVBoxLayout(self) root_layout.setContentsMargins(0, 0, 0, 0) - root_layout.setSpacing(6) + root_layout.setSpacing(0) label_bg_color = ( Theme.COLOR_BG_DARK.value @@ -87,17 +87,20 @@ def __init__(self, library: Library, driver: "QtDriver"): 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) @@ -142,12 +145,18 @@ def update_stats(self, filepath: Path | None = None, ext: str = ".", stats: dict stats = {} if not filepath: + self.layout().setSpacing(0) + self.file_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.file_label.setText("No Items Selected") 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 @@ -229,7 +238,10 @@ def add_newline(stats_label_text: str) -> str: def update_multi_selection(self, count: int): # Multiple Selected Items + self.layout().setSpacing(0) + self.file_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.file_label.setText(f"{count} Items Selected") 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 index 057551d99..7ac502df8 100644 --- a/tagstudio/src/qt/widgets/preview/preview_thumb.py +++ b/tagstudio/src/qt/widgets/preview/preview_thumb.py @@ -368,6 +368,10 @@ def update_preview(self, filepath: Path, ext: str) -> dict: 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/recent_libraries.py b/tagstudio/src/qt/widgets/preview/recent_libraries.py index fd2fb2b26..e43d43faa 100644 --- a/tagstudio/src/qt/widgets/preview/recent_libraries.py +++ b/tagstudio/src/qt/widgets/preview/recent_libraries.py @@ -99,7 +99,7 @@ def __init__(self, library: Library, driver: "QtDriver"): # "}" # 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};}}" +# f"QPushButton::disabled{{background-color:{Theme.COLOR_FORBIDDEN_BG.value};}}" # ) # btn.setCursor(Qt.CursorShape.PointingHandCursor) diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index 009ce5240..89c73311d 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -2,22 +2,26 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -import traceback import typing from pathlib import Path from warnings import catch_warnings import structlog from PySide6.QtCore import Qt, Signal -from PySide6.QtWidgets import QHBoxLayout, QSplitter, QVBoxLayout, QWidget +from PySide6.QtWidgets import ( + QHBoxLayout, + QPushButton, + QSplitter, + QVBoxLayout, + QWidget, +) +from src.core.enums import Theme from src.core.library.alchemy.library import Library from src.core.library.alchemy.models import Entry -from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper +from src.core.palette import ColorType, UiColor, get_ui_color from src.qt.modals.add_field import AddFieldModal from src.qt.modals.tag_search import TagSearchPanel from src.qt.widgets.panel import PanelModal - -# from src.qt.modals.add_tag import AddTagModal 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 @@ -34,6 +38,30 @@ class PreviewPanel(QWidget): 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;" + "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.lib = library @@ -46,10 +74,8 @@ def __init__(self, library: Library, driver: "QtDriver"): self.fields = FieldContainers(library, driver) tsp = TagSearchPanel(self.driver.lib) - # tsp.tag_chosen.connect(lambda x: self.add_tag_callback(x)) self.add_tag_modal = PanelModal(tsp, "Add Tags", "Add Tags") - # self.add_tag_modal = AddTagModal(self.lib) self.add_field_modal = AddFieldModal(self.lib) preview_section = QWidget() @@ -65,25 +91,24 @@ def __init__(self, library: Library, driver: "QtDriver"): splitter = QSplitter() splitter.setOrientation(Qt.Orientation.Vertical) splitter.setHandleWidth(12) - # splitter.splitterMoved.connect( - # lambda: self.thumb.update_image_size( - # ( - # self.thumb.image_container.size().width(), - # self.thumb.image_container.size().height(), - # ) - # ) - # ) + 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.add_tag_button = QPushButtonWrapper() + 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") - 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.setMinimumHeight(28) + self.add_field_button.setStyleSheet(PreviewPanel.button_style) self.add_field_button.setText("Add Field") add_buttons_layout.addWidget(self.add_tag_button) @@ -107,12 +132,14 @@ def __init__(self, library: Library, driver: "QtDriver"): def update_widgets(self) -> bool: """Render the panel widgets with the newest data from the Library.""" # No Items Selected - # items: list[Entry] = [self.driver.frame_content[x] for x in self.driver.selected] if len(self.driver.selected) == 0: - # TODO: Clear everything to default - # self.file_attrs.update_blank() + 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: @@ -121,48 +148,54 @@ def update_widgets(self) -> bool: ext: str = filepath.suffix.lower() stats: dict = self.thumb.update_preview(filepath, ext) - try: - self.file_attrs.update_stats(filepath, ext, stats) - self.file_attrs.update_date_label(filepath) - self.fields.update_from_entry(entry) - self.update_add_tag_button(entry) - self.update_add_field_button(entry) - except Exception as e: - logger.error("[Preview Panel] Error updating selection", error=e) - traceback.print_exc() + self.file_attrs.update_stats(filepath, ext, stats) + self.file_attrs.update_date_label(filepath) + self.fields.update_from_entry(entry) + self.update_add_tag_button(entry) + self.update_add_field_button(entry) + + self.add_tag_button.setEnabled(True) + self.add_field_button.setEnabled(True) # Multiple Selected Items elif len(self.driver.selected) > 1: - # Render mixed selection + # items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected] + # TODO: Render mixed selection + self.thumb.hide_preview() # TODO: Allow for mixed editing 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.fields.update_from_entries(items) # self.file_attrs.update_selection_count() + self.add_tag_button.setEnabled(True) + self.add_field_button.setEnabled(True) + # self.thumb.update_widgets() # # self.file_attrs.update_widgets() # self.fields.update_widgets() return True - def update_add_field_button(self, entry: Entry): + def update_add_field_button(self, entry: Entry | None = None): with catch_warnings(record=True): self.add_field_modal.done.disconnect() self.add_field_button.clicked.disconnect() # TODO: Remove all "is_connected" instances across the codebase self.add_field_modal.is_connected = False - self.add_field_button.is_connected = False self.add_field_modal.done.connect( lambda f: ( self.fields.add_field_to_selected(f), - self.fields.update_from_entry(entry), + (self.fields.update_from_entry(entry) if entry else ()), ) ) self.add_field_modal.is_connected = True self.add_field_button.clicked.connect(self.add_field_modal.show) - def update_add_tag_button(self, entry: Entry): + def update_add_tag_button(self, entry: Entry = None): with catch_warnings(record=True): self.add_tag_modal.widget.tag_chosen.disconnect() self.add_tag_button.clicked.disconnect() @@ -170,7 +203,7 @@ def update_add_tag_button(self, entry: Entry): self.add_tag_modal.widget.tag_chosen.connect( lambda t: ( self.fields.add_tags_to_selected(t), - self.fields.update_from_entry(entry), + (self.fields.update_from_entry(entry) if entry else ()), ) ) @@ -180,6 +213,3 @@ def update_add_tag_button(self, entry: Entry): self.add_tag_modal.show(), ) ) - self.add_tag_button.is_connected = True - - # self.add_field_button.clicked.connect(self.add_tag_modal.show) From 73bfda7ea8493169e27a9452e8ea9b5b74e1f51a Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 3 Jan 2025 04:41:33 -0800 Subject: [PATCH 22/68] cleanup comments; rename tsp to tag_search_panel --- .../src/qt/widgets/preview/field_containers.py | 10 ++-------- .../src/qt/widgets/preview/file_attributes.py | 18 +----------------- tagstudio/src/qt/widgets/preview_panel.py | 15 ++++----------- 3 files changed, 7 insertions(+), 36 deletions(-) diff --git a/tagstudio/src/qt/widgets/preview/field_containers.py b/tagstudio/src/qt/widgets/preview/field_containers.py index ae00a6c07..d34823870 100644 --- a/tagstudio/src/qt/widgets/preview/field_containers.py +++ b/tagstudio/src/qt/widgets/preview/field_containers.py @@ -89,11 +89,11 @@ def __init__(self, library: Library, driver: "QtDriver"): 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};" @@ -437,15 +437,11 @@ def remove_field(self, field: BaseField): entry_ids = [e.id for e in self.cached_entries] 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) + (TextField, DatetimeField), ), f"instance: {type(field)}" entry_ids = [e.id for e in self.cached_entries] @@ -466,11 +462,9 @@ def remove_message_box(self, prompt: str, callback: Callable) -> None: Translations["generic.cancel_alt"], QMessageBox.ButtonRole.DestructiveRole ) remove_mb.addButton("&Remove", QMessageBox.ButtonRole.RejectRole) - # 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/preview/file_attributes.py b/tagstudio/src/qt/widgets/preview/file_attributes.py index 6a99b9455..43c76227b 100644 --- a/tagstudio/src/qt/widgets/preview/file_attributes.py +++ b/tagstudio/src/qt/widgets/preview/file_attributes.py @@ -38,12 +38,6 @@ class FileAttributes(QWidget): 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 - root_layout = QVBoxLayout(self) root_layout.setContentsMargins(0, 0, 0, 0) root_layout.setSpacing(0) @@ -54,12 +48,6 @@ def __init__(self, library: Library, driver: "QtDriver"): else Theme.COLOR_DARK_LABEL.value ) - # panel_bg_color = ( - # Theme.COLOR_BG_DARK.value - # if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - # else Theme.COLOR_BG_LIGHT.value - # ) - self.date_style = "font-size:12px;" self.file_label_style = "font-size: 12px" self.properties_style = ( @@ -168,11 +156,7 @@ def update_stats(self, filepath: Path | None = None, ext: str = ".", stats: dict file_str += f"
{"\u200b".join(part_)}" self.file_label.setText(file_str) self.file_label.setCursor(Qt.CursorShape.PointingHandCursor) - self.opener = FileOpenerHelper(filepath) - # 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) # Initialize the possible stat variables stats_label_text = "" @@ -237,7 +221,7 @@ def add_newline(stats_label_text: str) -> str: self.dimensions_label.setText(stats_label_text) def update_multi_selection(self, count: int): - # Multiple Selected Items + """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") diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index 89c73311d..72fbca803 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -73,8 +73,8 @@ def __init__(self, library: Library, driver: "QtDriver"): self.file_attrs = FileAttributes(library, driver) self.fields = FieldContainers(library, driver) - tsp = TagSearchPanel(self.driver.lib) - self.add_tag_modal = PanelModal(tsp, "Add Tags", "Add Tags") + tag_search_panel = TagSearchPanel(self.driver.lib) + self.add_tag_modal = PanelModal(tag_search_panel, "Add Tags", "Add Tags") self.add_field_modal = AddFieldModal(self.lib) @@ -121,7 +121,7 @@ def __init__(self, library: Library, driver: "QtDriver"): splitter.addWidget(preview_section) splitter.addWidget(info_section) - # splitter.addWidget(self.libs_flow_container) + # splitter.addWidget(self.libs_flow_container) # TODO: Determine fate of this; Move to menu splitter.setStretchFactor(1, 2) root_layout = QVBoxLayout(self) @@ -160,23 +160,16 @@ def update_widgets(self) -> bool: # Multiple Selected Items elif len(self.driver.selected) > 1: # items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected] - # TODO: Render mixed selection - self.thumb.hide_preview() # TODO: Allow for mixed editing + 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.fields.update_from_entries(items) - # self.file_attrs.update_selection_count() self.add_tag_button.setEnabled(True) self.add_field_button.setEnabled(True) - # self.thumb.update_widgets() - # # self.file_attrs.update_widgets() - # self.fields.update_widgets() - return True def update_add_field_button(self, entry: Entry | None = None): From b867bc26e999081a8eb03d81b090b98d302f81c3 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 3 Jan 2025 04:58:55 -0800 Subject: [PATCH 23/68] fix(ui): properly unset container callbacks --- tagstudio/src/qt/widgets/fields.py | 37 +++++++++---------- .../qt/widgets/preview/field_containers.py | 7 ++-- 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/tagstudio/src/qt/widgets/fields.py b/tagstudio/src/qt/widgets/fields.py index dfcc89d00..f3117d812 100644 --- a/tagstudio/src/qt/widgets/fields.py +++ b/tagstudio/src/qt/widgets/fields.py @@ -5,14 +5,13 @@ 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 class FieldContainer(QWidget): @@ -72,7 +71,7 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None: self.title_layout.addWidget(self.title_widget) self.title_layout.addStretch(2) - self.copy_button = QPushButtonWrapper() + self.copy_button = QPushButton() self.copy_button.setMinimumSize(button_size, button_size) self.copy_button.setMaximumSize(button_size, button_size) self.copy_button.setFlat(True) @@ -81,7 +80,7 @@ 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.setMinimumSize(button_size, button_size) self.edit_button.setMaximumSize(button_size, button_size) self.edit_button.setFlat(True) @@ -90,7 +89,7 @@ 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.setMinimumSize(button_size, button_size) self.remove_button.setMaximumSize(button_size, button_size) self.remove_button.setFlat(True) @@ -107,29 +106,29 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None: self.field_container.setLayout(self.field_layout) self.inner_layout.addWidget(self.field_container) - 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): diff --git a/tagstudio/src/qt/widgets/preview/field_containers.py b/tagstudio/src/qt/widgets/preview/field_containers.py index d34823870..406bbbe44 100644 --- a/tagstudio/src/qt/widgets/preview/field_containers.py +++ b/tagstudio/src/qt/widgets/preview/field_containers.py @@ -321,7 +321,6 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): 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") @@ -330,12 +329,12 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): container.set_inner_widget(inner_widget) except Exception: container.set_title(field.type.name) - # container.set_editable(False) 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), @@ -367,7 +366,6 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): ) ) - container.edit_button.setHidden(True) container.setHidden(False) def write_tag_container( @@ -424,7 +422,8 @@ def write_tag_container( container.set_inner_widget(inner_widget) self.tags_updated.emit() - container.edit_button.setHidden(True) + container.set_edit_callback() + container.set_remove_callback() container.setHidden(False) def remove_field(self, field: BaseField): From d016eefe244027a8924c738c588d3b4c5038baf6 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 3 Jan 2025 10:16:15 -0800 Subject: [PATCH 24/68] fix: optimize queries --- tagstudio/src/core/library/alchemy/library.py | 27 ++++++++++++++++++- tagstudio/src/qt/ts_qt.py | 3 +-- .../qt/widgets/preview/field_containers.py | 2 +- tagstudio/src/qt/widgets/preview_panel.py | 2 +- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index 28dcf7897..34ec20c5b 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -434,7 +434,6 @@ def get_entry_full(self, entry_id: int) -> Entry | None: selectinload(Entry.tags).options( selectinload(Tag.aliases), selectinload(Tag.subtags), - selectinload(Tag.parent_tags), ), ) entry = session.scalar(statement) @@ -444,6 +443,32 @@ def get_entry_full(self, entry_id: int) -> Entry | None: 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.tags) + ) + statement = statement.options( + selectinload(Entry.text_fields), + selectinload(Entry.datetime_fields), + selectinload(Entry.tags).options( + selectinload(Tag.aliases), + selectinload(Tag.subtags), + ), + ) + 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: with Session(self.engine) as session: diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index a484e5007..e27c36075 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -1116,8 +1116,7 @@ def update_thumbs(self): is_grid_thumb = True logger.info("[QtDriver] Loading Entries...") - # TODO: Grab all entries at once - entries: list[Entry] = [self.lib.get_entry_full(e_id) for e_id in self.frame_content] + 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...") diff --git a/tagstudio/src/qt/widgets/preview/field_containers.py b/tagstudio/src/qt/widgets/preview/field_containers.py index 406bbbe44..6557c3191 100644 --- a/tagstudio/src/qt/widgets/preview/field_containers.py +++ b/tagstudio/src/qt/widgets/preview/field_containers.py @@ -108,7 +108,6 @@ def __init__(self, library: Library, driver: "QtDriver"): def update_from_entry(self, entry: Entry): """Update tags and fields from a single Entry source.""" - self.cached_entries = [self.lib.get_entry_full(entry.id)] logger.info( "[FieldContainers] Updating Selection", path=entry.path, @@ -116,6 +115,7 @@ def update_from_entry(self, entry: Entry): tags=entry.tags, ) + self.cached_entries = [self.lib.get_entry_full(entry.id)] entry_ = self.cached_entries[0] container_len: int = len(entry_.fields) container_index = 0 diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index 72fbca803..69f1b7913 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -143,7 +143,7 @@ def update_widgets(self) -> bool: # One Item Selected elif len(self.driver.selected) == 1: - entry: Entry = self.lib.get_entry_full(self.driver.selected[0]) + entry: Entry = self.lib.get_entry(self.driver.selected[0]) filepath: Path = self.lib.library_dir / entry.path ext: str = filepath.suffix.lower() From 45d502f4c0d37aad1f9f76d054478978fe46ce60 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 3 Jan 2025 10:16:37 -0800 Subject: [PATCH 25/68] fix: catch int cast exception --- tagstudio/src/core/library/alchemy/visitors.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tagstudio/src/core/library/alchemy/visitors.py b/tagstudio/src/core/library/alchemy/visitors.py index 3117b5ae2..401086bc5 100644 --- a/tagstudio/src/core/library/alchemy/visitors.py +++ b/tagstudio/src/core/library/alchemy/visitors.py @@ -51,12 +51,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: From 741c2829ff1a1458856aeabf020c80d29df5b4a1 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 3 Jan 2025 10:50:52 -0800 Subject: [PATCH 26/68] fix: remove unnecessary update calls --- tagstudio/src/core/library/alchemy/library.py | 13 +++++++------ .../src/qt/widgets/preview/field_containers.py | 12 ++++-------- tagstudio/src/qt/widgets/tag_box.py | 6 +++--- 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index 34ec20c5b..c8bdb72c8 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -419,15 +419,16 @@ def get_entry(self, entry_id: int) -> Entry | None: make_transient(entry) return entry - def get_entry_full(self, entry_id: int) -> Entry | None: + 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) - statement = ( - statement.outerjoin(Entry.text_fields) - .outerjoin(Entry.datetime_fields) - .outerjoin(Entry.tags) - ) + if with_fields: + statement = statement.outerjoin(Entry.text_fields).outerjoin(Entry.datetime_fields) + if with_tags: + statement = statement.outerjoin(Entry.tags) statement = statement.options( selectinload(Entry.text_fields), selectinload(Entry.datetime_fields), diff --git a/tagstudio/src/qt/widgets/preview/field_containers.py b/tagstudio/src/qt/widgets/preview/field_containers.py index 6557c3191..89f785d10 100644 --- a/tagstudio/src/qt/widgets/preview/field_containers.py +++ b/tagstudio/src/qt/widgets/preview/field_containers.py @@ -115,7 +115,7 @@ def update_from_entry(self, entry: Entry): tags=entry.tags, ) - self.cached_entries = [self.lib.get_entry_full(entry.id)] + self.cached_entries = [self.lib.get_entry_full(entry.id, with_fields=False)] entry_ = self.cached_entries[0] container_len: int = len(entry_.fields) container_index = 0 @@ -129,6 +129,8 @@ def update_from_entry(self, entry: Entry): ) container_index += 1 container_len += 1 + if categories: + self.tags_updated.emit() # Write field container(s) for index, field in enumerate(entry_.fields, start=container_index): self.write_container(index, field, is_mixed=False) @@ -410,18 +412,12 @@ def write_tag_container( ) container.set_inner_widget(inner_widget) - inner_widget.updated.connect( - lambda: ( - self.write_tag_container(index, tags, category_tag), - self.update_from_entry(self.cached_entries[0]), - ) - ) + inner_widget.updated.connect(lambda: (self.update_from_entry(self.cached_entries[0]))) else: text = "Mixed Data" inner_widget = TextWidget("Mixed Tags", text) container.set_inner_widget(inner_widget) - self.tags_updated.emit() container.set_edit_callback() container.set_remove_callback() container.setHidden(False) diff --git a/tagstudio/src/qt/widgets/tag_box.py b/tagstudio/src/qt/widgets/tag_box.py index 8802dafaf..5fbaba838 100644 --- a/tagstudio/src/qt/widgets/tag_box.py +++ b/tagstudio/src/qt/widgets/tag_box.py @@ -98,10 +98,10 @@ def remove_tag(self, tag_id: int): selected=self.driver.selected, ) + if tag_id in (TAG_FAVORITE, TAG_ARCHIVED): + self.driver.update_badges(self.driver.selected) + 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.driver.selected) From 7bb4cb129c1387679a01c9a6fbf3d26a94a4a583 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 3 Jan 2025 17:21:59 -0800 Subject: [PATCH 27/68] fix: restore try/except block in preview_panel --- tagstudio/src/qt/widgets/preview_panel.py | 84 ++++++++++++----------- 1 file changed, 45 insertions(+), 39 deletions(-) diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index 69f1b7913..acdeca536 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -2,6 +2,7 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio +import traceback import typing from pathlib import Path from warnings import catch_warnings @@ -132,45 +133,50 @@ def __init__(self, library: Library, driver: "QtDriver"): def update_widgets(self) -> bool: """Render the panel widgets with the newest data from the Library.""" # No Items Selected - 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]) - filepath: Path = self.lib.library_dir / entry.path - ext: str = filepath.suffix.lower() - - 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) - self.update_add_tag_button(entry) - self.update_add_field_button(entry) - - 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) - - return True + 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]) + filepath: Path = self.lib.library_dir / entry.path + ext: str = filepath.suffix.lower() + + 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) + self.update_add_tag_button(entry) + self.update_add_field_button(entry) + + 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) + + return True + except Exception as e: + logger.error("[Preview Panel] Error updating selection", error=e) + traceback.print_exc() + return False def update_add_field_button(self, entry: Entry | None = None): with catch_warnings(record=True): From 1755cb9771c19802b678246069040deab44d1464 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 3 Jan 2025 18:22:32 -0800 Subject: [PATCH 28/68] fix: correct type hints for get_tag_categories --- tagstudio/src/qt/widgets/preview/field_containers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tagstudio/src/qt/widgets/preview/field_containers.py b/tagstudio/src/qt/widgets/preview/field_containers.py index 89f785d10..5b8e5d9cb 100644 --- a/tagstudio/src/qt/widgets/preview/field_containers.py +++ b/tagstudio/src/qt/widgets/preview/field_containers.py @@ -146,9 +146,9 @@ def hide_containers(self): for c in self.containers: c.setHidden(True) - def get_tag_categories(self, tags: set[Tag]) -> dict[Tag, set[Tag | None]]: + 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, set[Tag | None]] = {} + cats: dict[Tag | None, set[Tag]] = {} cats[None] = set() # Initialize all categories from parents From 50815aa1b4a41632a0699c9dda06e9a23847cc2f Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 3 Jan 2025 18:29:05 -0800 Subject: [PATCH 29/68] fix: tags no longer lazy load subtags and aliases --- tagstudio/src/core/library/alchemy/library.py | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index c8bdb72c8..2be9d663d 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -28,6 +28,7 @@ from sqlalchemy.orm import ( Session, contains_eager, + joinedload, make_transient, selectinload, ) @@ -426,17 +427,22 @@ def get_entry_full( 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) + 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) - statement = statement.options( - selectinload(Entry.text_fields), - selectinload(Entry.datetime_fields), - selectinload(Entry.tags).options( - selectinload(Tag.aliases), - selectinload(Tag.subtags), - ), - ) + statement = ( + statement.outerjoin(Entry.tags) + .outerjoin(TagAlias) + .options( + selectinload(Entry.tags).options( + joinedload(Tag.aliases), + joinedload(Tag.subtags), + ) + ) + ) entry = session.scalar(statement) if not entry: return None From 5ef1eeb829c000076c671290244c989c90aa0f44 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 4 Jan 2025 04:12:08 -0800 Subject: [PATCH 30/68] fix: recursively include parent tag categories --- .../qt/widgets/preview/field_containers.py | 83 ++++++++++++++----- 1 file changed, 62 insertions(+), 21 deletions(-) diff --git a/tagstudio/src/qt/widgets/preview/field_containers.py b/tagstudio/src/qt/widgets/preview/field_containers.py index 5b8e5d9cb..851559fba 100644 --- a/tagstudio/src/qt/widgets/preview/field_containers.py +++ b/tagstudio/src/qt/widgets/preview/field_containers.py @@ -151,30 +151,71 @@ def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[Tag]]: cats: dict[Tag | None, set[Tag]] = {} cats[None] = set() - # Initialize all categories from parents + 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 subtag's ID's 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.subtag_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.subtag_ids: + add_to_cluster( + tag_id, + [sub_id for sub_id in p_tag.subtag_ids if sub_id != tag_id], + ) + exhausted.add(p_id) + exhausted.add(tag_id) + for tag in tags: - for p_tag in list(tag.subtags) + [tag]: - logger.info(f"[{tag.name}] is {p_tag.name} a category? ({p_tag.is_category})") - if p_tag.is_category: - cats[p_tag] = set() + add_to_cluster(tag.id) + + logger.info("Entry Cluster", entry_cluster=exhausted) + logger.info("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("Blank Tag Categories", cats=cats) - # Add tags to any applicable categories - for tag in tags: - is_general = True - for p_tag in list(cats.keys()): - logger.info(f"[{tag.name}] Checking category tag key {p_tag}") - if not p_tag: - pass - elif p_tag in tag.subtags: - cats[p_tag].add(tag) - is_general = False - elif tag == p_tag: - cats[p_tag].add(tag) - is_general = False - pass - if is_general: - cats[None].add(tag) + # Add tags to any applicable categories. + added_ids: set[int] = set() + for key in cats: + logger.info("Checking category tag key", key=key) + + if key: + logger.info("Key cluster:", key=key, cluster=cluster_map.get(key.id)) + + if final_tags := cluster_map.get(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"[{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] = [] From b0324cddff6411715106e539c793f80b1d2c5b9c Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 4 Jan 2025 04:52:58 -0800 Subject: [PATCH 31/68] chore: update copyright info --- tagstudio/src/core/constants.py | 4 ++++ tagstudio/src/core/enums.py | 4 ++++ tagstudio/src/core/library/alchemy/db.py | 4 ++++ tagstudio/src/core/library/alchemy/fields.py | 4 ++++ tagstudio/src/core/library/alchemy/joins.py | 4 ++++ tagstudio/src/core/library/alchemy/library.py | 4 ++++ tagstudio/src/core/library/alchemy/models.py | 4 ++++ tagstudio/src/core/library/alchemy/visitors.py | 4 ++++ tagstudio/src/core/palette.py | 2 +- tagstudio/src/qt/main_window.py | 12 +----------- tagstudio/src/qt/modals/build_tag.py | 2 +- tagstudio/src/qt/modals/folders_to_tags.py | 2 +- tagstudio/src/qt/ts_qt.py | 2 +- tagstudio/src/qt/widgets/collage_icon.py | 2 +- tagstudio/src/qt/widgets/fields.py | 2 +- tagstudio/src/qt/widgets/item_thumb.py | 2 +- tagstudio/src/qt/widgets/migration_modal.py | 2 +- tagstudio/src/qt/widgets/preview/field_containers.py | 2 +- tagstudio/src/qt/widgets/preview/file_attributes.py | 2 +- tagstudio/src/qt/widgets/preview/preview_thumb.py | 2 +- tagstudio/src/qt/widgets/preview/recent_libraries.py | 2 +- tagstudio/src/qt/widgets/preview_panel.py | 2 +- tagstudio/src/qt/widgets/thumb_renderer.py | 2 +- 23 files changed, 47 insertions(+), 25 deletions(-) diff --git a/tagstudio/src/core/constants.py b/tagstudio/src/core/constants.py index 5b0f17303..a4c8aa18f 100644 --- a/tagstudio/src/core/constants.py +++ b/tagstudio/src/core/constants.py @@ -1,3 +1,7 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# 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" diff --git a/tagstudio/src/core/enums.py b/tagstudio/src/core/enums.py index 749428cae..c5969054d 100644 --- a/tagstudio/src/core/enums.py +++ b/tagstudio/src/core/enums.py @@ -1,3 +1,7 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# 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 diff --git a/tagstudio/src/core/library/alchemy/db.py b/tagstudio/src/core/library/alchemy/db.py index 9bcefdf47..5e5f70e9f 100644 --- a/tagstudio/src/core/library/alchemy/db.py +++ b/tagstudio/src/core/library/alchemy/db.py @@ -1,3 +1,7 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + from pathlib import Path import structlog diff --git a/tagstudio/src/core/library/alchemy/fields.py b/tagstudio/src/core/library/alchemy/fields.py index 9f180cfce..dd318f4bc 100644 --- a/tagstudio/src/core/library/alchemy/fields.py +++ b/tagstudio/src/core/library/alchemy/fields.py @@ -1,3 +1,7 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + from __future__ import annotations from dataclasses import dataclass, field diff --git a/tagstudio/src/core/library/alchemy/joins.py b/tagstudio/src/core/library/alchemy/joins.py index 640b97fcc..63c5dba62 100644 --- a/tagstudio/src/core/library/alchemy/joins.py +++ b/tagstudio/src/core/library/alchemy/joins.py @@ -1,3 +1,7 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# 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 diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index 2be9d663d..42ba36c80 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -1,3 +1,7 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + import re import shutil import time diff --git a/tagstudio/src/core/library/alchemy/models.py b/tagstudio/src/core/library/alchemy/models.py index 5363213a7..0f6fc8d81 100644 --- a/tagstudio/src/core/library/alchemy/models.py +++ b/tagstudio/src/core/library/alchemy/models.py @@ -1,3 +1,7 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# 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 diff --git a/tagstudio/src/core/library/alchemy/visitors.py b/tagstudio/src/core/library/alchemy/visitors.py index 401086bc5..f593abfac 100644 --- a/tagstudio/src/core/library/alchemy/visitors.py +++ b/tagstudio/src/core/library/alchemy/visitors.py @@ -1,3 +1,7 @@ +# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + from typing import TYPE_CHECKING import structlog diff --git a/tagstudio/src/core/palette.py b/tagstudio/src/core/palette.py index 45c095610..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 diff --git a/tagstudio/src/qt/main_window.py b/tagstudio/src/qt/main_window.py index 9bddfe8d9..1bae4b3bf 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 diff --git a/tagstudio/src/qt/modals/build_tag.py b/tagstudio/src/qt/modals/build_tag.py index 5cf9f56a5..5d62612c7 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 diff --git a/tagstudio/src/qt/modals/folders_to_tags.py b/tagstudio/src/qt/modals/folders_to_tags.py index 2557829e9..1af62e435 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 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index e27c36075..0847ee0a2 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 diff --git a/tagstudio/src/qt/widgets/collage_icon.py b/tagstudio/src/qt/widgets/collage_icon.py index 23df2bde4..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 diff --git a/tagstudio/src/qt/widgets/fields.py b/tagstudio/src/qt/widgets/fields.py index f3117d812..44ce64bbf 100644 --- a/tagstudio/src/qt/widgets/fields.py +++ b/tagstudio/src/qt/widgets/fields.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 diff --git a/tagstudio/src/qt/widgets/item_thumb.py b/tagstudio/src/qt/widgets/item_thumb.py index 344035c27..5594eb5e8 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 diff --git a/tagstudio/src/qt/widgets/migration_modal.py b/tagstudio/src/qt/widgets/migration_modal.py index d141e5d34..4bfa6a982 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 diff --git a/tagstudio/src/qt/widgets/preview/field_containers.py b/tagstudio/src/qt/widgets/preview/field_containers.py index 851559fba..34e9f29ec 100644 --- a/tagstudio/src/qt/widgets/preview/field_containers.py +++ b/tagstudio/src/qt/widgets/preview/field_containers.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 diff --git a/tagstudio/src/qt/widgets/preview/file_attributes.py b/tagstudio/src/qt/widgets/preview/file_attributes.py index 43c76227b..17575c7a8 100644 --- a/tagstudio/src/qt/widgets/preview/file_attributes.py +++ b/tagstudio/src/qt/widgets/preview/file_attributes.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 diff --git a/tagstudio/src/qt/widgets/preview/preview_thumb.py b/tagstudio/src/qt/widgets/preview/preview_thumb.py index 7ac502df8..13d6980fb 100644 --- a/tagstudio/src/qt/widgets/preview/preview_thumb.py +++ b/tagstudio/src/qt/widgets/preview/preview_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 diff --git a/tagstudio/src/qt/widgets/preview/recent_libraries.py b/tagstudio/src/qt/widgets/preview/recent_libraries.py index e43d43faa..8106bf02d 100644 --- a/tagstudio/src/qt/widgets/preview/recent_libraries.py +++ b/tagstudio/src/qt/widgets/preview/recent_libraries.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 diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index acdeca536..aeca0be54 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.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 diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index b17f519ad..0df495d2c 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 From d37e461f022f88e5f514eb7afe48ad5921169695 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 4 Jan 2025 04:59:17 -0800 Subject: [PATCH 32/68] chore: remove unused code --- tagstudio/src/core/library/alchemy/fields.py | 23 ----------- tagstudio/src/core/library/alchemy/joins.py | 8 ---- tagstudio/src/core/library/alchemy/library.py | 40 ------------------- tagstudio/src/core/library/alchemy/models.py | 1 - tagstudio/src/qt/widgets/migration_modal.py | 2 - .../qt/widgets/preview/field_containers.py | 1 - 6 files changed, 75 deletions(-) diff --git a/tagstudio/src/core/library/alchemy/fields.py b/tagstudio/src/core/library/alchemy/fields.py index dd318f4bc..2b860e2f5 100644 --- a/tagstudio/src/core/library/alchemy/fields.py +++ b/tagstudio/src/core/library/alchemy/fields.py @@ -84,29 +84,6 @@ def __eq__(self, value) -> bool: raise NotImplementedError -# TODO: Remove -# 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" diff --git a/tagstudio/src/core/library/alchemy/joins.py b/tagstudio/src/core/library/alchemy/joins.py index 63c5dba62..998fbdce2 100644 --- a/tagstudio/src/core/library/alchemy/joins.py +++ b/tagstudio/src/core/library/alchemy/joins.py @@ -15,14 +15,6 @@ class TagSubtag(Base): child_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True) -# TODO: Remove -# class TagField(Base): -# __tablename__ = "tag_fields" -# -# 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) - - class TagEntry(Base): __tablename__ = "tag_entries" diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index 42ba36c80..d3f2e03ef 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -376,44 +376,6 @@ def delete_item(self, item): session.delete(item) session.commit() - # TODO: Remove (i think) - # 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: @@ -495,12 +457,10 @@ def get_entries(self, with_joins: bool = False) -> Iterator[Entry]: stmt.outerjoin(Entry.text_fields) .outerjoin(Entry.datetime_fields) .outerjoin(Entry.tags) - # .outerjoin(Entry.tag_box_fields) ) stmt = stmt.options( contains_eager(Entry.text_fields), contains_eager(Entry.datetime_fields), - # contains_eager(Entry.tag_box_fields).selectinload(TagBoxField.tags), ) stmt = stmt.distinct() diff --git a/tagstudio/src/core/library/alchemy/models.py b/tagstudio/src/core/library/alchemy/models.py index 0f6fc8d81..d59adc9cf 100644 --- a/tagstudio/src/core/library/alchemy/models.py +++ b/tagstudio/src/core/library/alchemy/models.py @@ -229,7 +229,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/qt/widgets/migration_modal.py b/tagstudio/src/qt/widgets/migration_modal.py index 4bfa6a982..8f2446b50 100644 --- a/tagstudio/src/qt/widgets/migration_modal.py +++ b/tagstudio/src/qt/widgets/migration_modal.py @@ -22,8 +22,6 @@ from src.core.constants import TS_FOLDER_NAME from src.core.enums import LibraryPrefs from src.core.library.alchemy.enums import TagColor - -# from src.core.library.alchemy.fields import TagBoxField, _FieldID from src.core.library.alchemy.joins import TagSubtag from src.core.library.alchemy.library import DEFAULT_TAG_DIFF from src.core.library.alchemy.library import Library as SqliteLibrary diff --git a/tagstudio/src/qt/widgets/preview/field_containers.py b/tagstudio/src/qt/widgets/preview/field_containers.py index 34e9f29ec..c5a9b9ed7 100644 --- a/tagstudio/src/qt/widgets/preview/field_containers.py +++ b/tagstudio/src/qt/widgets/preview/field_containers.py @@ -326,7 +326,6 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): 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: From 8084e885c19bac9a7526d08d43d013106cf80ab7 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 4 Jan 2025 07:09:05 -0800 Subject: [PATCH 33/68] fix: load fields for selected entry --- tagstudio/src/qt/widgets/preview/field_containers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tagstudio/src/qt/widgets/preview/field_containers.py b/tagstudio/src/qt/widgets/preview/field_containers.py index c5a9b9ed7..9603474af 100644 --- a/tagstudio/src/qt/widgets/preview/field_containers.py +++ b/tagstudio/src/qt/widgets/preview/field_containers.py @@ -115,7 +115,7 @@ def update_from_entry(self, entry: Entry): tags=entry.tags, ) - self.cached_entries = [self.lib.get_entry_full(entry.id, with_fields=False)] + self.cached_entries = [self.lib.get_entry_full(entry.id)] entry_ = self.cached_entries[0] container_len: int = len(entry_.fields) container_index = 0 From 7d2dadb448d4a9df5948d0a774685b63f26ef586 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 4 Jan 2025 07:10:11 -0800 Subject: [PATCH 34/68] refactor: remove `is_connected` from AddFieldModal --- tagstudio/src/qt/modals/add_field.py | 1 - tagstudio/src/qt/widgets/preview_panel.py | 3 --- 2 files changed, 4 deletions(-) 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/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index aeca0be54..09c91fc45 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -182,8 +182,6 @@ def update_add_field_button(self, entry: Entry | None = None): with catch_warnings(record=True): self.add_field_modal.done.disconnect() self.add_field_button.clicked.disconnect() - # TODO: Remove all "is_connected" instances across the codebase - self.add_field_modal.is_connected = False self.add_field_modal.done.connect( lambda f: ( @@ -191,7 +189,6 @@ def update_add_field_button(self, entry: Entry | None = None): (self.fields.update_from_entry(entry) if entry else ()), ) ) - self.add_field_modal.is_connected = True self.add_field_button.clicked.connect(self.add_field_modal.show) def update_add_tag_button(self, entry: Entry = None): From f72ef71630eb2093b01dd8cb3de015f619ac0e9e Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 4 Jan 2025 07:54:55 -0800 Subject: [PATCH 35/68] fix: include category tags under their own categories --- tagstudio/src/qt/widgets/preview/field_containers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tagstudio/src/qt/widgets/preview/field_containers.py b/tagstudio/src/qt/widgets/preview/field_containers.py index 9603474af..552f47410 100644 --- a/tagstudio/src/qt/widgets/preview/field_containers.py +++ b/tagstudio/src/qt/widgets/preview/field_containers.py @@ -204,7 +204,7 @@ def add_to_cluster(tag_id: int, p_ids: list[int] | None = None): if key: logger.info("Key cluster:", key=key, cluster=cluster_map.get(key.id)) - if final_tags := cluster_map.get(key.id): + if final_tags := cluster_map.get(key.id).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}) From 11fcea1087bcfaee9828b2e5838d1beac8eab26f Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 4 Jan 2025 18:44:25 -0800 Subject: [PATCH 36/68] fix: badges now update when last tag is removed --- tagstudio/src/qt/widgets/preview/field_containers.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tagstudio/src/qt/widgets/preview/field_containers.py b/tagstudio/src/qt/widgets/preview/field_containers.py index 552f47410..a768b3bbc 100644 --- a/tagstudio/src/qt/widgets/preview/field_containers.py +++ b/tagstudio/src/qt/widgets/preview/field_containers.py @@ -129,8 +129,7 @@ def update_from_entry(self, entry: Entry): ) container_index += 1 container_len += 1 - if categories: - self.tags_updated.emit() + self.tags_updated.emit() # Write field container(s) for index, field in enumerate(entry_.fields, start=container_index): self.write_container(index, field, is_mixed=False) From ab937052d6dc43a78bad767e52e453468a35aed2 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 4 Jan 2025 23:45:26 -0800 Subject: [PATCH 37/68] fix: resolve differences with main --- tagstudio/src/qt/modals/build_tag.py | 22 ++++--------------- tagstudio/src/qt/widgets/migration_modal.py | 4 +--- .../qt/widgets/preview/field_containers.py | 5 +---- .../src/qt/widgets/preview/file_attributes.py | 13 +++++------ .../src/qt/widgets/preview/preview_thumb.py | 3 ++- .../qt/widgets/preview/recent_libraries.py | 1 - tagstudio/src/qt/widgets/preview_panel.py | 11 ++++++---- 7 files changed, 21 insertions(+), 38 deletions(-) diff --git a/tagstudio/src/qt/modals/build_tag.py b/tagstudio/src/qt/modals/build_tag.py index 5d62612c7..82b96d4cd 100644 --- a/tagstudio/src/qt/modals/build_tag.py +++ b/tagstudio/src/qt/modals/build_tag.py @@ -224,17 +224,6 @@ def __init__(self, library: Library, tag: Tag | None = None): self.cat_layout.addWidget(self.cat_checkbox) self.cat_layout.addWidget(self.cat_title) - # Keyboard Actions ===================================================== - remove_selected_alias_action = QAction("remove selected alias", self) - remove_selected_alias_action.triggered.connect(self.remove_selected_alias) - remove_selected_alias_action.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier), - QtCore.Qt.Key.Key_D, - ) - ) - self.addAction(remove_selected_alias_action) - # Add Widgets to Layout ================================================ self.root_layout.addWidget(self.name_widget) self.root_layout.addWidget(self.shorthand_widget) @@ -301,12 +290,9 @@ 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 @@ -402,12 +388,12 @@ def set_tag(self, tag: Tag): self.name_field.setText(tag.name) self.shorthand_field.setText(tag.shorthand or "") - for subtag in tag.subtag_ids: - self.subtag_ids.add(subtag) - for alias_id in tag.alias_ids: - self.alias_ids.add(alias_id) + self.alias_ids.append(alias_id) + self._set_aliases() + for subtag in tag.subtag_ids: + self.subtag_ids.add(subtag) self.set_subtags() # select item in self.color_field where the userData value matched tag.color diff --git a/tagstudio/src/qt/widgets/migration_modal.py b/tagstudio/src/qt/widgets/migration_modal.py index 8f2446b50..dcc11a954 100644 --- a/tagstudio/src/qt/widgets/migration_modal.py +++ b/tagstudio/src/qt/widgets/migration_modal.py @@ -367,9 +367,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) ) diff --git a/tagstudio/src/qt/widgets/preview/field_containers.py b/tagstudio/src/qt/widgets/preview/field_containers.py index a768b3bbc..f2d82272a 100644 --- a/tagstudio/src/qt/widgets/preview/field_containers.py +++ b/tagstudio/src/qt/widgets/preview/field_containers.py @@ -28,11 +28,8 @@ TextField, ) from src.core.library.alchemy.library import Library -from src.qt.translations import Translations -from src.core.library.alchemy.models import Entry -from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper -from src.qt.modals.add_field import AddFieldModal 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 diff --git a/tagstudio/src/qt/widgets/preview/file_attributes.py b/tagstudio/src/qt/widgets/preview/file_attributes.py index 17575c7a8..af493bd52 100644 --- a/tagstudio/src/qt/widgets/preview/file_attributes.py +++ b/tagstudio/src/qt/widgets/preview/file_attributes.py @@ -23,7 +23,6 @@ from src.core.library.alchemy.library import Library from src.core.media_types import MediaCategories from src.qt.helpers.file_opener import FileOpenerHelper, FileOpenerLabel -from src.qt.translations import Translations if typing.TYPE_CHECKING: from src.qt.ts_qt import QtDriver @@ -111,16 +110,16 @@ def update_date_label(self, filepath: Path | None = None) -> None: 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")}" + 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")}" + 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") - self.date_modified_label.setText("Date Modified: N/A") + 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: @@ -135,7 +134,7 @@ def update_stats(self, filepath: Path | None = None, ext: str = ".", stats: dict if not filepath: self.layout().setSpacing(0) self.file_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.file_label.setText("No Items Selected") + 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("") @@ -224,7 +223,7 @@ 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") + 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("") diff --git a/tagstudio/src/qt/widgets/preview/preview_thumb.py b/tagstudio/src/qt/widgets/preview/preview_thumb.py index 13d6980fb..baff35ed8 100644 --- a/tagstudio/src/qt/widgets/preview/preview_thumb.py +++ b/tagstudio/src/qt/widgets/preview/preview_thumb.py @@ -54,7 +54,8 @@ def __init__(self, library: Library, driver: "QtDriver"): image_layout = QHBoxLayout(self) image_layout.setContentsMargins(0, 0, 0, 0) - self.open_file_action = QAction("Open file", self) + 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() diff --git a/tagstudio/src/qt/widgets/preview/recent_libraries.py b/tagstudio/src/qt/widgets/preview/recent_libraries.py index 8106bf02d..b2f806172 100644 --- a/tagstudio/src/qt/widgets/preview/recent_libraries.py +++ b/tagstudio/src/qt/widgets/preview/recent_libraries.py @@ -9,7 +9,6 @@ QWidget, ) from src.core.library.alchemy.library import Library -from src.qt.translations import Translations if typing.TYPE_CHECKING: from src.qt.ts_qt import QtDriver diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index 09c91fc45..a88768fa5 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -22,11 +22,11 @@ from src.core.palette import ColorType, UiColor, get_ui_color from src.qt.modals.add_field import AddFieldModal from src.qt.modals.tag_search import TagSearchPanel +from src.qt.translations import Translations from src.qt.widgets.panel import PanelModal 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 -from src.qt.translations import Translations if typing.TYPE_CHECKING: from src.qt.ts_qt import QtDriver @@ -75,7 +75,10 @@ def __init__(self, library: Library, driver: "QtDriver"): self.fields = FieldContainers(library, driver) tag_search_panel = TagSearchPanel(self.driver.lib) - self.add_tag_modal = PanelModal(tag_search_panel, "Add Tags", "Add Tags") + 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.add_field_modal = AddFieldModal(self.lib) @@ -103,14 +106,14 @@ def __init__(self, library: Library, driver: "QtDriver"): 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") + self.add_tag_button.setText("Add Tag") # TODO: Translate self.add_field_button = QPushButton() self.add_field_button.setEnabled(False) self.add_field_button.setCursor(Qt.CursorShape.PointingHandCursor) self.add_field_button.setMinimumHeight(28) self.add_field_button.setStyleSheet(PreviewPanel.button_style) - self.add_field_button.setText("Add Field") + self.add_field_button.setText("Add Field") # TODO: Translate add_buttons_layout.addWidget(self.add_tag_button) add_buttons_layout.addWidget(self.add_field_button) From 369d2dc3882f52a021ec1524a593ef79a0bd1816 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 4 Jan 2025 23:45:52 -0800 Subject: [PATCH 38/68] fix: return empty set in place of `None` --- tagstudio/src/qt/widgets/preview/field_containers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tagstudio/src/qt/widgets/preview/field_containers.py b/tagstudio/src/qt/widgets/preview/field_containers.py index f2d82272a..333a6a274 100644 --- a/tagstudio/src/qt/widgets/preview/field_containers.py +++ b/tagstudio/src/qt/widgets/preview/field_containers.py @@ -200,7 +200,7 @@ def add_to_cluster(tag_id: int, p_ids: list[int] | None = None): if key: logger.info("Key cluster:", key=key, cluster=cluster_map.get(key.id)) - if final_tags := cluster_map.get(key.id).union([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}) From af511d898610344d29c6ec0721abd6c03ff41e44 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sun, 5 Jan 2025 04:59:55 -0800 Subject: [PATCH 39/68] ui: add field highlighting, tweak theming --- tagstudio/src/qt/widgets/fields.py | 42 +++++++++++++------ .../qt/widgets/preview/field_containers.py | 5 ++- tagstudio/src/qt/widgets/preview_panel.py | 1 + tagstudio/src/qt/widgets/tag.py | 6 +-- tagstudio/src/qt/widgets/text.py | 5 +-- tagstudio/src/qt/widgets/text_box_edit.py | 3 +- tagstudio/src/qt/widgets/text_line_edit.py | 3 +- 7 files changed, 39 insertions(+), 26 deletions(-) diff --git a/tagstudio/src/qt/widgets/fields.py b/tagstudio/src/qt/widgets/fields.py index 44ce64bbf..f4fd6ae1f 100644 --- a/tagstudio/src/qt/widgets/fields.py +++ b/tagstudio/src/qt/widgets/fields.py @@ -12,6 +12,7 @@ from PySide6.QtCore import QEvent, Qt from PySide6.QtGui import QEnterEvent, QPixmap from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget +from src.core.enums import Theme class FieldContainer(QWidget): @@ -31,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") @@ -47,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) @@ -66,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 = 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,6 +95,7 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None: self.copy_button.setHidden(True) 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,6 +105,7 @@ def __init__(self, title: str = "Field", inline: bool = True) -> None: self.edit_button.setHidden(True) 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) @@ -98,13 +114,15 @@ 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: Callable | None = None): with catch_warnings(record=True): @@ -144,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/preview/field_containers.py b/tagstudio/src/qt/widgets/preview/field_containers.py index 333a6a274..a61d3a0d2 100644 --- a/tagstudio/src/qt/widgets/preview/field_containers.py +++ b/tagstudio/src/qt/widgets/preview/field_containers.py @@ -68,7 +68,8 @@ def __init__(self, library: Library, driver: "QtDriver"): self.scroll_layout = QVBoxLayout() self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - self.scroll_layout.setContentsMargins(6, 1, 6, 6) + self.scroll_layout.setContentsMargins(3, 3, 3, 3) + self.scroll_layout.setSpacing(0) scroll_container: QWidget = QWidget() scroll_container.setObjectName("entryScrollContainer") @@ -77,7 +78,7 @@ def __init__(self, library: Library, driver: "QtDriver"): info_section = QWidget() info_layout = QVBoxLayout(info_section) info_layout.setContentsMargins(0, 0, 0, 0) - info_layout.setSpacing(6) + info_layout.setSpacing(0) self.scroll_area = QScrollArea() self.scroll_area.setObjectName("entryScrollArea") diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index a88768fa5..0ae9c0e58 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -44,6 +44,7 @@ class PreviewPanel(QWidget): f"QPushButton{{" f"background-color:{Theme.COLOR_BG.value};" "border-radius:6px;" + "font-weight: 500;" "text-align: center;" f"}}" f"QPushButton::hover{{" 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/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) From 6461eebb4861c014b9606430f5d3b232cb406ab0 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sun, 5 Jan 2025 19:12:18 -0800 Subject: [PATCH 40/68] refactor!: eradicate use of the term "subtag" - Removes ambiguity between the use of the term "parent tag" and "subtag" - Fixes inconstancies between the use of the term "subtag" to refer to either parent tags or child tags - Fixes duplicate and ambiguous subtags mapped relationship for the Tag model - Does NOT fix tests --- tagstudio/src/core/library/alchemy/joins.py | 4 +- tagstudio/src/core/library/alchemy/library.py | 139 +++++++++--------- tagstudio/src/core/library/alchemy/models.py | 23 +-- .../src/core/library/alchemy/visitors.py | 13 +- tagstudio/src/core/ts_core.py | 2 +- tagstudio/src/qt/modals/build_tag.py | 76 +++++----- tagstudio/src/qt/modals/folders_to_tags.py | 6 +- tagstudio/src/qt/modals/tag_database.py | 4 +- tagstudio/src/qt/ts_qt.py | 6 +- tagstudio/src/qt/widgets/migration_modal.py | 48 +++--- .../qt/widgets/preview/field_containers.py | 8 +- tagstudio/src/qt/widgets/tag_box.py | 4 +- tagstudio/tests/qt/test_build_tag_panel.py | 14 +- tagstudio/tests/test_library.py | 33 ++--- 14 files changed, 178 insertions(+), 202 deletions(-) diff --git a/tagstudio/src/core/library/alchemy/joins.py b/tagstudio/src/core/library/alchemy/joins.py index 998fbdce2..b3c97e359 100644 --- a/tagstudio/src/core/library/alchemy/joins.py +++ b/tagstudio/src/core/library/alchemy/joins.py @@ -8,8 +8,8 @@ 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) diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index d3f2e03ef..26960669c 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -54,7 +54,7 @@ TextField, _FieldID, ) -from .joins import TagEntry, TagSubtag +from .joins import TagEntry, TagParent from .models import Entry, Folder, Preferences, Tag, TagAlias, ValueType from .visitors import SQLBoolExpressionBuilder @@ -85,7 +85,7 @@ def get_default_tags() -> tuple[Tag, ...]: id=TAG_ARCHIVED, name="Archived", aliases={TagAlias(name="Archive")}, - subtags={meta_tag}, + parent_tags={meta_tag}, color=TagColor.RED, ) favorite_tag = Tag( @@ -95,7 +95,7 @@ def get_default_tags() -> tuple[Tag, ...]: TagAlias(name="Favorited"), TagAlias(name="Favorites"), }, - subtags={meta_tag}, + parent_tags={meta_tag}, color=TagColor.YELLOW, ) @@ -192,10 +192,10 @@ def migrate_json_to_sqlite(self, json_lib: JsonLibrary): break 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( @@ -216,8 +216,8 @@ def migrate_json_to_sqlite(self, json_lib: JsonLibrary): if k in {6, 7, 8}: self.add_tags_to_entry(entry_id=entry.id + 1, tag_ids=v) else: - self.add_entry_field_type( - entry_ids=(entry.id + 1), # JSON IDs start at 0 instead of 1 + 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, ) @@ -405,7 +405,7 @@ def get_entry_full( .options( selectinload(Entry.tags).options( joinedload(Tag.aliases), - joinedload(Tag.subtags), + joinedload(Tag.parent_tags), ) ) ) @@ -430,7 +430,7 @@ def get_entries_full(self, entry_ids: list[int] | set[int]) -> Iterator[Entry]: selectinload(Entry.datetime_fields), selectinload(Entry.tags).options( selectinload(Tag.aliases), - selectinload(Tag.subtags), + selectinload(Tag.parent_tags), ), ) statement = statement.distinct() @@ -476,8 +476,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) @@ -577,7 +577,7 @@ def search_library( selectinload(Entry.text_fields), selectinload(Entry.datetime_fields), selectinload(Entry.tags).options( - selectinload(Tag.aliases), selectinload(Tag.subtags) + selectinload(Tag.aliases), selectinload(Tag.parent_tags) ), ) @@ -613,7 +613,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) @@ -641,22 +641,22 @@ 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} + """Recursively traverse a Tag's parent tags and return a list of all children tags.""" + all_parent_ids: set[int] = {tag_id} with Session(self.engine) as session: - tag = session.scalar(select(Tag).where(Tag.id == tag_id)) + tag: Tag | None = 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 + parent_ids = tag.parent_ids - all_subtags.update(subtag_ids) + all_parent_ids.update(parent_ids) - for sub_id in subtag_ids: - all_subtags.update(self.get_all_child_tag_ids(sub_id)) + for child_id in parent_ids: + all_parent_ids.update(self.get_all_child_tag_ids(child_id)) - return list(all_subtags) + return list(all_parent_ids) def update_entry_path(self, entry_id: int | Entry, path: Path) -> None: if isinstance(entry_id, Entry): @@ -679,11 +679,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) + parent_tags = session.scalars( + select(TagParent).where(TagParent.parent_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 +691,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 parent_tag in parent_tags or []: + session.delete(parent_tag) + session.expunge(parent_tag) session.delete(tag) session.commit() @@ -810,17 +810,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, @@ -828,9 +828,6 @@ 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 @@ -853,11 +850,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) @@ -869,7 +864,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 @@ -898,7 +893,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: @@ -907,8 +902,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) @@ -978,12 +973,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) @@ -1006,19 +1003,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: @@ -1045,11 +1042,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() @@ -1058,12 +1055,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() @@ -1079,28 +1076,30 @@ 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): # load given item from Preferences table @@ -1130,8 +1129,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 d59adc9cf..2765e880a 100644 --- a/tagstudio/src/core/library/alchemy/models.py +++ b/tagstudio/src/core/library/alchemy/models.py @@ -17,16 +17,14 @@ FieldTypeEnum, TextField, ) -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") @@ -54,22 +52,15 @@ class Tag(Base): 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]: @@ -86,7 +77,6 @@ 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, @@ -94,7 +84,6 @@ def __init__( 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 diff --git a/tagstudio/src/core/library/alchemy/visitors.py b/tagstudio/src/core/library/alchemy/visitors.py index f593abfac..9ce07fdb1 100644 --- a/tagstudio/src/core/library/alchemy/visitors.py +++ b/tagstudio/src/core/library/alchemy/visitors.py @@ -23,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 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/modals/build_tag.py b/tagstudio/src/qt/modals/build_tag.py index 82b96d4cd..236fc4886 100644 --- a/tagstudio/src/qt/modals/build_tag.py +++ b/tagstudio/src/qt/modals/build_tag.py @@ -118,21 +118,21 @@ def __init__(self, library: Library, tag: Tag | None = None): self.alias_add_button.clicked.connect(self.add_alias_callback) # Parent Tags ---------------------------------------------------------- - 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.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_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) @@ -142,23 +142,23 @@ 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) # Color ---------------------------------------------------------------- self.color_widget = QWidget() @@ -230,13 +230,13 @@ def __init__(self, library: Library, tag: Tag | None = None): 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.parent().done.connect(self.update_tag) - 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 = {} @@ -276,15 +276,15 @@ 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") @@ -305,20 +305,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() @@ -392,9 +392,9 @@ def set_tag(self, tag: Tag): 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()): diff --git a/tagstudio/src/qt/modals/folders_to_tags.py b/tagstudio/src/qt/modals/folders_to_tags.py index 1af62e435..47b7a9b89 100644 --- a/tagstudio/src/qt/modals/folders_to_tags.py +++ b/tagstudio/src/qt/modals/folders_to_tags.py @@ -41,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) @@ -81,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) diff --git a/tagstudio/src/qt/modals/tag_database.py b/tagstudio/src/qt/modals/tag_database.py index c2cdc8f0d..ac0221568 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, ), @@ -169,7 +169,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 0847ee0a2..0bb62a7a0 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -682,7 +682,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), ), @@ -858,7 +858,7 @@ def run_macro(self, name: MacroID, entry_id: int): 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, @@ -867,7 +867,7 @@ def run_macro(self, name: MacroID, entry_id: 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: diff --git a/tagstudio/src/qt/widgets/migration_modal.py b/tagstudio/src/qt/widgets/migration_modal.py index dcc11a954..4f8b2c791 100644 --- a/tagstudio/src/qt/widgets/migration_modal.py +++ b/tagstudio/src/qt/widgets/migration_modal.py @@ -22,7 +22,7 @@ from src.core.constants import TS_FOLDER_NAME from src.core.enums import LibraryPrefs from src.core.library.alchemy.enums import TagColor -from src.core.library.alchemy.joins import TagSubtag +from src.core.library.alchemy.joins import TagParent from src.core.library.alchemy.library import DEFAULT_TAG_DIFF from src.core.library.alchemy.library import Library as SqliteLibrary from src.core.library.alchemy.models import Entry, TagAlias @@ -115,7 +115,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 +129,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 +151,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 +183,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 +192,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 +214,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 +246,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 +257,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) @@ -396,7 +396,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() @@ -639,39 +639,39 @@ 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: if tag.id in range(0, 1000): break 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)) ) - # sql_subtags = sql_subtags.difference([x for x in range(0, 1000)]) + # sql_parent_tags = sql_parent_tags.difference([x for x in range(0, 1000)]) # JSON tags allowed self-parenting; SQL tags no longer allow this. - json_subtags = set(self.json_lib.get_tag(tag_id).subtag_ids) - json_subtags.discard(tag_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][Tag ID: {tag_id}]:" - f"\nOLD (JSON):{json_subtags}\nNEW (SQL):{sql_subtags}" + f"\nOLD (JSON):{json_parent_tags}\nNEW (SQL):{sql_parent_tags}" ) self.subtag_parity = False return self.subtag_parity diff --git a/tagstudio/src/qt/widgets/preview/field_containers.py b/tagstudio/src/qt/widgets/preview/field_containers.py index a61d3a0d2..cf2aa9cfc 100644 --- a/tagstudio/src/qt/widgets/preview/field_containers.py +++ b/tagstudio/src/qt/widgets/preview/field_containers.py @@ -163,7 +163,7 @@ def add_to_cluster(tag_id: int, p_ids: list[int] | None = None): """ tag_obj = self.lib.get_tag(tag_id) # Get full object if p_ids is None: - p_ids = tag_obj.subtag_ids + p_ids = tag_obj.parent_ids for p_id in p_ids: if cluster_map.get(p_id) is None: @@ -172,10 +172,10 @@ def add_to_cluster(tag_id: int, p_ids: list[int] | None = None): 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.subtag_ids: + if p_tag.parent_ids: add_to_cluster( tag_id, - [sub_id for sub_id in p_tag.subtag_ids if sub_id != 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) @@ -240,7 +240,7 @@ def add_field_to_selected(self, field_list: list): ) for entry_id in self.driver.selected: for field_item in field_list: - self.lib.add_entry_field_type( + self.lib.add_field_to_entry( entry_id, field_id=field_item.data(Qt.ItemDataRole.UserRole), ) diff --git a/tagstudio/src/qt/widgets/tag_box.py b/tagstudio/src/qt/widgets/tag_box.py index 5fbaba838..7f576c604 100644 --- a/tagstudio/src/qt/widgets/tag_box.py +++ b/tagstudio/src/qt/widgets/tag_box.py @@ -76,7 +76,7 @@ def edit_tag(self, tag: Tag): self.edit_modal = PanelModal( build_tag_panel, - 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, @@ -85,7 +85,7 @@ def edit_tag(self, tag: 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), ) diff --git a/tagstudio/tests/qt/test_build_tag_panel.py b/tagstudio/tests/qt/test_build_tag_panel.py index d0cdea5b0..87c7d731d 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 @@ -79,14 +79,14 @@ def test_build_tag_panel_set_subtags(library, generate_tag): 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/test_library.py b/tagstudio/tests/test_library.py index 82f9522e8..0b3f84b71 100644 --- a/tagstudio/tests/test_library.py +++ b/tagstudio/tests/test_library.py @@ -18,9 +18,6 @@ def test_library_add_alias(library, generate_tag): 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 alias_ids = library.get_tag(tag.id).alias_ids assert len(alias_ids) == 1 @@ -35,7 +32,6 @@ def test_library_get_alias(library, generate_tag): alias_names: set[str] = set() alias_names.add("test_alias") library.update_tag(tag, subtag_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" @@ -50,9 +46,7 @@ def test_library_update_alias(library, generate_tag): 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 + alias_ids = library.get_tag(tag.id).alias_ids assert library.get_alias(tag.id, alias_ids[0]).name == "test_alias" @@ -61,7 +55,6 @@ def test_library_update_alias(library, generate_tag): library.update_tag(tag, subtag_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) @@ -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): @@ -133,11 +124,8 @@ 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) @@ -168,11 +156,10 @@ def test_add_field_to_entry(library): ) # 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) + library.add_field_to_entry(entry.id, field_id=_FieldID.TAGS) # Then entry = [x for x in library.get_entries(with_joins=True) if x.path == entry.path][0] @@ -195,22 +182,22 @@ def test_add_field_tag(library: Library, entry_full, generate_tag): assert [x.name for x in tag_field.tags if x.name == tag_name] -def test_subtags_add(library, generate_tag): +def test_parents_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("subtag1") + 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): From c1cea164517a39594d5c7875dde5cb4ba303f030 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Mon, 6 Jan 2025 07:14:35 -0800 Subject: [PATCH 41/68] fix: catch and show library load errors --- tagstudio/src/qt/ts_qt.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 0bb62a7a0..989627151 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -1276,7 +1276,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: From 70c7f4f660c7037d41180d81a195ed5c8325f64c Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Mon, 6 Jan 2025 11:17:55 -0800 Subject: [PATCH 42/68] tests: fix and/or remove tests --- tagstudio/src/core/library/alchemy/library.py | 1 + tagstudio/tests/conftest.py | 33 ++-- .../.TagStudio/ts_library.sqlite | Bin 86016 -> 81920 bytes tagstudio/tests/macros/test_sidecar.py | 65 +++---- tagstudio/tests/qt/test_build_tag_panel.py | 2 +- tagstudio/tests/qt/test_preview_panel.py | 172 ++++++++---------- tagstudio/tests/qt/test_qt_driver.py | 106 +++++------ tagstudio/tests/qt/test_tag_panel.py | 4 +- tagstudio/tests/test_library.py | 83 +++------ tagstudio/tests/test_search.py | 36 ++-- 10 files changed, 215 insertions(+), 287 deletions(-) diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index 26960669c..b708fbe7e 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -461,6 +461,7 @@ def get_entries(self, with_joins: bool = False) -> Iterator[Entry]: stmt = stmt.options( contains_eager(Entry.text_fields), contains_eager(Entry.datetime_fields), + contains_eager(Entry.tags), ) stmt = stmt.distinct() diff --git a/tagstudio/tests/conftest.py b/tagstudio/tests/conftest.py index cf8b06f09..dde3c4180 100644 --- a/tagstudio/tests/conftest.py +++ b/tagstudio/tests/conftest.py @@ -46,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 @@ -71,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) - 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 diff --git a/tagstudio/tests/fixtures/search_library/.TagStudio/ts_library.sqlite b/tagstudio/tests/fixtures/search_library/.TagStudio/ts_library.sqlite index 449f380b02fad6ca8fbcc08dddf2ca8d99116aaf..2540a46824a559c73e51578db723590c5321d020 100644 GIT binary patch delta 5727 zcma)=dvF`qb%%Gci@SL2y=OrX0>Os>5Tahzi+VroNFcGIV1Wb;f|8lkWg!xwXn~*v zl5!X)ffd&Sc+yTRp^tGoQ`gOSS|_qog>hRoe$>fK9oJ1`kCQY`Cvlxl(pr*b!?Ii> z4&$EPd*P9$|CB=#-~P_o``UZ%Ig9)L!g=3)&WAdDuS$~Cihs=?@7WF8w!JECI{hWe z{#EiTFG;YL{g^((9$_c-$7$7jNiTW+*1P1s?E0SjY3*KeKy4^5soz;TW$aN7oSARY zg0Yx<|K|DJu|j@6H#MFw&X=b1b2p&BKV!zTW+WT$OPP@y43V`pL+PrWcXri^E=n!S z^yG$!)xu)!q_?&9_nyw$`%a^F8;Ow}eiBT^YERO(&KsH-@4exo^@|B#?M-EU?M^3K zimFsz%8{zN<7BRcP1++8=>0z<5{AfHkDFudg0ro*)BBuXSa?VLV#%e)75AvD1p@*3 zep<`Gqn^3(iQIgCY8FSHqk-DH&~j|E_ksjh;Awaj9)mxGMYszN!$t_P580dSHTG@x z7(2sGuraoWtzmv9)3@m>^f~$neUQ%4F>2C{G)QIrKlLBz-_Re{AJC`uL4A+jt?SNFP4!WdtVfu7F zok|`tLseaq!ZeKC&CV7g1Et(ze>$BSAIQYVLRGJ@9nF7j&K?y9;pDMn*1DK$NHd_K}wn!Tr(N)8;#j`wBK zM~8e>O*Ca&Y^|e8G$~4>E-8^Ko#3}5;+ezz4u91tI*KnGTPPLdqkW^9A%9gB>PW6I zGg};qrv}qQfpS$53q6>d!i|<2j1QPY*|@LjXm*<~l@|NVOlB-tl{Kl0cHvNSr}H?4 zQYwDL#7SiQRY^2Hyiht>$PbTZhEt}$QWolw=~;}gBgu5i%yw12AST>bSjd+n+}4*G zHOILFR6egs0UEFtY5|vN)aLeabOuWKd~qOSn&|ZzP5NECqrvIo{9tkj$9B(655somJ&9XRi-uFvGdP4noa2I+ z<5<2>n7wDrOr_FCqm_A0T0_^kY8O^^l>L=C;gc@qic|S?CO$NPlPZZxW%82|ZpoO5 zac-rRQ<~(bewTz+l%aA`^gKLWJTaUcI^0p2y=hS_oe$Nr`YJ^+;iKi_)ARYGhmu+B za^|L6to_`aU3?5G1<{42+grSHLTqt+pINfK%`BU*a(8os6mkpANM&0q$E_8>upPFKvu2cA`$@uxIG8&yf! zgntX}vM$N*T6!=1W;mAHz4ciB*zS$J6UTBJw;$V@+nC#t-?4FW`|dr*w(i)yJ->b8 zn+JXt9{+#%QA{Z9`Z)=H3EzXS!8y1a(r^p3;8FK1dzckihV5i+^f&b9bct5!-Si+` zMJ4^m`nU9ROTNev-4SW8?JGp(Q0*@K-&QLZ*2%%zKqV|1M0=2gbv2ZI1g~BfR zNIEq-Xdam@EX?Gy=AGH`RC35{Tj#I!&a9VPYQKa3eYMI=SC>J8LG&NQ?Ghivos;|6 zyR{Q)&(CzptoHg$hfHf9&V*#W7AS6zy|upLD%n$;Dz2B^wR6R_?Jg1sbh+dNX0ZgG z^44b-cdn;ftNpOpT}#hCwY!}?A+c-l1|ImIgbOgto?!m~e+_>M55NTIa2i&^i|hsV zE7%9mv7dkmTi}1-HIQ-cr&w86y5#=!;2@qGBkkg1I6Y!!LWa^K4~&{4+3|QLn;glu zQ6(%7kM^aKBZtgP(DKFeCjLpB{|d_#AIcs|r_uvs0gn(uH~*4Tp|&zLDQK4K>P zA1kB7sdT)YFcU47|44c?({K9RN{}y^VT)!aX^wy;;uw?t%%ue6zI6I9ZBf?ocAEoA z=uC-nDH}g*4tYQBXE2>e9!#1Ek2N7{0mpd`PPnl(zpc2e@%N`wsdzS-9$I>~{1(W{ z()lmnX_RlL1qpr)@4;pG1+Lo5@B%ywPr-TkDtrabLKVum!ZR=lpMne|;Q;J{P0$0K z;0F)L>;v{T`xW~c`yqRYeTQ9Sf5ZNY{W<$%_6O{K_E~m{9cSb02pePxwvTOP>sW*Z z8Dmb|t=H%!`UVaCg#Lj36Mcq$i+-K{C4HDaL@V?Qbb%J|@x82h4p+3> zY(P`_R*Piis_LplO{2zedwq4h?OM4QN_z zK|{H&^>A3$R@s22m<1mwA8Oqk8d}r_G)0s$+TO>3ge~cPrJ=2~0c~9tTu11%;5}Tu z4jWKux8NFf7UEFX+H62m(1Lf-c?E~M7O(+LtrpY~{1&{U)U_7HQMTx+=CdWV85X>a z3UH`v%my@33$7yQ7QBU{G!>z&Tt=H> zNtaYZbto>`+752n;lWe+b{do58b;5X@FRE$UWdPfC*XPbGCYjY^8if2DYzF#Flz3C z{di(WVFyr9Ac!&3V86sO>lOCzY>7S2o?&0Yn0bWV!)CB>9b;)W!Fn-b%$;`q>SkT^ zI=xIkqW?y}k3sVddXavY{uw<>AETeginc;W={TLHhiRPd!rC^5u~X5@Zq?zCt=PG} zNs#1((Q~LtFu)1p=O8BxAhSu0gdo-E=Y%oT*Q6_PL8@_p6Nb@#L1pF2Cj_d-ZJaQY zZWW|T`dLdO;$YZY*KxPAl2B;2}5dIla{wyRF;+BszxstF|4+5 z!noSpq~%S5RAVD2jI0e!YTP17HP&;&;9A$D##%wDv4#`I*XkxM_qfZ}@+%G1Sj9z* zu~?HD-GWpj$_b+^(xgUMkZP>tgn`z@2_vmjkZN>r!dPo>QavO{EgNkdG2Vhrs;>~F z8Uaoia;=;&=KM{%+9F6be4H@qj3(7VkZLea7KJ*SuwK#(Un|U2FM|Evk2mH;#S$)U(B^9zO11 zwN+-^*mloN0=BuTR=ylrDqTL;>T$qD3EqW^@H9S$ufxDD2bqw9vMU)mj&Es^PfzCy zlXDv)#}?)z#aZhMjJZvbWF$9(SMw3xWj2{jnLWBpBUjFT zfe-%`coEOui&!B(4v*pqd=?&rGx*iODVT;FjKLjv4#!~+^ujtki9_H6H`Xr=R>wN} z4gAvJ74{-~9xIp&cpg8Bb@f^HAfCmGSi?-S9G=N{uq2D)7Ye=jAgrIIw#bvTOt2h~ zRTd*y4aiD8Mz9!=RbPx?Er5DA!BPPAZi1D7tX%6RSO}oKn_wLvE7zk0%K-F?608Ey zFG{coKzo#64S;%tY_jzT!2$pelL*25kM;<`^pEy(m|*(H8^Z*%KN`XWlfSGihY99> z?68wy>c=F|Nig$c104htKVEkb%=`G+PB87Ge>=gfkL`p=#MVOuQ$E_;2xfd7XpmsS z$Lk=$e2?v}AeioDwafwp(>)3Sg4rHFTL~t697rp{TraEOCz$H-x`kk-#}WGoCVFhp zAeiUz69}ex)PZ1<$7@C~$D=hlsz?>GZO zFuUUoPJ+oDubl*QJ2s>eOzn8B63pzfQpXd-))j(j9sM1|Ve1a3tT^0C-Ae3x=?B)< zdIx@l8>7l5@zHlO8MjRv5cXBpgInq-46*lEdKdf&egXCS>?t+_{{r}4!d_-yf&ao? zcrW}OzKZ}Wvrn-D%uhdnZSZsWE*?)MxE0pp{yL3sZkw2gEwjt)7g!BE%g$qze2$h~ zszb(+#rE2OyJ`*dNa@orl>@{25?j`BmpU9ob+~am{x2C`u!sNv delta 6324 zcma)=dvqJsoySL-(H&_t_ukRVZ^f@XY$uNGBu?T4LS$)To5+@rEGIa-tXGLeVj@ey zu@k~eDTP`MXImQ4vrE}Mg|aQBhjsyM!GO>zaOCd)@s@&Fwtp`i=Ss`g2FS{Eq#E{LC|tHQ6JdyWOW& zHa6Nmzj-FPJDr+I?j65nGL@b<_$m3raWj-K8xo=3sM+wTy`e>O@wSDpII0RaYb}^x zjZaRjVyz0pZhv#h(%KL=x0!KsC~S`KE_P>cw+1S_>Zm9@tOf0B{e@$=u5eOOD#R4V zyFMK(aBsw0_*iMP`Q}$RYHZD&9<{Qy)plQPdD@@$$nJuxLcJq^Ef!-HhG|vdBvR(H zj$MxKgR{HA%NJ^)`~R%ea8PjB~Oxv$mhuf86jIq2U$XF z_yT?v{}4Zczl86^6F7!9;UL!ZGy3cLKk47r|3be*pU`9aCVi=n-B;aL=RFPgxpVHh zy$E81N;9dc16|&{4@s4{R*Gk5GZPVWTWBPla)0U-Ceyr7usAlw%!?+%^mQWVJQ&n6<5xV(H04saSMi zhgp@^kyMZCrEoTzZs?m%9tp={(eb``Xsjyl7OtJi=_&5o8Hx{bmyy>*N5iS)o_)h+ zDBSPOyV#VgrQUQlGm+|z#&Sauvo7yMQZsIrqLX{~%{25*XAfth1AYC8@!oiB=a7+C zML*oZO*MIhq(GNhcF~k$#lXDMUA;5 zVikSUsZ^#fZko{McF|^VGBYzcFqEi^&K>^j-t(qn5bK6R;)dwt6bwEHE1MN78%w3r z*~4RIG#cC4FgJsw6?jGA*pk|uckZBQ5t~kC_NHR-&`=*NWm+sHo|}t*X!uTv8&zQAOODJSZsT5HoL#<@vNkUOlFmh z6duVewc)~xnKqkVIFo6qal;)!r!4~CwMcR%#WVpSsWoYZ_H5Hw(1q&j11?)4)HgCd zXeL5~@MUaBz}JFVoXt+OY+1=2jtwOs)5F=!4CH;ek?K=njDVB(QMwZqV%hI+36lFI z@(TSOJxkZnFVYrrKRHg1(k#81ZYFQjA##PjOukN@r#|`ud71{Plb)d8gfGVj^bPU` z>}!}5zI%_J3+i%^ZOCwJ#EjP&@-kcBs5z1t55*G$BZ*op*V~3id!qv*{bsy|wS{+5 zXcz2QHFJfA68*7gtZ%GJldEkzV$sn-v(h84;F~fYH=|}~#EevwEu+KHSSVsf0<8H+ zY&4z=n|_yE3B3;Wg?!v<#s|z1FEjGzEsN?6Ul%T1q(6sm^WW3Iqu-)`Lm#3K(0l0-dVubwe?~`Xl!hVl zyJ!oorxg@ag?vOVl6T2T@@w+%kSMjNR z0;sx;fk*MGrU0r&7!ZZ3VFp+}tM&?5P(vIPL*2?imlxF=1e{g3Z~)i!4BYZ1^*RA( z)y*8hmD|L~CZAV(I4F|3QGlUtU?4${>jf0mZVqtUIso|-aIFp6gHiJSx96_`knnXbqSY8?mg)iSsYP{ZJT*u82F zV5wqo2?nbaP*f{8fGfb@A~g03D5^dV;PNsk0(cl)kc+Az+jET0tCX9-M;N>Z7A&Bs z>Kwr3W^f)rV{i_Fz$M_k>f`_}mBCrK9SJzCIyit!VQ>cSk_DVr?EpFX4ESu!bXqYq z_zv{rE|x2Qpl?7G`!o8F^gkd=o}kar0-dEfc!MSBPN-=IX%FpyT&blVx{NGRm0Tjf zBEN-9`7wEpyh0u(kCX4hyY5cNlfC3-avQmU3_#7>O&UohSxIEPKrsH}a!8N&@Q?7n zKneUL{yLt=4?u2w74N}Q_%I%WV%Wr+aTnyu!+Or8*zGo!EBz&+eS#ocwv~vQf*@Za zf*@hSC0gjUNHIc!AZNCg=J5G2or z5*625q!`_tSpKXlQE{zBim^rzq)=Ch7CRZ`Z1Oe5=nzIoqt${SkJ?MLxXL2MSSbiH zsjWl{trjUpiy%m*6(w3&ZjoXv69oCRv_y-|t{mHbc|kFngb{M8u|x|E7AZ!(AjqmE zC0Yntq!@LAAhBu%L1xuhq!`tLAh)VYRIIc}$r%*_A-@78D*7!_44)uKF|Q!VF;9uk z8x|=B6$DvEN>s!aDTXcx63s0LGEL)@gQPp}vW$x16vVQvM5mEOis29h38$3kv}}=L z*qyMKE|zkv^y(l{iC&^_(_hhlgOvLbeTqIxzeK-4@1ciihTZ}nrXB1Mxs7h2>*;E` z98QxJlu!gG$}8kNd58QDc^$q^|4g1IC&>56x5!t?edI3qHl^X5`KKg7qL4uE;2i2u zBy3lFXf;0pFXnM~+I7{>LTzj#g{$lg|ZN@#?{EKf!93<$DOFDAD0Y3%A-A@(# zCp*DuxC^;64fE4yj{6^1E*RI&oAgtq3U6w^$^X&?-B>-1icCBovm~o zR6BLlfLiA&ISZA}f0CD=&UqSQ<^aiQbq*$S=< z*J%}HuGIo%uF?EuuGV~IuF|}mb8O&B%~N(%XhxX>8ZEP5BW3n!xXfNnFSAE;m)X#? zGE>b}W}-REj5Rf0zR)#PX1C@jGXzGN9hzKbMYES#)@)_AKYKwjay?{|L>K5qdYb;8 zzDZw$qVy;92lQDuetm;}4US;Ppe((EPQy`bH{C_!^hVkXh3OjFMw@62^-_)6$%jyy zo+iI1Z^AL{=j12k2jp2OPQO9EMvjwX6xVmV;I*UbKuUOk^o&1za~Xl^0MGXh2>> zjZDGb0O|*=XA1o-0rkOk5Y!73tz!z;wV)p8uLjh}0aP=9nO1>PSV<*QuvdUW1%U#f z80>yf9oqRo-O!;IRD){|Q@A!jU7(aHv?riWm?&ln?Q~ETuHB#rR0GW+Xy^iPz)GB; z3aH8yZa|UQ{dO60{}1F<2&R|d=j0Pmy&Wfy zkiR8gB!|gS_yr&XUy=}1a5s|WBnZD;ZX|wUCvJF4}~Q7NC%I7I@ZjfPh*fK=E{0Ksqf~v4aBy)oKeo?E>WDDrb%jUo9`Jta)1zP5Fi)pIY5vtaVj|&9>Ofh zO%P{w93aqYIT%pm2vA5h2MDz)0rI6v4iIb=93a{P93b5M93b9&93bGl0_4jc4iIt% zK+bN1prhObQAY$Q1apA6(>XxkxdkX5_-etVA@W=tAoQF96ryrq!6!iRI5No Items Selected" +# assert not entry.tag_box_fields +# library.add_entries([entry]) +# assert library.entries_count == 3 -@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] +# qt_driver.frame_content = list(library.get_entries()) +# qt_driver.selected = [0, 1, 2] - panel = PreviewPanel(library, qt_driver) - panel.update_widgets() +# panel = PreviewPanel(library, qt_driver) +# panel.update_widgets() - assert panel.preview_img.isVisible() +# 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_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 +# def test_write_container_text_line(qt_driver, entry_full, library): +# # Given +# field_container = PreviewPanel(library, qt_driver).fields - library.add_entries([entry]) - assert library.entries_count == 3 +# 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" - qt_driver.frame_content = list(library.get_entries()) - qt_driver.selected = [0, 1, 2] +# # set any value +# field.value = "foo" +# field_container.write_container(0, field) +# field_container.cached_entries = [library.get_entry_full(entry_full.id)] - panel = PreviewPanel(library, qt_driver) - panel.update_widgets() +# assert len(field_container.containers) == 1 +# container = field_container.containers[0] +# widget = container.get_inner_widget() +# # test it's not "mixed data" +# assert widget.text_label.text() == "foo" - assert {f.type_key for f in panel.common_fields} == { - _FieldID.TITLE.name, - } +# # When update and submit modal +# modal = field_container.fields.containers[0].modal +# modal.widget.text_edit.setText("bar") +# modal.save_button.click() - assert {f.type_key for f in panel.mixed_fields} == { - _FieldID.TAGS.name, - _FieldID.TAGS_META.name, - } +# # 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_write_container_text_line(qt_driver, entry_full, library): - # Given - panel = PreviewPanel(library, qt_driver) +# def test_remove_field(qt_driver, library): +# # Given +# panel = PreviewPanel(library, qt_driver).fields +# entries = list(library.get_entries(with_joins=True)) +# qt_driver.frame_content = entries - 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" +# # When second entry is selected +# panel.selected = [1] - # set any value - field.value = "foo" - panel.write_container(0, field) - panel.selected = [0] +# field = entries[1].text_fields[0] +# panel.write_container(0, field) +# panel.remove_field(field) - 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" +# entries = list(library.get_entries(with_joins=True)) +# assert not entries[1].text_fields - # 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_update_field(qt_driver, library, entry_full): +# panel = PreviewPanel(library, qt_driver).fields +# # select both entries +# qt_driver.frame_content = [e.id for e in library.get_entries()][:2] +# qt_driver.selected = [0, 1] +# panel.selected = [0, 1] -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 +# # update field +# title_field = entry_full.text_fields[0] +# panel.update_field(title_field, "meow") - # When second entry is selected - panel.selected = [1] - - field = entries[1].text_fields[0] - panel.write_container(0, field) - panel.remove_field(field) - - entries = list(library.get_entries(with_joins=True)) - assert not entries[1].text_fields - - -def test_update_field(qt_driver, library, entry_full): - 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") - - 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" +# 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" diff --git a/tagstudio/tests/qt/test_qt_driver.py b/tagstudio/tests/qt/test_qt_driver.py index a8a484d11..0cda8151a 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_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() - # 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.select_item(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.select_item(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.select_item(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.select_item(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.select_item(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/test_library.py b/tagstudio/tests/test_library.py index 0b3f84b71..ad4321fa9 100644 --- a/tagstudio/tests/test_library.py +++ b/tagstudio/tests/test_library.py @@ -13,11 +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) + library.update_tag(tag, parent_ids, alias_names, alias_ids) alias_ids = library.get_tag(tag.id).alias_ids assert len(alias_ids) == 1 @@ -27,11 +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" @@ -41,18 +41,18 @@ 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) + 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 @@ -87,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)) @@ -117,7 +117,7 @@ def test_library_search(library, generate_tag, entry_full): "foo", } - assert entry.tag_box_fields + assert entry.tags def test_tag_search(library): @@ -133,7 +133,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): @@ -147,47 +147,12 @@ def test_entries_count(library): assert len(results) == 5 -def test_add_field_to_entry(library): - # 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_field_to_entry(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_parents_add(library, generate_tag): # Given tag: Tag = library.tags[0] assert tag.id is not None - parent_tag = generate_tag("subtag1") + parent_tag = generate_tag("parent_tag_01") parent_tag = library.add_tag(parent_tag) assert parent_tag.id is not None @@ -273,7 +238,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]) @@ -302,7 +267,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( @@ -344,25 +309,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( @@ -385,8 +346,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), ], From be3d237bdf3781ab97bcd31d771c43a97f21cd1c Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Tue, 7 Jan 2025 00:04:16 -0800 Subject: [PATCH 43/68] suppress db preference warnings --- tagstudio/src/core/library/alchemy/library.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index b708fbe7e..c7a297795 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -12,6 +12,7 @@ from os import makedirs from pathlib import Path from uuid import uuid4 +from warnings import catch_warnings import structlog from humanfriendly import format_timespan @@ -301,12 +302,13 @@ def open_sqlite_library(self, library_dir: Path, is_new: bool) -> LibraryStatus: ) 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: From 91ae1d98a5628323bc2634b711f03e13ed595d5e Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Tue, 7 Jan 2025 00:06:02 -0800 Subject: [PATCH 44/68] tests: add field container tests --- tagstudio/tests/conftest.py | 1 + tagstudio/tests/qt/test_field_containers.py | 115 ++++++++++++++++++ tagstudio/tests/qt/test_preview_panel.py | 123 ++++++-------------- 3 files changed, 149 insertions(+), 90 deletions(-) create mode 100644 tagstudio/tests/qt/test_field_containers.py diff --git a/tagstudio/tests/conftest.py b/tagstudio/tests/conftest.py index dde3c4180..ae386f80c 100644 --- a/tagstudio/tests/conftest.py +++ b/tagstudio/tests/conftest.py @@ -142,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/qt/test_field_containers.py b/tagstudio/tests/qt/test_field_containers.py new file mode 100644 index 000000000..3f15dcd8b --- /dev/null +++ b/tagstudio/tests/qt/test_field_containers.py @@ -0,0 +1,115 @@ +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.select_item(1, append=False, bridge=False) + qt_driver.select_item(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.select_item(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.select_item(1, append=False, bridge=False) + qt_driver.select_item(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.select_item(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.select_item(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.select_item(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 diff --git a/tagstudio/tests/qt/test_preview_panel.py b/tagstudio/tests/qt/test_preview_panel.py index 65333b544..c4ba91c7e 100644 --- a/tagstudio/tests/qt/test_preview_panel.py +++ b/tagstudio/tests/qt/test_preview_panel.py @@ -1,102 +1,45 @@ -# def test_hide_preview_not_selected(qt_driver, library): -# qt_driver.frame_content = list(library.get_entries()) -# qt_driver.selected = [] +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 -# panel = PreviewPanel(library, qt_driver).thumb -# panel.hide_preview() -# assert panel.preview_img.isVisible() +def test_update_selection_empty(qt_driver, library): + panel = PreviewPanel(library, qt_driver) + # Clear the library selection (selecting 1 then unselecting 1) + qt_driver.select_item(1, append=False, bridge=False) + qt_driver.select_item(1, append=True, bridge=False) + panel.update_widgets() -# 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)], -# ) + # Panel should disable UI that allows for entry modification + assert not panel.add_tag_button.isEnabled() + assert not panel.add_field_button.isEnabled() -# assert not entry.tag_box_fields -# library.add_entries([entry]) -# assert library.entries_count == 3 +def test_update_selection_single(qt_driver, library, entry_full): + panel = PreviewPanel(library, qt_driver) -# qt_driver.frame_content = list(library.get_entries()) -# qt_driver.selected = [0, 1, 2] + # Select the single entry + qt_driver.select_item(entry_full.id, append=False, bridge=False) + panel.update_widgets() -# panel = PreviewPanel(library, qt_driver) -# panel.update_widgets() + # Panel should enable UI that allows for entry modification + assert panel.add_tag_button.isEnabled() + assert panel.add_field_button.isEnabled() -# 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_update_selection_multiple(qt_driver, library): + panel = PreviewPanel(library, qt_driver) + # Select the multiple entries + qt_driver.select_item(1, append=False, bridge=False) + qt_driver.select_item(2, append=True, bridge=False) + panel.update_widgets() -# def test_write_container_text_line(qt_driver, entry_full, library): -# # Given -# field_container = PreviewPanel(library, qt_driver).fields - -# 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" -# field_container.write_container(0, field) -# field_container.cached_entries = [library.get_entry_full(entry_full.id)] - -# assert len(field_container.containers) == 1 -# container = field_container.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 = field_container.fields.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).fields -# 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) - -# entries = list(library.get_entries(with_joins=True)) -# assert not entries[1].text_fields - - -# def test_update_field(qt_driver, library, entry_full): -# panel = PreviewPanel(library, qt_driver).fields - -# # select both entries -# qt_driver.frame_content = [e.id for e in 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") - -# 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() From ec80f24b8ddaa6cbc1736327cabf545884c0f05b Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Tue, 7 Jan 2025 01:51:51 -0800 Subject: [PATCH 45/68] tests: add tag category tests --- tagstudio/tests/qt/test_field_containers.py | 57 +++++++++++++++++++++ tagstudio/tests/qt/test_preview_panel.py | 6 --- 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/tagstudio/tests/qt/test_field_containers.py b/tagstudio/tests/qt/test_field_containers.py index 3f15dcd8b..fb2cd9ba3 100644 --- a/tagstudio/tests/qt/test_field_containers.py +++ b/tagstudio/tests/qt/test_field_containers.py @@ -113,3 +113,60 @@ def test_add_tag_to_selection_multiple(qt_driver, library): 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.select_item(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.select_item(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 c4ba91c7e..569fb80d9 100644 --- a/tagstudio/tests/qt/test_preview_panel.py +++ b/tagstudio/tests/qt/test_preview_panel.py @@ -1,9 +1,3 @@ -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 From 031229132f2c00d0671df7540f1f18ed4de85d31 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Tue, 7 Jan 2025 04:33:30 -0800 Subject: [PATCH 46/68] refactor(ui): move recent libraries list to file menu --- tagstudio/resources/translations/en.json | 2 + tagstudio/src/qt/ts_qt.py | 91 ++++++++++-- .../qt/widgets/preview/recent_libraries.py | 136 ------------------ 3 files changed, 82 insertions(+), 147 deletions(-) delete mode 100644 tagstudio/src/qt/widgets/preview/recent_libraries.py diff --git a/tagstudio/resources/translations/en.json b/tagstudio/resources/translations/en.json index e9247b71e..c68e65767 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/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 989627151..204d5508e 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -289,6 +289,27 @@ 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" + ) + # open_recent_library_menu.set + 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( @@ -329,17 +350,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") @@ -1264,6 +1274,65 @@ 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(): + logger.info(action) + self.open_recent_library_menu.removeAction(action) + + # Add new actions + for action in actions: + logger.info(action.text()) + 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.""" diff --git a/tagstudio/src/qt/widgets/preview/recent_libraries.py b/tagstudio/src/qt/widgets/preview/recent_libraries.py deleted file mode 100644 index b2f806172..000000000 --- a/tagstudio/src/qt/widgets/preview/recent_libraries.py +++ /dev/null @@ -1,136 +0,0 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio - -import typing - -import structlog -from PySide6.QtWidgets import ( - QWidget, -) -from src.core.library.alchemy.library import Library - -if typing.TYPE_CHECKING: - from src.qt.ts_qt import QtDriver - -logger = structlog.get_logger(__name__) - - -class RecentLibraries(QWidget): - """The Recent Libraries Widget.""" - - def __init__(self, library: Library, driver: "QtDriver"): - super().__init__() - - # # keep list of rendered libraries to avoid needless re-rendering - # self.render_libs: set = set() - # self.library = library - # self.driver = driver - # layout = QVBoxLayout() - - # settings = 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.setLayout(layout) - - # 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_FORBIDDEN_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)) - -# 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;"]) - -# 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) From 970648343255ab2c3c8641422734937a8fb05a71 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Tue, 7 Jan 2025 06:27:28 -0800 Subject: [PATCH 47/68] docs: update roadmap and docs for tag categories --- docs/library/tag_categories.md | 13 ++++++++++--- docs/updates/roadmap.md | 8 ++++---- 2 files changed, 14 insertions(+), 7 deletions(-) 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 7a8e25578..0dbae4a61 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] @@ -169,8 +169,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] From 1fc584d750af8ef5da3ad93cbb21e57581e37f41 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Tue, 7 Jan 2025 07:35:55 -0800 Subject: [PATCH 48/68] fix: restore json migration functionality --- tagstudio/src/core/constants.py | 4 + tagstudio/src/core/library/alchemy/library.py | 3 +- tagstudio/src/qt/widgets/migration_modal.py | 202 +++++++----------- 3 files changed, 84 insertions(+), 125 deletions(-) diff --git a/tagstudio/src/core/constants.py b/tagstudio/src/core/constants.py index a4c8aa18f..0525e2a22 100644 --- a/tagstudio/src/core/constants.py +++ b/tagstudio/src/core/constants.py @@ -15,6 +15,10 @@ ) FONT_SAMPLE_SIZES: list[int] = [10, 15, 20] +# 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 diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index c7a297795..eeed607f9 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -41,6 +41,7 @@ from ...constants import ( BACKUP_FOLDER_NAME, + LEGACY_TAG_FIELD_IDS, TAG_ARCHIVED, TAG_FAVORITE, TAG_META, @@ -214,7 +215,7 @@ def migrate_json_to_sqlite(self, json_lib: JsonLibrary): for field in entry.fields: for k, v in field.items(): # Old tag fields get added as tags - if k in {6, 7, 8}: + 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( diff --git a/tagstudio/src/qt/widgets/migration_modal.py b/tagstudio/src/qt/widgets/migration_modal.py index 4f8b2c791..2a4896fdb 100644 --- a/tagstudio/src/qt/widgets/migration_modal.py +++ b/tagstudio/src/qt/widgets/migration_modal.py @@ -19,7 +19,7 @@ ) 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, RESERVED_TAG_END, TAG_META, TS_FOLDER_NAME from src.core.enums import LibraryPrefs from src.core.library.alchemy.enums import TagColor from src.core.library.alchemy.joins import TagParent @@ -283,7 +283,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") @@ -350,6 +349,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) @@ -380,16 +381,20 @@ 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__}" - self.done = True + QApplication.beep() + QApplication.alert(self.paged_panel) + self.done = True def update_parity_ui(self): """Update all parity values UI.""" @@ -427,7 +432,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() @@ -496,27 +501,9 @@ 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, - # ) - # ) - # ) - # ) - # - # return set(tags) if tags else None - # else: + """Check if all JSON field and tag data matches the new SQL data.""" + + def sanitize_field(entry: Entry, value, type, type_key): return value if value else None def sanitize_json_field(value): @@ -525,105 +512,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 - - for sf in sql_entry.fields: - if sf.type.type.value not in {6, 7, 8}: - sql_fields.append( - ( - sql_entry.id, - sf.type.key, - sanitize_field( - session, sql_entry, sf.value, sf.type.type, sf.type_key - ), - ) + 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: + if sf.type.type.value not in LEGACY_TAG_FIELD_IDS: + sql_fields.append( + ( + sql_entry.id, + 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: - int_key: int = list(jf.keys())[0] - value = sanitize_json_field(list(jf.values())[0]) - if int_key in {6, 7, 8}: - tags_count += 1 - merged_tags = merged_tags.union(value or []) - pass - else: - key: str = self.sql_lib.get_field_name_from_id(int_key).name - json_fields.append((json_entry.id + 1, key, value)) - - # TODO: DO NOT IGNORE TAGS - # 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, - # "TAGS", - # merged_tags if merged_tags else None, - # ) - # ) - json_fields.sort() - - 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 + 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} - 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 @@ -645,13 +595,13 @@ def check_subtag_parity(self) -> bool: with Session(self.sql_lib.engine) as session: for tag in self.sql_lib.tags: - if tag.id in range(0, 1000): + # NOTE: Don't check subtag parity for built-in tags. + if tag.id in range(0, RESERVED_TAG_END + 1): break tag_id = tag.id # Tag IDs start at 0 sql_parent_tags = set( session.scalars(select(TagParent.child_id).where(TagParent.parent_id == tag.id)) ) - # sql_parent_tags = sql_parent_tags.difference([x for x in range(0, 1000)]) # JSON tags allowed self-parenting; SQL tags no longer allow this. json_parent_tags = set(self.json_lib.get_tag(tag_id).subtag_ids) @@ -689,7 +639,9 @@ def check_alias_parity(self) -> bool: with Session(self.sql_lib.engine) as session: for tag in self.sql_lib.tags: - if tag.id in range(0, 1000): + # NOTE: Do not check alias parity for built-in tags added + # after "Favorite" and "Archived". + if tag.id in range(TAG_META, RESERVED_TAG_END + 1): break tag_id = tag.id # Tag IDs start at 0 sql_aliases = set( @@ -724,7 +676,8 @@ def check_shorthand_parity(self) -> bool: json_shorthand: str = None for tag in self.sql_lib.tags: - if tag.id in range(0, 1000): + # NOTE: Don't check shorthand parity for built-in tags. + if tag.id in range(0, RESERVED_TAG_END + 1): break tag_id = tag.id # Tag IDs start at 0 sql_shorthand = tag.shorthand @@ -758,7 +711,8 @@ def check_color_parity(self) -> bool: json_color: str = None for tag in self.sql_lib.tags: - if tag.id in range(0, 1000): + # NOTE: Don't check tag color parity for built-in tags. + if tag.id in range(0, RESERVED_TAG_END + 1): break tag_id = tag.id # Tag IDs start at 0 sql_color = tag.color.name From c2ab38cbf6061834fef28c1090bb4a17cd3c6b0b Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Tue, 7 Jan 2025 07:43:02 -0800 Subject: [PATCH 49/68] logs: remove/update debug logs --- tagstudio/src/qt/ts_qt.py | 2 -- .../qt/widgets/preview/field_containers.py | 22 +++++++++---------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 204d5508e..22c88c1ca 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -1309,12 +1309,10 @@ def update_recent_lib_menu(self): # Clear previous actions for action in self.open_recent_library_menu.actions(): - logger.info(action) self.open_recent_library_menu.removeAction(action) # Add new actions for action in actions: - logger.info(action.text()) self.open_recent_library_menu.addAction(action) # Only enable add "clear recent" if there are still recent libraries. diff --git a/tagstudio/src/qt/widgets/preview/field_containers.py b/tagstudio/src/qt/widgets/preview/field_containers.py index cf2aa9cfc..e2108b1d6 100644 --- a/tagstudio/src/qt/widgets/preview/field_containers.py +++ b/tagstudio/src/qt/widgets/preview/field_containers.py @@ -183,23 +183,25 @@ def add_to_cluster(tag_id: int, p_ids: list[int] | None = None): for tag in tags: add_to_cluster(tag.id) - logger.info("Entry Cluster", entry_cluster=exhausted) - logger.info("Cluster Map", cluster_map=cluster_map) + 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("Blank Tag Categories", cats=cats) + 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("Checking category tag key", key=key) + logger.info("[FieldContainers] Checking category tag key", key=key) if key: - logger.info("Key cluster:", key=key, cluster=cluster_map.get(key.id)) + 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} @@ -208,7 +210,7 @@ def add_to_cluster(tag_id: int, p_ids: list[int] | None = None): # 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"[{key}] Key cluster: None, general case!", + f"[FieldContainers] [{key}] Key cluster: None, general case!", general_tags=cats[key], added=added_ids, base_tag_ids=base_tag_ids, @@ -222,7 +224,7 @@ def add_to_cluster(tag_id: int, p_ids: list[int] | None = None): for key in empty: cats.pop(key, None) - logger.info("Tag Categories", cats=cats) + logger.info("[FieldContainers] Tag Categories", cats=cats) return cats def remove_field_prompt(self, name: str) -> str: @@ -272,7 +274,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): If True, field is not present in all selected items. """ - logger.info("[write_field_container]", index=index) + logger.info("[FieldContainers][write_field_container]", index=index) if len(self.containers) < (index + 1): container = FieldContainer() self.containers.append(container) @@ -420,7 +422,7 @@ def write_tag_container( If True, field is not present in all selected items. """ - logger.info("[write_tag_container]", index=index) + logger.info("[FieldContainers][write_tag_container]", index=index) if len(self.containers) < (index + 1): container = FieldContainer() self.containers.append(container) @@ -435,13 +437,11 @@ def write_tag_container( inner_widget = container.get_inner_widget() if isinstance(inner_widget, TagBoxWidget): - logger.warning("recycling") inner_widget.set_tags(tags) with catch_warnings(record=True): inner_widget.updated.disconnect() else: - logger.warning("creating new") inner_widget = TagBoxWidget( tags, "Tags", From 7e1978d8e39227960cd3d6d0be8f374e3436b823 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Tue, 7 Jan 2025 08:21:12 -0800 Subject: [PATCH 50/68] chore: remove unused code --- tagstudio/src/qt/modals/build_tag.py | 5 ----- tagstudio/src/qt/ts_qt.py | 26 ----------------------- tagstudio/src/qt/widgets/preview_panel.py | 1 - 3 files changed, 32 deletions(-) diff --git a/tagstudio/src/qt/modals/build_tag.py b/tagstudio/src/qt/modals/build_tag.py index 236fc4886..1bea954ad 100644 --- a/tagstudio/src/qt/modals/build_tag.py +++ b/tagstudio/src/qt/modals/build_tag.py @@ -206,14 +206,10 @@ def __init__(self, library: Library, tag: Tag | None = None): "QCheckBox::indicator{" "width: 19px; height: 19px;" # 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: 20p+x;" "}" # f"QCheckBox::indicator::hover" # f"{{" @@ -234,7 +230,6 @@ def __init__(self, library: Library, tag: Tag | None = None): self.root_layout.addWidget(self.color_widget) self.root_layout.addWidget(QLabel("

Properties

")) self.root_layout.addWidget(self.cat_widget) - # self.parent().done.connect(self.update_tag) self.parent_ids: set[int] = set() self.alias_ids: list[int] = [] diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 22c88c1ca..2840d69b3 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -293,7 +293,6 @@ def start(self) -> None: Translations.translate_qobject( self.open_recent_library_menu, "menu.file.open_recent_library" ) - # open_recent_library_menu.set file_menu.addMenu(self.open_recent_library_menu) self.update_recent_lib_menu() @@ -405,13 +404,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") @@ -593,15 +585,6 @@ def init_library_window(self): self.splash.finish(self.main_window) self.preview_panel.update_widgets() - def toggle_libs_list(self, value: bool): - # TODO: Reimplement or remove - # if value: - # self.preview_panel.libs_flow_container.show() - # else: - # self.preview_panel.libs_flow_container.hide() - # self.preview_panel.update() - pass - def show_grid_filenames(self, value: bool): for thumb in self.item_thumbs: thumb.set_filename_visibility(value) @@ -823,21 +806,12 @@ 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, entry_ids: list[int]): """Run a specific Macro on a group of given entry_ids.""" for entry_id in entry_ids: diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index 0ae9c0e58..00ba259ea 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -126,7 +126,6 @@ def __init__(self, library: Library, driver: "QtDriver"): splitter.addWidget(preview_section) splitter.addWidget(info_section) - # splitter.addWidget(self.libs_flow_container) # TODO: Determine fate of this; Move to menu splitter.setStretchFactor(1, 2) root_layout = QVBoxLayout(self) From 5207f28e2bbfa7f8574ad7596c2a8d3cb523b796 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Tue, 7 Jan 2025 08:26:11 -0800 Subject: [PATCH 51/68] tests: remove tests related to `TagBoxWidget` --- tagstudio/tests/qt/test_tag_widget.py | 110 -------------------------- 1 file changed, 110 deletions(-) delete mode 100644 tagstudio/tests/qt/test_tag_widget.py diff --git a/tagstudio/tests/qt/test_tag_widget.py b/tagstudio/tests/qt/test_tag_widget.py deleted file mode 100644 index 2e402f50a..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 From c8bbfe5cb43fc0d4b1e783877a0e8c026cda98c2 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Wed, 8 Jan 2025 10:14:38 -0800 Subject: [PATCH 52/68] ui: optimize selection and badge updates --- tagstudio/src/qt/ts_qt.py | 119 +++++++++++------- tagstudio/src/qt/widgets/item_thumb.py | 28 +---- .../qt/widgets/preview/field_containers.py | 65 ++++++---- .../src/qt/widgets/preview/file_attributes.py | 4 +- .../src/qt/widgets/preview/preview_thumb.py | 4 +- tagstudio/src/qt/widgets/preview_panel.py | 19 ++- tagstudio/src/qt/widgets/tag_box.py | 4 - 7 files changed, 129 insertions(+), 114 deletions(-) diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 2840d69b3..1669844ce 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -15,7 +15,6 @@ import sys import time import webbrowser -from collections.abc import Sequence from pathlib import Path from queue import Queue @@ -52,6 +51,8 @@ QWidget, ) from src.core.constants import ( + TAG_ARCHIVED, + TAG_FAVORITE, VERSION, VERSION_BRANCH, ) @@ -89,6 +90,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 @@ -485,6 +492,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) @@ -919,6 +937,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): @@ -957,6 +977,7 @@ def _init_thumb_grid(self): def select_item(self, item_id: int, append: bool, bridge: bool): """Select one or more items in the Thumbnail Grid.""" 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) @@ -969,40 +990,45 @@ def select_item(self, item_id: int, append: bool, bridge: bool): if it.item_id == item_id: it.thumb_button.set_selected(False) + # TODO: Allow bridge selecting across pages. elif bridge and self.selected: - 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) + 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, + ) else: self.selected.clear() self.selected.append(item_id) - for it in self.item_thumbs: - if it.item_id == item_id: - it.thumb_button.set_selected(True) - else: - it.thumb_button.set_selected(False) - - # 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 == item_id: - self.preview_panel.fields.set_tags_updated_slot(it.refresh_badge) + 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() @@ -1113,10 +1139,9 @@ def update_thumbs(self): continue if not entry: continue + item_thumb.set_mode(ItemType.ENTRY) item_thumb.set_item_id(entry.id) - - # TODO - show after item is rendered item_thumb.show() is_loading = True self.thumb_job_queue.put( @@ -1162,18 +1187,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) - def update_badges(self, item_ids: Sequence[int] = None): - if not item_ids: - # no items passed, update all items in grid - item_ids = range(min(len(self.item_thumbs), len(self.frame_content))) + def update_badges(self, badge_values: dict[BadgeType, bool], origin_id: int, add_tags=True): + """Update the tag badges for item_thumbs. + + 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] - item_ids_ = set(item_ids) for it in self.item_thumbs: - if it.item_id in item_ids_: - it.refresh_badge() + 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: @@ -1189,13 +1224,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 diff --git a/tagstudio/src/qt/widgets/item_thumb.py b/tagstudio/src/qt/widgets/item_thumb.py index 5594eb5e8..a3bdb2938 100644 --- a/tagstudio/src/qt/widgets/item_thumb.py +++ b/tagstudio/src/qt/widgets/item_thumb.py @@ -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 @@ -437,18 +438,10 @@ 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_id: int | None = None): - entry = self.lib.get_entry_full(self.item_id) - if not entry: - return - self.assign_badge(BadgeType.ARCHIVED, entry.is_archived) - self.assign_badge(BadgeType.FAVORITE, entry.is_favorite) def set_item_id(self, item_id: int): self.item_id = item_id @@ -489,20 +482,9 @@ 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.item_id in self.driver.selected: - items_to_update = self.driver.selected - else: - items_to_update = [self.item_id] - - for item_id in items_to_update: - self.toggle_item_tag(item_id, toggle_value, tag_id) - - self.driver.update_badges(items_to_update) + badge_values: dict[BadgeType, bool] = {badge_type: toggle_value} + self.driver.update_badges(badge_values, self.item_id) def toggle_item_tag( self, diff --git a/tagstudio/src/qt/widgets/preview/field_containers.py b/tagstudio/src/qt/widgets/preview/field_containers.py index e2108b1d6..bb8b24ede 100644 --- a/tagstudio/src/qt/widgets/preview/field_containers.py +++ b/tagstudio/src/qt/widgets/preview/field_containers.py @@ -20,6 +20,10 @@ 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, @@ -46,7 +50,8 @@ class FieldContainers(QWidget): """The Preview Panel Widget.""" - tags_updated = Signal() + favorite_updated = Signal(bool) + archived_updated = Signal(bool) def __init__(self, library: Library, driver: "QtDriver"): super().__init__() @@ -104,16 +109,11 @@ def __init__(self, library: Library, driver: "QtDriver"): root_layout.setContentsMargins(0, 0, 0, 0) root_layout.addWidget(self.scroll_area) - def update_from_entry(self, entry: Entry): + def update_from_entry(self, entry_id: int, update_badges: bool = True): """Update tags and fields from a single Entry source.""" - logger.info( - "[FieldContainers] Updating Selection", - path=entry.path, - fields=entry.fields, - tags=entry.tags, - ) + logger.warning("[FieldContainers] Updating Selection", entry_id=entry_id) - self.cached_entries = [self.lib.get_entry_full(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 @@ -127,7 +127,9 @@ def update_from_entry(self, entry: Entry): ) container_index += 1 container_len += 1 - self.tags_updated.emit() + 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) @@ -224,7 +226,7 @@ def add_to_cluster(tag_id: int, p_ids: list[int] | None = None): for key in empty: cats.pop(key, None) - logger.info("[FieldContainers] Tag Categories", cats=cats) + logger.info("[FieldContainers] Tag Categories", categories=cats) return cats def remove_field_prompt(self, name: str) -> str: @@ -247,11 +249,13 @@ def add_field_to_selected(self, field_list: list): field_id=field_item.data(Qt.ItemDataRole.UserRole), ) - def add_tags_to_selected(self, tags: list[int]): + 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, @@ -262,7 +266,7 @@ def add_tags_to_selected(self, tags: list[int]): entry_id, tag_ids=tags, ) - self.tags_updated.emit() + 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. @@ -304,7 +308,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): save_callback=( lambda content: ( self.update_field(field, content), - self.update_from_entry(self.cached_entries[0]), + self.update_from_entry(self.cached_entries[0].id), ) ), ) @@ -318,7 +322,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): prompt=self.remove_field_prompt(field.type.type.value), callback=lambda: ( self.remove_field(field), - self.update_from_entry(self.cached_entries[0]), + self.update_from_entry(self.cached_entries[0].id), ), ) ) @@ -343,7 +347,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): save_callback=( lambda content: ( self.update_field(field, content), - self.update_from_entry(self.cached_entries[0]), + self.update_from_entry(self.cached_entries[0].id), ) ), ) @@ -353,7 +357,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): prompt=self.remove_field_prompt(field.type.name), callback=lambda: ( self.remove_field(field), - self.update_from_entry(self.cached_entries[0]), + self.update_from_entry(self.cached_entries[0].id), ), ) ) @@ -381,7 +385,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): prompt=self.remove_field_prompt(field.type.name), callback=lambda: ( self.remove_field(field), - self.update_from_entry(self.cached_entries[0]), + self.update_from_entry(self.cached_entries[0].id), ), ) ) @@ -402,7 +406,7 @@ def write_container(self, index: int, field: BaseField, is_mixed: bool = False): prompt=self.remove_field_prompt(field.type.name), callback=lambda: ( self.remove_field(field), - self.update_from_entry(self.cached_entries[0]), + self.update_from_entry(self.cached_entries[0].id), ), ) ) @@ -449,7 +453,9 @@ def write_tag_container( ) container.set_inner_widget(inner_widget) - inner_widget.updated.connect(lambda: (self.update_from_entry(self.cached_entries[0]))) + 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) @@ -500,10 +506,15 @@ def remove_message_box(self, prompt: str, callback: Callable) -> None: if result == 3: # TODO - what is this magic number? callback() - def set_tags_updated_slot(self, slot: object): - """Replacement for tag_callback.""" - with catch_warnings(record=True): - self.tags_updated.disconnect() - - logger.info("[FieldContainers][set_tags_updated_slot] Setting tags updated slot") - self.tags_updated.connect(slot) + 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 index af493bd52..fa1a6c7e5 100644 --- a/tagstudio/src/qt/widgets/preview/file_attributes.py +++ b/tagstudio/src/qt/widgets/preview/file_attributes.py @@ -12,7 +12,7 @@ import structlog from humanfriendly import format_size from PIL import ImageFont -from PySide6.QtCore import Qt, Signal +from PySide6.QtCore import Qt from PySide6.QtGui import QGuiApplication from PySide6.QtWidgets import ( QLabel, @@ -33,8 +33,6 @@ class FileAttributes(QWidget): """The Preview Panel Widget.""" - tags_updated = Signal() - def __init__(self, library: Library, driver: "QtDriver"): super().__init__() root_layout = QVBoxLayout(self) diff --git a/tagstudio/src/qt/widgets/preview/preview_thumb.py b/tagstudio/src/qt/widgets/preview/preview_thumb.py index baff35ed8..d73f81f33 100644 --- a/tagstudio/src/qt/widgets/preview/preview_thumb.py +++ b/tagstudio/src/qt/widgets/preview/preview_thumb.py @@ -11,7 +11,7 @@ import rawpy import structlog from PIL import Image, UnidentifiedImageError -from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt, Signal +from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt from PySide6.QtGui import QAction, QMovie, QResizeEvent from PySide6.QtWidgets import ( QHBoxLayout, @@ -39,8 +39,6 @@ class PreviewThumb(QWidget): """The Preview Panel Widget.""" - tags_updated = Signal() - def __init__(self, library: Library, driver: "QtDriver"): super().__init__() diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index 00ba259ea..62cf0bd24 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -8,7 +8,7 @@ from warnings import catch_warnings import structlog -from PySide6.QtCore import Qt, Signal +from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QHBoxLayout, QPushButton, @@ -37,8 +37,6 @@ class PreviewPanel(QWidget): """The Preview Panel Widget.""" - tags_updated = Signal() - # TODO: There should be a global button theme somewhere. button_style = ( f"QPushButton{{" @@ -149,15 +147,16 @@ def update_widgets(self) -> bool: # 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() 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) - self.update_add_tag_button(entry) - self.update_add_field_button(entry) + 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) @@ -181,7 +180,7 @@ def update_widgets(self) -> bool: traceback.print_exc() return False - def update_add_field_button(self, entry: Entry | None = None): + def update_add_field_button(self, entry_id: int | None = None): with catch_warnings(record=True): self.add_field_modal.done.disconnect() self.add_field_button.clicked.disconnect() @@ -189,12 +188,12 @@ def update_add_field_button(self, entry: Entry | None = None): self.add_field_modal.done.connect( lambda f: ( self.fields.add_field_to_selected(f), - (self.fields.update_from_entry(entry) if entry else ()), + (self.fields.update_from_entry(entry_id) if entry_id else ()), ) ) self.add_field_button.clicked.connect(self.add_field_modal.show) - def update_add_tag_button(self, entry: Entry = None): + 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() @@ -202,7 +201,7 @@ def update_add_tag_button(self, entry: Entry = None): self.add_tag_modal.widget.tag_chosen.connect( lambda t: ( self.fields.add_tags_to_selected(t), - (self.fields.update_from_entry(entry) if entry else ()), + (self.fields.update_from_entry(entry_id) if entry_id else ()), ) ) diff --git a/tagstudio/src/qt/widgets/tag_box.py b/tagstudio/src/qt/widgets/tag_box.py index 7f576c604..82d664ecb 100644 --- a/tagstudio/src/qt/widgets/tag_box.py +++ b/tagstudio/src/qt/widgets/tag_box.py @@ -7,7 +7,6 @@ import structlog from PySide6.QtCore import Signal -from src.core.constants import TAG_ARCHIVED, TAG_FAVORITE from src.core.library import Tag from src.core.library.alchemy.enums import FilterState from src.qt.flowlayout import FlowLayout @@ -98,9 +97,6 @@ def remove_tag(self, tag_id: int): selected=self.driver.selected, ) - if tag_id in (TAG_FAVORITE, TAG_ARCHIVED): - self.driver.update_badges(self.driver.selected) - for entry_id in self.driver.selected: self.driver.lib.remove_tags_from_entry(entry_id, tag_id) From 389d0fb83199aac4ba4ef4d2c1cd60fb628f7891 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Thu, 9 Jan 2025 00:34:49 -0800 Subject: [PATCH 53/68] docs: update usage --- README.md | 18 ++++++++++-------- docs/usage.md | 17 +++++++++++------ 2 files changed, 21 insertions(+), 14 deletions(-) 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/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. From 6d2c1ec7f577b713bb68eed8dac697b3d9e66294 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 10 Jan 2025 08:21:10 -0800 Subject: [PATCH 54/68] fix: change typo of `tag.id` to `tag_id` Co-authored-by: Jann Stute <46534683+Computerdores@users.noreply.github.com> --- tagstudio/src/qt/widgets/preview/field_containers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tagstudio/src/qt/widgets/preview/field_containers.py b/tagstudio/src/qt/widgets/preview/field_containers.py index bb8b24ede..ae779d620 100644 --- a/tagstudio/src/qt/widgets/preview/field_containers.py +++ b/tagstudio/src/qt/widgets/preview/field_containers.py @@ -171,8 +171,8 @@ def add_to_cluster(tag_id: int, p_ids: list[int] | None = None): 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) + 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( From 17e0522e45b732d19e9f68cecab901cb64987f2e Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 10 Jan 2025 08:21:48 -0800 Subject: [PATCH 55/68] fix: use term "child tags" instead of "subtags" in docstring Co-authored-by: Jann Stute <46534683+Computerdores@users.noreply.github.com> --- tagstudio/src/qt/widgets/preview/field_containers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tagstudio/src/qt/widgets/preview/field_containers.py b/tagstudio/src/qt/widgets/preview/field_containers.py index ae779d620..5c1cde83e 100644 --- a/tagstudio/src/qt/widgets/preview/field_containers.py +++ b/tagstudio/src/qt/widgets/preview/field_containers.py @@ -155,7 +155,7 @@ def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[Tag]]: cluster_map: dict[int, set[int]] = {} def add_to_cluster(tag_id: int, p_ids: list[int] | None = None): - """Maps a Tag's subtag's ID's back to it's parent Tag's ID. + """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: From 77d957fd337bf325dbed173f543ebaad6ea885e0 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 10 Jan 2025 10:25:21 -0800 Subject: [PATCH 56/68] fix: reference `child_id` instead of `parent_id` when deleting tags Co-Authored-By: Jann Stute <46534683+Computerdores@users.noreply.github.com> --- tagstudio/src/core/library/alchemy/library.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index eeed607f9..717a8f9ad 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -683,8 +683,8 @@ 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: - parent_tags = session.scalars( - select(TagParent).where(TagParent.parent_id == tag.id) + child_tags = session.scalars( + select(TagParent).where(TagParent.child_id == tag.id) ).all() tags_query = select(Tag).options( selectinload(Tag.parent_tags), selectinload(Tag.aliases) @@ -695,9 +695,9 @@ def remove_tag(self, tag: Tag): for alias in aliases or []: session.delete(alias) - for parent_tag in parent_tags or []: - session.delete(parent_tag) - session.expunge(parent_tag) + for child_tag in child_tags or []: + session.delete(child_tag) + session.expunge(child_tag) session.delete(tag) session.commit() From 5e1e4c57a73dfaef7f6ae9ff233376b12a775704 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Fri, 10 Jan 2025 10:34:26 -0800 Subject: [PATCH 57/68] add TODO comment for `update_thumbs()` optimization --- tagstudio/src/qt/ts_qt.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 1669844ce..c67856c1c 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -1126,6 +1126,8 @@ def update_thumbs(self): is_grid_thumb = True 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] From 114bc0437ee7bb72390bf90b2489d6f9145a562c Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 11 Jan 2025 03:25:33 -0800 Subject: [PATCH 58/68] fix: combine and check (most) built-in tag data from JSON Known issue: Tag colors from built-in JSON tags are not updated. This can be seen in the failing test. --- tagstudio/src/core/library/alchemy/library.py | 48 +++++++++------- tagstudio/src/qt/modals/build_tag.py | 1 + tagstudio/src/qt/modals/tag_database.py | 5 +- tagstudio/src/qt/widgets/migration_modal.py | 57 +++++++++++-------- 4 files changed, 64 insertions(+), 47 deletions(-) diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index 717a8f9ad..b51d5523c 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -42,6 +42,8 @@ from ...constants import ( BACKUP_FOLDER_NAME, LEGACY_TAG_FIELD_IDS, + RESERVED_TAG_END, + RESERVED_TAG_START, TAG_ARCHIVED, TAG_FAVORITE, TAG_META, @@ -105,7 +107,7 @@ def get_default_tags() -> tuple[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()) - 2 +DEFAULT_TAG_DIFF: int = len(get_default_tags()) - len([TAG_ARCHIVED, TAG_FAVORITE]) @dataclass(frozen=True) @@ -174,25 +176,30 @@ def migrate_json_to_sqlite(self, json_lib: JsonLibrary): # Tags for tag in json_lib.tags: - if tag.id == TAG_ARCHIVED or tag.id == TAG_FAVORITE: - # Update built-in - pass + new_tag = Tag( + id=tag.id, + name=tag.name, + shorthand=tag.shorthand, + color=TagColor.get_color_from_str(tag.color), + ) + if tag.id in range(RESERVED_TAG_START, RESERVED_TAG_END + 1): + self.update_tag(new_tag) # NOTE: This just calls add_tag?? else: - self.add_tag( - Tag( - id=tag.id, - name=tag.name, - shorthand=tag.shorthand, - color=TagColor.get_color_from_str(tag.color), - ) - ) + self.add_tag(new_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) # Parent Tags (Previously known as "Subtags" in JSON) for tag in json_lib.tags: @@ -279,13 +286,14 @@ def open_sqlite_library(self, library_dir: Path, is_new: bool) -> LibraryStatus: with Session(self.engine) as session: make_tables(self.engine) - tags = get_default_tags() - try: - session.add_all(tags) - session.commit() - except IntegrityError: - # default tags may exist already - session.rollback() + # Add default tags to new libraries only. + if is_new: + tags = get_default_tags() + try: + session.add_all(tags) + session.commit() + except IntegrityError: + session.rollback() # dont check db version when creating new library if not is_new: diff --git a/tagstudio/src/qt/modals/build_tag.py b/tagstudio/src/qt/modals/build_tag.py index 1bea954ad..f9957b303 100644 --- a/tagstudio/src/qt/modals/build_tag.py +++ b/tagstudio/src/qt/modals/build_tag.py @@ -58,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) diff --git a/tagstudio/src/qt/modals/tag_database.py b/tagstudio/src/qt/modals/tag_database.py index ac0221568..d567e8991 100644 --- a/tagstudio/src/qt/modals/tag_database.py +++ b/tagstudio/src/qt/modals/tag_database.py @@ -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( diff --git a/tagstudio/src/qt/widgets/migration_modal.py b/tagstudio/src/qt/widgets/migration_modal.py index 2a4896fdb..79143d00e 100644 --- a/tagstudio/src/qt/widgets/migration_modal.py +++ b/tagstudio/src/qt/widgets/migration_modal.py @@ -19,14 +19,15 @@ ) from sqlalchemy import select from sqlalchemy.orm import Session -from src.core.constants import LEGACY_TAG_FIELD_IDS, RESERVED_TAG_END, TAG_META, 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 TagColor from src.core.library.alchemy.joins import TagParent -from src.core.library.alchemy.library import DEFAULT_TAG_DIFF +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, 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 @@ -310,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)) @@ -320,6 +322,28 @@ 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"], [], "") + logger.warning(self.json_lib.tags) + # 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) + logger.warning(self.json_lib.tags) + + 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( @@ -415,7 +439,7 @@ def update_sql_value_ui(self, show_msg_box: bool = True): ) self.update_sql_value( self.tags_row, - (len(self.sql_lib.tags) - DEFAULT_TAG_DIFF), + len(self.sql_lib.tags), self.old_tag_count, ) self.update_sql_value( @@ -595,9 +619,6 @@ def check_subtag_parity(self) -> bool: with Session(self.sql_lib.engine) as session: for tag in self.sql_lib.tags: - # NOTE: Don't check subtag parity for built-in tags. - if tag.id in range(0, RESERVED_TAG_END + 1): - break tag_id = tag.id # Tag IDs start at 0 sql_parent_tags = set( session.scalars(select(TagParent.child_id).where(TagParent.parent_id == tag.id)) @@ -639,10 +660,6 @@ def check_alias_parity(self) -> bool: with Session(self.sql_lib.engine) as session: for tag in self.sql_lib.tags: - # NOTE: Do not check alias parity for built-in tags added - # after "Favorite" and "Archived". - if tag.id in range(TAG_META, RESERVED_TAG_END + 1): - break tag_id = tag.id # Tag IDs start at 0 sql_aliases = set( session.scalars(select(TagAlias.name).where(TagAlias.tag_id == tag.id)) @@ -675,13 +692,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: - # NOTE: Don't check shorthand parity for built-in tags. - if tag.id in range(0, RESERVED_TAG_END + 1): - break 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]", @@ -690,11 +708,7 @@ 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][Tag ID: {tag_id}]:" f"\nOLD (JSON):{json_shorthand}\nNEW (SQL):{sql_shorthand}" @@ -711,9 +725,6 @@ def check_color_parity(self) -> bool: json_color: str = None for tag in self.sql_lib.tags: - # NOTE: Don't check tag color parity for built-in tags. - if tag.id in range(0, RESERVED_TAG_END + 1): - break tag_id = tag.id # Tag IDs start at 0 sql_color = tag.color.name json_color = ( From 3049afa43c65dcbb0004388041f4677d993c29af Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 11 Jan 2025 04:38:39 -0800 Subject: [PATCH 59/68] refactor: rename `select_item()` to `toggle_item_selection()` --- tagstudio/src/qt/ts_qt.py | 19 ++++++++++++++++--- tagstudio/tests/qt/test_field_containers.py | 20 ++++++++++---------- tagstudio/tests/qt/test_preview_panel.py | 10 +++++----- tagstudio/tests/qt/test_qt_driver.py | 12 ++++++------ 4 files changed, 37 insertions(+), 24 deletions(-) diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index c67856c1c..5a9935b68 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -974,8 +974,21 @@ def _init_thumb_grid(self): sa.setWidgetResizable(True) sa.setWidget(self.flow_container) - def select_item(self, item_id: int, append: bool, bridge: bool): - """Select one or more items in the Thumbnail Grid.""" + def toggle_item_selection(self, item_id: int, append: bool, bridge: bool): + """Toggle the selection of an item in the Thumbnail Grid. + + 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. + + 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: @@ -1175,7 +1188,7 @@ def update_thumbs(self): item_thumb.assign_badge(BadgeType.FAVORITE, entry.is_favorite) item_thumb.update_clickable( clickable=( - lambda checked=False, item_id=entry.id: self.select_item( + lambda checked=False, item_id=entry.id: self.toggle_item_selection( item_id, append=( QGuiApplication.keyboardModifiers() diff --git a/tagstudio/tests/qt/test_field_containers.py b/tagstudio/tests/qt/test_field_containers.py index fb2cd9ba3..e501866d8 100644 --- a/tagstudio/tests/qt/test_field_containers.py +++ b/tagstudio/tests/qt/test_field_containers.py @@ -5,8 +5,8 @@ def test_update_selection_empty(qt_driver, library): panel = PreviewPanel(library, qt_driver) # Clear the library selection (selecting 1 then unselecting 1) - qt_driver.select_item(1, append=False, bridge=False) - qt_driver.select_item(1, append=True, bridge=False) + 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 @@ -18,7 +18,7 @@ def test_update_selection_single(qt_driver, library, entry_full): panel = PreviewPanel(library, qt_driver) # Select the single entry - qt_driver.select_item(entry_full.id, append=False, bridge=False) + qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) panel.update_widgets() # FieldContainer should show all applicable tags and field containers @@ -32,8 +32,8 @@ def test_update_selection_multiple(qt_driver, library): panel = PreviewPanel(library, qt_driver) # Select the multiple entries - qt_driver.select_item(1, append=False, bridge=False) - qt_driver.select_item(2, append=True, bridge=False) + 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 @@ -47,7 +47,7 @@ def test_add_tag_to_selection_single(qt_driver, library, entry_full): assert {t.id for t in entry_full.tags} == {1000} # Select the single entry - qt_driver.select_item(entry_full.id, append=False, bridge=False) + qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) panel.update_widgets() # Add new tag @@ -64,7 +64,7 @@ def test_add_same_tag_to_selection_single(qt_driver, library, entry_full): assert {t.id for t in entry_full.tags} == {1000} # Select the single entry - qt_driver.select_item(entry_full.id, append=False, bridge=False) + qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) panel.update_widgets() # Add an existing tag @@ -94,7 +94,7 @@ def test_add_tag_to_selection_multiple(qt_driver, library): # Select the multiple entries for i, e in enumerate(library.get_entries(with_joins=True), start=0): - qt_driver.select_item(e.id, append=(True if i == 0 else False), bridge=False) # noqa: SIM210 + qt_driver.toggle_item_selection(e.id, append=(True if i == 0 else False), bridge=False) # noqa: SIM210 panel.update_widgets() # Add new tag @@ -122,7 +122,7 @@ def test_meta_tag_category(qt_driver, library, entry_full): library.add_tags_to_entry(1, entry_full.id) # Select the single entry - qt_driver.select_item(entry_full.id, append=False, bridge=False) + qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) panel.update_widgets() # FieldContainer should hide all containers @@ -154,7 +154,7 @@ def test_custom_tag_category(qt_driver, library, entry_full): library.add_tags_to_entry(1, entry_full.id) # Select the single entry - qt_driver.select_item(entry_full.id, append=False, bridge=False) + qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) panel.update_widgets() # FieldContainer should hide all containers diff --git a/tagstudio/tests/qt/test_preview_panel.py b/tagstudio/tests/qt/test_preview_panel.py index 569fb80d9..12b64af91 100644 --- a/tagstudio/tests/qt/test_preview_panel.py +++ b/tagstudio/tests/qt/test_preview_panel.py @@ -5,8 +5,8 @@ def test_update_selection_empty(qt_driver, library): panel = PreviewPanel(library, qt_driver) # Clear the library selection (selecting 1 then unselecting 1) - qt_driver.select_item(1, append=False, bridge=False) - qt_driver.select_item(1, append=True, bridge=False) + qt_driver.toggle_item_selection(1, append=False, bridge=False) + qt_driver.toggle_item_selection(1, append=True, bridge=False) panel.update_widgets() # Panel should disable UI that allows for entry modification @@ -18,7 +18,7 @@ def test_update_selection_single(qt_driver, library, entry_full): panel = PreviewPanel(library, qt_driver) # Select the single entry - qt_driver.select_item(entry_full.id, append=False, bridge=False) + qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) panel.update_widgets() # Panel should enable UI that allows for entry modification @@ -30,8 +30,8 @@ def test_update_selection_multiple(qt_driver, library): panel = PreviewPanel(library, qt_driver) # Select the multiple entries - qt_driver.select_item(1, append=False, bridge=False) - qt_driver.select_item(2, append=True, bridge=False) + qt_driver.toggle_item_selection(1, append=False, bridge=False) + qt_driver.toggle_item_selection(2, append=True, bridge=False) panel.update_widgets() # Panel should enable UI that allows for entry modification diff --git a/tagstudio/tests/qt/test_qt_driver.py b/tagstudio/tests/qt/test_qt_driver.py index 0cda8151a..c05453732 100644 --- a/tagstudio/tests/qt/test_qt_driver.py +++ b/tagstudio/tests/qt/test_qt_driver.py @@ -29,7 +29,7 @@ # assert thumb.isVisible() == (idx == 0) -# def test_select_item_bridge(qt_driver, entry_min): +# 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() @@ -42,22 +42,22 @@ # assert len(qt_driver.item_thumbs) == 3 # # select first item -# qt_driver.select_item(0, append=False, bridge=False) +# 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) +# 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) +# 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) +# qt_driver.toggle_item_selection(2, append=False, bridge=False) # assert qt_driver.selected == [2] -# qt_driver.select_item(0, append=False, bridge=True) +# qt_driver.toggle_item_selection(0, append=False, bridge=True) # assert qt_driver.selected == [0, 1, 2] From 5a4ba68978acebc91965b2503397eeeea9caf027 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 11 Jan 2025 04:43:25 -0800 Subject: [PATCH 60/68] add TODO to optimize `add_tags_to_entry()` --- tagstudio/src/core/library/alchemy/library.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index b51d5523c..ecbe93043 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -934,6 +934,7 @@ def add_tags_to_entry(self, entry_id: int, tag_ids: int | list[int] | set[int]) tag_ids_ = [tag_ids] if isinstance(tag_ids, int) else tag_ids with Session(self.engine, expire_on_commit=False) as session: try: + # 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() From 4c019ca19c365f6d0d66ec4a9d3db105624e83d6 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 11 Jan 2025 06:24:54 -0800 Subject: [PATCH 61/68] fix: remove unnecessary joins in search --- tagstudio/src/core/library/alchemy/library.py | 4 ---- tagstudio/tests/test_library.py | 19 ------------------- 2 files changed, 23 deletions(-) diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index ecbe93043..5c8194355 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -585,19 +585,15 @@ 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.tags).options( selectinload(Tag.aliases), selectinload(Tag.parent_tags) ), ) statement = statement.distinct(Entry.id) - query_count = select(func.count()).select_from(statement.alias("entries")) count_all: int = session.execute(query_count).scalar() - statement = statement.limit(search.limit).offset(search.offset) logger.info( diff --git a/tagstudio/tests/test_library.py b/tagstudio/tests/test_library.py index ad4321fa9..3f0f3ce29 100644 --- a/tagstudio/tests/test_library.py +++ b/tagstudio/tests/test_library.py @@ -101,25 +101,6 @@ def test_tag_self_parent(library, generate_tag): assert len(tag.parent_ids) == 0 -def test_library_search(library, generate_tag, entry_full): - assert library.entries_count == 2 - tag = list(entry_full.tags)[0] - - results = library.search_library( - FilterState.from_tag_name(tag.name), - ) - - assert results.total_count == 1 - assert len(results) == 1 - - entry = results[0] - assert {x.name for x in entry.tags} == { - "foo", - } - - assert entry.tags - - def test_tag_search(library): tag = library.tags[0] From 2c966ada9970e3bbf7769574e235b396473f90d8 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 11 Jan 2025 06:27:04 -0800 Subject: [PATCH 62/68] Revert "fix: remove unnecessary joins in search" This reverts commit 4c019ca19c365f6d0d66ec4a9d3db105624e83d6. --- tagstudio/src/core/library/alchemy/library.py | 4 ++++ tagstudio/tests/test_library.py | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index 5c8194355..ecbe93043 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -585,15 +585,19 @@ 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.tags).options( selectinload(Tag.aliases), selectinload(Tag.parent_tags) ), ) statement = statement.distinct(Entry.id) + query_count = select(func.count()).select_from(statement.alias("entries")) count_all: int = session.execute(query_count).scalar() + statement = statement.limit(search.limit).offset(search.offset) logger.info( diff --git a/tagstudio/tests/test_library.py b/tagstudio/tests/test_library.py index 3f0f3ce29..ad4321fa9 100644 --- a/tagstudio/tests/test_library.py +++ b/tagstudio/tests/test_library.py @@ -101,6 +101,25 @@ def test_tag_self_parent(library, generate_tag): assert len(tag.parent_ids) == 0 +def test_library_search(library, generate_tag, entry_full): + assert library.entries_count == 2 + tag = list(entry_full.tags)[0] + + results = library.search_library( + FilterState.from_tag_name(tag.name), + ) + + assert results.total_count == 1 + assert len(results) == 1 + + entry = results[0] + assert {x.name for x in entry.tags} == { + "foo", + } + + assert entry.tags + + def test_tag_search(library): tag = library.tags[0] From cf30ee67c090a2aa1466c8bdf177a85200266f4a Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 11 Jan 2025 06:29:16 -0800 Subject: [PATCH 63/68] fix: remove unnecessary joins in search --- tagstudio/src/core/library/alchemy/library.py | 10 ---------- tagstudio/tests/test_library.py | 7 ------- 2 files changed, 17 deletions(-) diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index ecbe93043..2670fb851 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -585,19 +585,9 @@ 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.tags).options( - selectinload(Tag.aliases), selectinload(Tag.parent_tags) - ), - ) - statement = statement.distinct(Entry.id) - query_count = select(func.count()).select_from(statement.alias("entries")) count_all: int = session.execute(query_count).scalar() - statement = statement.limit(search.limit).offset(search.offset) logger.info( diff --git a/tagstudio/tests/test_library.py b/tagstudio/tests/test_library.py index ad4321fa9..ef88fe8bd 100644 --- a/tagstudio/tests/test_library.py +++ b/tagstudio/tests/test_library.py @@ -112,13 +112,6 @@ 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.tags - def test_tag_search(library): tag = library.tags[0] From a0fe8675a70efb09aa0b12134ae0881dd8eaf9b5 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 11 Jan 2025 06:32:08 -0800 Subject: [PATCH 64/68] reremove unused method `get_all_child_tag_ids()` --- tagstudio/src/core/library/alchemy/library.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index 2670fb851..c61a7b0c4 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -642,24 +642,6 @@ def search_tags( return res - def get_all_child_tag_ids(self, tag_id: int) -> list[int]: - """Recursively traverse a Tag's parent tags and return a list of all children tags.""" - all_parent_ids: set[int] = {tag_id} - - with Session(self.engine) as session: - tag: Tag | None = session.scalar(select(Tag).where(Tag.id == tag_id)) - if tag is None: - raise ValueError(f"No tag found with id {tag_id}.") - - parent_ids = tag.parent_ids - - all_parent_ids.update(parent_ids) - - for child_id in parent_ids: - all_parent_ids.update(self.get_all_child_tag_ids(child_id)) - - return list(all_parent_ids) - def update_entry_path(self, entry_id: int | Entry, path: Path) -> None: if isinstance(entry_id, Entry): entry_id = entry_id.id From db53d61f0021da1df3a12d0bd139e9cfe53d7f04 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 11 Jan 2025 09:05:56 -0800 Subject: [PATCH 65/68] fix: migrate user-edited tag colors for built-in tags --- tagstudio/src/core/library/alchemy/library.py | 19 +++++++++++-------- tagstudio/src/qt/widgets/migration_modal.py | 2 -- tagstudio/tests/test_json_migration.py | 3 +-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index 9bbdd9166..0b2f74742 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -179,16 +179,19 @@ def migrate_json_to_sqlite(self, json_lib: JsonLibrary): # Tags for tag in json_lib.tags: - new_tag = Tag( - id=tag.id, - name=tag.name, - shorthand=tag.shorthand, - color=TagColor.get_color_from_str(tag.color), + self.add_tag( + Tag( + id=tag.id, + name=tag.name, + shorthand=tag.shorthand, + 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): - self.update_tag(new_tag) # NOTE: This just calls add_tag?? - else: - self.add_tag(new_tag) + 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: diff --git a/tagstudio/src/qt/widgets/migration_modal.py b/tagstudio/src/qt/widgets/migration_modal.py index 79143d00e..954ad4722 100644 --- a/tagstudio/src/qt/widgets/migration_modal.py +++ b/tagstudio/src/qt/widgets/migration_modal.py @@ -330,11 +330,9 @@ def update_json_builtins(self): """ # 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"], [], "") - logger.warning(self.json_lib.tags) # 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) - logger.warning(self.json_lib.tags) archived_tag: JsonTag = self.json_lib.get_tag(TAG_ARCHIVED) archived_tag.subtag_ids.append(TAG_META) diff --git a/tagstudio/tests/test_json_migration.py b/tagstudio/tests/test_json_migration.py index 62078a07e..c8ad58e6c 100644 --- a/tagstudio/tests/test_json_migration.py +++ b/tagstudio/tests/test_json_migration.py @@ -6,7 +6,6 @@ from time import time from src.core.enums import LibraryPrefs -from src.core.library.alchemy.library import DEFAULT_TAG_DIFF from src.qt.widgets.migration_modal import JsonMigrationModal CWD = pathlib.Path(__file__) @@ -30,7 +29,7 @@ def test_json_migration(): # Tags ===================================================================== # Count - assert len(modal.json_lib.tags) == (len(modal.sql_lib.tags) - DEFAULT_TAG_DIFF) + assert len(modal.json_lib.tags) == len(modal.sql_lib.tags) # Shorthand Parity assert modal.check_shorthand_parity() # Subtag/Parent Tag Parity From 959a76d36a5c77c39eae6b8288d84cbadf478a38 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 11 Jan 2025 09:21:27 -0800 Subject: [PATCH 66/68] style: update header for contributor-created files --- tagstudio/src/core/constants.py | 2 +- tagstudio/src/core/enums.py | 2 +- tagstudio/src/core/library/alchemy/db.py | 2 +- tagstudio/src/core/library/alchemy/fields.py | 2 +- tagstudio/src/core/library/alchemy/joins.py | 2 +- tagstudio/src/core/library/alchemy/library.py | 2 +- tagstudio/src/core/library/alchemy/models.py | 2 +- tagstudio/src/core/library/alchemy/visitors.py | 2 +- tagstudio/src/qt/modals/folders_to_tags.py | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tagstudio/src/core/constants.py b/tagstudio/src/core/constants.py index 0525e2a22..baec4a758 100644 --- a/tagstudio/src/core/constants.py +++ b/tagstudio/src/core/constants.py @@ -1,4 +1,4 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Copyright (C) 2025 # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio diff --git a/tagstudio/src/core/enums.py b/tagstudio/src/core/enums.py index c5969054d..74cdcbe4b 100644 --- a/tagstudio/src/core/enums.py +++ b/tagstudio/src/core/enums.py @@ -1,4 +1,4 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Copyright (C) 2025 # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio diff --git a/tagstudio/src/core/library/alchemy/db.py b/tagstudio/src/core/library/alchemy/db.py index 5e5f70e9f..8cc168288 100644 --- a/tagstudio/src/core/library/alchemy/db.py +++ b/tagstudio/src/core/library/alchemy/db.py @@ -1,4 +1,4 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Copyright (C) 2025 # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio diff --git a/tagstudio/src/core/library/alchemy/fields.py b/tagstudio/src/core/library/alchemy/fields.py index 2b860e2f5..4f0b33b32 100644 --- a/tagstudio/src/core/library/alchemy/fields.py +++ b/tagstudio/src/core/library/alchemy/fields.py @@ -1,4 +1,4 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Copyright (C) 2025 # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio diff --git a/tagstudio/src/core/library/alchemy/joins.py b/tagstudio/src/core/library/alchemy/joins.py index b3c97e359..f8b569f8d 100644 --- a/tagstudio/src/core/library/alchemy/joins.py +++ b/tagstudio/src/core/library/alchemy/joins.py @@ -1,4 +1,4 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Copyright (C) 2025 # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index 0b2f74742..37b58d050 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -1,4 +1,4 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Copyright (C) 2025 # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio diff --git a/tagstudio/src/core/library/alchemy/models.py b/tagstudio/src/core/library/alchemy/models.py index 2765e880a..b6ffd2e49 100644 --- a/tagstudio/src/core/library/alchemy/models.py +++ b/tagstudio/src/core/library/alchemy/models.py @@ -1,4 +1,4 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Copyright (C) 2025 # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio diff --git a/tagstudio/src/core/library/alchemy/visitors.py b/tagstudio/src/core/library/alchemy/visitors.py index 9ce07fdb1..f88bc2a77 100644 --- a/tagstudio/src/core/library/alchemy/visitors.py +++ b/tagstudio/src/core/library/alchemy/visitors.py @@ -1,4 +1,4 @@ -# Copyright (C) 2025 Travis Abendshien (CyanVoxel). +# Copyright (C) 2025 # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio diff --git a/tagstudio/src/qt/modals/folders_to_tags.py b/tagstudio/src/qt/modals/folders_to_tags.py index 47b7a9b89..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) 2025 Travis Abendshien (CyanVoxel). +# Copyright (C) 2025 # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio From 4030e802b8e12d9a796c01b879a73e18f9738a7d Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 11 Jan 2025 09:34:02 -0800 Subject: [PATCH 67/68] fix: use absolute path in "open file" context menu --- tagstudio/src/qt/widgets/item_thumb.py | 2 +- tagstudio/src/qt/widgets/thumb_renderer.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tagstudio/src/qt/widgets/item_thumb.py b/tagstudio/src/qt/widgets/item_thumb.py index a3bdb2938..3354fe46a 100644 --- a/tagstudio/src/qt/widgets/item_thumb.py +++ b/tagstudio/src/qt/widgets/item_thumb.py @@ -399,7 +399,7 @@ def set_count(self, count: str) -> None: def set_filename_text(self, filename: Path | str | None): self.set_item_path(filename) - self.file_label.setText(str(filename)) + self.file_label.setText(str(filename.name)) def set_filename_visibility(self, set_visible: bool): """Toggle the visibility of the filename label. diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index 0df495d2c..4fea7f659 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -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(), ) From 59122dc881d0475007a75898f90ad31a81cb6a1a Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 11 Jan 2025 09:37:08 -0800 Subject: [PATCH 68/68] chore: change paramater type hint --- tagstudio/src/qt/widgets/item_thumb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tagstudio/src/qt/widgets/item_thumb.py b/tagstudio/src/qt/widgets/item_thumb.py index 3354fe46a..adb05dfce 100644 --- a/tagstudio/src/qt/widgets/item_thumb.py +++ b/tagstudio/src/qt/widgets/item_thumb.py @@ -397,7 +397,7 @@ 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): + def set_filename_text(self, filename: Path | None): self.set_item_path(filename) self.file_label.setText(str(filename.name))