diff --git a/docs/updates/schema_changes.md b/docs/updates/schema_changes.md index 2d721d8be..7a4aefc63 100644 --- a/docs/updates/schema_changes.md +++ b/docs/updates/schema_changes.md @@ -123,3 +123,12 @@ Migration from the legacy JSON format is provided via a walkthrough when opening | [v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | SQLite | ``/.TagStudio/ts_library.sqlite | - Applies repairs to the `tag_parents` table created in [version 100](#version-100), removing rows that reference tags that have been deleted. + +#### Version 103 + +| Used From | Format | Location | +| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- | +| [#1139](https://github.com/TagStudioDev/TagStudio/pull/1139) | SQLite | ``/.TagStudio/ts_library.sqlite | + +- Adds the `is_hidden` column to the `tags` table (default `0`). Used for excluding entries tagged with hidden tags from library searches. +- Sets the `is_hidden` field on the built-in Archived tag to `1`, to match the Archived tag now being hidden by default. \ No newline at end of file diff --git a/src/tagstudio/core/library/alchemy/constants.py b/src/tagstudio/core/library/alchemy/constants.py index 83aab71b0..1e2080249 100644 --- a/src/tagstudio/core/library/alchemy/constants.py +++ b/src/tagstudio/core/library/alchemy/constants.py @@ -11,7 +11,7 @@ DB_VERSION_LEGACY_KEY: str = "DB_VERSION" DB_VERSION_CURRENT_KEY: str = "CURRENT" DB_VERSION_INITIAL_KEY: str = "INITIAL" -DB_VERSION: int = 102 +DB_VERSION: int = 103 TAG_CHILDREN_QUERY = text(""" WITH RECURSIVE ChildTags AS ( diff --git a/src/tagstudio/core/library/alchemy/db.py b/src/tagstudio/core/library/alchemy/db.py index 026678ddf..8e3e6a618 100644 --- a/src/tagstudio/core/library/alchemy/db.py +++ b/src/tagstudio/core/library/alchemy/db.py @@ -57,8 +57,8 @@ def make_tables(engine: Engine) -> None: conn.execute( text( "INSERT INTO tags " - "(id, name, color_namespace, color_slug, is_category) VALUES " - f"({RESERVED_TAG_END}, 'temp', NULL, NULL, false)" + "(id, name, color_namespace, color_slug, is_category, is_hidden) VALUES " + f"({RESERVED_TAG_END}, 'temp', NULL, NULL, false, false)" ) ) conn.execute(text(f"DELETE FROM tags WHERE id = {RESERVED_TAG_END}")) diff --git a/src/tagstudio/core/library/alchemy/enums.py b/src/tagstudio/core/library/alchemy/enums.py index 76f7fa124..a2d38c48c 100644 --- a/src/tagstudio/core/library/alchemy/enums.py +++ b/src/tagstudio/core/library/alchemy/enums.py @@ -82,6 +82,8 @@ class BrowsingState: ascending: bool = True random_seed: float = 0 + exclude_hidden_entries: bool = True + query: str | None = None # Abstract Syntax Tree Of the current Search Query @@ -147,6 +149,9 @@ def with_sorting_direction(self, ascending: bool) -> "BrowsingState": def with_search_query(self, search_query: str) -> "BrowsingState": return replace(self, query=search_query) + def with_exclude_hidden_entries(self, exclude_hidden_entries: bool) -> "BrowsingState": + return replace(self, exclude_hidden_entries=exclude_hidden_entries) + class FieldTypeEnum(enum.Enum): TEXT_LINE = "Text Line" diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py index a25231e95..717546c88 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -104,6 +104,7 @@ ) from tagstudio.core.library.alchemy.visitors import SQLBoolExpressionBuilder from tagstudio.core.library.json.library import Library as JsonLibrary +from tagstudio.core.query_lang.ast import ANDList, Constraint, ConstraintType, Not, ORList from tagstudio.core.utils.types import unwrap from tagstudio.qt.translations import Translations @@ -151,6 +152,7 @@ def get_default_tags() -> tuple[Tag, ...]: name="Archived", aliases={TagAlias(name="Archive")}, parent_tags={meta_tag}, + is_hidden=True, color_slug="red", color_namespace="tagstudio-standard", ) @@ -540,6 +542,8 @@ def open_sqlite_library(self, library_dir: Path, is_new: bool) -> LibraryStatus: self.__apply_db8_schema_changes(session) if loaded_db_version < 9: self.__apply_db9_schema_changes(session) + if loaded_db_version < 103: + self.__apply_db103_schema_changes(session) if loaded_db_version == 6: self.__apply_repairs_for_db6(session) @@ -551,6 +555,8 @@ def open_sqlite_library(self, library_dir: Path, is_new: bool) -> LibraryStatus: self.__apply_db100_parent_repairs(session) if loaded_db_version < 102: self.__apply_db102_repairs(session) + if loaded_db_version < 103: + self.__apply_db103_default_data(session) # Convert file extension list to ts_ignore file, if a .ts_ignore file does not exist self.migrate_sql_to_ts_ignore(library_dir) @@ -698,6 +704,36 @@ def __apply_db102_repairs(self, session: Session): session.commit() logger.info("[Library][Migration] Verified TagParent table data") + def __apply_db103_schema_changes(self, session: Session): + """Apply database schema changes introduced in DB_VERSION 103.""" + add_is_hidden_column = text( + "ALTER TABLE tags ADD COLUMN is_hidden BOOLEAN NOT NULL DEFAULT 0" + ) + try: + session.execute(add_is_hidden_column) + session.commit() + logger.info("[Library][Migration] Added is_hidden column to tags table") + except Exception as e: + logger.error( + "[Library][Migration] Could not create is_hidden column in tags table!", + error=e, + ) + session.rollback() + + def __apply_db103_default_data(self, session: Session): + """Apply default data changes introduced in DB_VERSION 103.""" + try: + session.query(Tag).filter(Tag.id == TAG_ARCHIVED).update({"is_hidden": True}) + session.commit() + logger.info("[Library][Migration] Updated archived tag to be hidden") + session.commit() + except Exception as e: + logger.error( + "[Library][Migration] Could not update archived tag to be hidden!", + error=e, + ) + session.rollback() + def migrate_sql_to_ts_ignore(self, library_dir: Path): # Do not continue if existing '.ts_ignore' file is found if Path(library_dir / TS_FOLDER_NAME / IGNORE_NAME).exists(): @@ -1003,13 +1039,28 @@ def search_library( else: statement = select(Entry.id) - if search.ast: + ast = search.ast + + if search.exclude_hidden_entries: + hidden_tag_ids = self.get_hidden_tag_ids() + hidden_tag_constraints: list[Constraint] = list( + map( + lambda tag_id: Constraint(ConstraintType.TagID, str(tag_id), []), + hidden_tag_ids, + ) + ) + hidden_tag_ast = Not(ORList(hidden_tag_constraints)) + + ast = hidden_tag_ast if not ast else ANDList([search.ast, hidden_tag_ast]) + + if ast: start_time = time.time() - statement = statement.where(SQLBoolExpressionBuilder(self).visit(search.ast)) + statement = statement.where(SQLBoolExpressionBuilder(self).visit(ast)) end_time = time.time() logger.info( f"SQL Expression Builder finished ({format_timespan(end_time - start_time)})" ) + statement = statement.distinct(Entry.id) sort_on: ColumnExpressionArgument = Entry.id @@ -1800,6 +1851,19 @@ def update_parent_tags(self, tag: Tag, parent_ids: list[int] | set[int], session ) session.add(parent_tag) + def get_hidden_tag_ids(self) -> set[int]: + """Get a set containing the IDs of all of the hidden tags.""" + hidden_tag_ids: set[int] = set() + + with Session(self.engine) as session: + root_hidden_tag_ids = session.scalars( + select(Tag.id).where(Tag.is_hidden == True) # noqa: E712 + ).all() + for root_hidden_tag_id in root_hidden_tag_ids: + hidden_tag_ids.add(root_hidden_tag_id) + + return hidden_tag_ids + def get_version(self, key: str) -> int: """Get a version value from the DB. diff --git a/src/tagstudio/core/library/alchemy/models.py b/src/tagstudio/core/library/alchemy/models.py index 223dc0216..fb8c5801c 100644 --- a/src/tagstudio/core/library/alchemy/models.py +++ b/src/tagstudio/core/library/alchemy/models.py @@ -97,6 +97,7 @@ class Tag(Base): color_slug: Mapped[str | None] = mapped_column() color: Mapped[TagColorGroup | None] = relationship(lazy="joined") is_category: Mapped[bool] + is_hidden: Mapped[bool] icon: Mapped[str | None] aliases: Mapped[set[TagAlias]] = relationship(back_populates="tag") parent_tags: Mapped[set["Tag"]] = relationship( @@ -138,6 +139,7 @@ def __init__( color_slug: str | None = None, disambiguation_id: int | None = None, is_category: bool = False, + is_hidden: bool = False, ): self.name = name self.aliases = aliases or set() @@ -148,6 +150,7 @@ def __init__( self.shorthand = shorthand self.disambiguation_id = disambiguation_id self.is_category = is_category + self.is_hidden = is_hidden self.id = id # pyright: ignore[reportAttributeAccessIssue] super().__init__() diff --git a/src/tagstudio/core/library/alchemy/visitors.py b/src/tagstudio/core/library/alchemy/visitors.py index c24c8ed7a..50c490ac8 100644 --- a/src/tagstudio/core/library/alchemy/visitors.py +++ b/src/tagstudio/core/library/alchemy/visitors.py @@ -171,6 +171,8 @@ def __separate_tags( continue case ConstraintType.FileType: pass + case ConstraintType.MediaType: + pass case ConstraintType.Path: pass case ConstraintType.Special: diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py index aabdc47a9..cfffb6595 100644 --- a/src/tagstudio/qt/mixed/build_tag.py +++ b/src/tagstudio/qt/mixed/build_tag.py @@ -246,6 +246,46 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: self.cat_layout.addWidget(self.cat_checkbox) self.cat_layout.addWidget(self.cat_title) + # Hidden --------------------------------------------------------------- + self.hidden_widget = QWidget() + self.hidden_layout = QHBoxLayout(self.hidden_widget) + self.hidden_layout.setStretch(1, 1) + self.hidden_layout.setContentsMargins(0, 0, 0, 0) + self.hidden_layout.setSpacing(6) + self.hidden_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.hidden_title = QLabel(Translations["tag.is_hidden"]) + self.hidden_checkbox = QCheckBox() + self.hidden_checkbox.setFixedSize(22, 22) + + self.hidden_checkbox.setStyleSheet( + f"QCheckBox{{" + f"background: rgba{primary_color.toTuple()};" + f"color: rgba{text_color.toTuple()};" + f"border-color: rgba{border_color.toTuple()};" + f"border-radius: 6px;" + f"border-style:solid;" + f"border-width: 2px;" + f"}}" + f"QCheckBox::indicator{{" + f"width: 10px;" + f"height: 10px;" + f"border-radius: 2px;" + f"margin: 4px;" + f"}}" + f"QCheckBox::indicator:checked{{" + f"background: rgba{text_color.toTuple()};" + f"}}" + f"QCheckBox::hover{{" + f"border-color: rgba{highlight_color.toTuple()};" + f"}}" + f"QCheckBox::focus{{" + f"border-color: rgba{highlight_color.toTuple()};" + f"outline:none;" + f"}}" + ) + self.hidden_layout.addWidget(self.hidden_checkbox) + self.hidden_layout.addWidget(self.hidden_title) + # Add Widgets to Layout ================================================ self.root_layout.addWidget(self.name_widget) self.root_layout.addWidget(self.shorthand_widget) @@ -256,6 +296,7 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: self.root_layout.addWidget(self.color_widget) self.root_layout.addWidget(QLabel("

Properties

")) self.root_layout.addWidget(self.cat_widget) + self.root_layout.addWidget(self.hidden_widget) self.parent_ids: set[int] = set() self.alias_ids: list[int] = [] @@ -544,6 +585,7 @@ def set_tag(self, tag: Tag): self.color_button.set_tag_color_group(None) self.cat_checkbox.setChecked(tag.is_category) + self.hidden_checkbox.setChecked(tag.is_hidden) def on_name_changed(self): is_empty = not self.name_field.text().strip() @@ -567,6 +609,7 @@ def build_tag(self) -> Tag: tag.color_namespace = self.tag_color_namespace tag.color_slug = self.tag_color_slug tag.is_category = self.cat_checkbox.isChecked() + tag.is_hidden = self.hidden_checkbox.isChecked() logger.info("built tag", tag=tag) return tag diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index b2e20f491..393cdbdd6 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -623,6 +623,7 @@ def _update_browsing_state(): BrowsingState.from_search_query(self.main_window.search_field.text()) .with_sorting_mode(self.main_window.sorting_mode) .with_sorting_direction(self.main_window.sorting_direction) + .with_exclude_hidden_entries(self.main_window.exclude_hidden_entries) ) except ParsingError as e: self.main_window.status_bar.showMessage( @@ -655,6 +656,12 @@ def _update_browsing_state(): lambda: self.thumb_size_callback(self.main_window.thumb_size_combobox.currentIndex()) ) + # Exclude hidden entries checkbox + self.main_window.hidden_entries_checkbox.setChecked(True) # Default: Yes + self.main_window.hidden_entries_checkbox.stateChanged.connect( + self.exclude_hidden_entries_callback + ) + self.main_window.back_button.clicked.connect(lambda: self.navigation_callback(-1)) self.main_window.forward_button.clicked.connect(lambda: self.navigation_callback(1)) @@ -1156,6 +1163,16 @@ def thumb_size_callback(self, size: int): min(self.main_window.thumb_size // spacing_divisor, min_spacing) ) + def exclude_hidden_entries_callback(self): + logger.info( + "Exclude Hidden Entries Changed", exclude=self.main_window.exclude_hidden_entries + ) + self.update_browsing_state( + self.browsing_history.current.with_exclude_hidden_entries( + self.main_window.exclude_hidden_entries + ) + ) + def mouse_navigation(self, event: QMouseEvent): # print(event.button()) if event.button() == Qt.MouseButton.ForwardButton: diff --git a/src/tagstudio/qt/views/main_window.py b/src/tagstudio/qt/views/main_window.py index df675fbe6..ee6d1bd6e 100644 --- a/src/tagstudio/qt/views/main_window.py +++ b/src/tagstudio/qt/views/main_window.py @@ -11,13 +11,15 @@ from PIL import Image, ImageQt from PySide6 import QtCore from PySide6.QtCore import QMetaObject, QSize, QStringListModel, Qt -from PySide6.QtGui import QAction, QPixmap +from PySide6.QtGui import QAction, QColor, QPixmap from PySide6.QtWidgets import ( + QCheckBox, QComboBox, QCompleter, QFrame, QGridLayout, QHBoxLayout, + QLabel, QLayout, QLineEdit, QMainWindow, @@ -34,12 +36,14 @@ ) from tagstudio.core.enums import ShowFilepathOption -from tagstudio.core.library.alchemy.enums import SortingModeEnum +from tagstudio.core.library.alchemy.enums import SortingModeEnum, TagColorEnum from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel from tagstudio.qt.helpers.color_overlay import theme_fg_overlay from tagstudio.qt.mixed.landing import LandingWidget from tagstudio.qt.mixed.pagination import Pagination +from tagstudio.qt.mixed.tag_widget import get_border_color, get_highlight_color, get_text_color from tagstudio.qt.mnemonics import assign_mnemonics +from tagstudio.qt.models.palette import ColorType, get_tag_color from tagstudio.qt.platform_strings import trash_term from tagstudio.qt.resource_manager import ResourceManager from tagstudio.qt.thumb_grid_layout import ThumbGridLayout @@ -578,7 +582,57 @@ def setup_extra_input_bar(self): self.extra_input_layout = QHBoxLayout() self.extra_input_layout.setObjectName("extra_input_layout") - ## left side spacer + primary_color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)) + border_color = get_border_color(primary_color) + highlight_color = get_highlight_color(primary_color) + text_color: QColor = get_text_color(primary_color, highlight_color) + + ## Exclude hidden tags checkbox + self.hidden_entries_widget = QWidget() + self.hidden_entries_layout = QHBoxLayout(self.hidden_entries_widget) + self.hidden_entries_layout.setStretch(1, 1) + self.hidden_entries_layout.setContentsMargins(0, 0, 0, 0) + self.hidden_entries_layout.setSpacing(6) + self.hidden_entries_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.hidden_entries_title = QLabel(Translations["home.exclude_hidden_entries"]) + self.hidden_entries_checkbox = QCheckBox() + self.hidden_entries_checkbox.setFixedSize(22, 22) + + self.hidden_entries_checkbox.setStyleSheet( + f"QCheckBox{{" + f"background: rgba{primary_color.toTuple()};" + f"color: rgba{text_color.toTuple()};" + f"border-color: rgba{border_color.toTuple()};" + f"border-radius: 6px;" + f"border-style:solid;" + f"border-width: 2px;" + f"}}" + f"QCheckBox::indicator{{" + f"width: 10px;" + f"height: 10px;" + f"border-radius: 2px;" + f"margin: 4px;" + f"}}" + f"QCheckBox::indicator:checked{{" + f"background: rgba{text_color.toTuple()};" + f"}}" + f"QCheckBox::hover{{" + f"border-color: rgba{highlight_color.toTuple()};" + f"}}" + f"QCheckBox::focus{{" + f"border-color: rgba{highlight_color.toTuple()};" + f"outline:none;" + f"}}" + ) + + self.hidden_entries_checkbox.setChecked(True) # Default: Yes + + self.hidden_entries_layout.addWidget(self.hidden_entries_checkbox) + self.hidden_entries_layout.addWidget(self.hidden_entries_title) + + self.extra_input_layout.addWidget(self.hidden_entries_widget) + + ## Spacer self.extra_input_layout.addItem( QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) ) @@ -712,3 +766,8 @@ def sorting_direction(self) -> bool: @property def thumb_size(self) -> int: return self.thumb_size_combobox.currentData() + + @property + def exclude_hidden_entries(self) -> bool: + """Whether to exclude entries tagged with hidden tags.""" + return self.hidden_entries_checkbox.isChecked() diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index edda02311..58e411b0e 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -146,6 +146,7 @@ "home.thumbnail_size.mini": "Mini Thumbnails", "home.thumbnail_size.small": "Small Thumbnails", "home.thumbnail_size": "Thumbnail Size", + "home.exclude_hidden_entries": "Exclude Hidden Entries?", "ignore.open_file": "Show \"{ts_ignore}\" File on Disk", "json_migration.checking_for_parity": "Checking for Parity...", "json_migration.creating_database_tables": "Creating SQL Database Tables...", @@ -326,6 +327,7 @@ "tag.disambiguation.tooltip": "Use this tag for disambiguation", "tag.edit": "Edit Tag", "tag.is_category": "Is Category", + "tag.is_hidden": "Is Hidden", "tag.name": "Name", "tag.new": "New Tag", "tag.parent_tags.add": "Add Parent Tag(s)", diff --git a/tests/fixtures/search_library/.TagStudio/ts_library.sqlite b/tests/fixtures/search_library/.TagStudio/ts_library.sqlite index 60b205624..65b059981 100644 Binary files a/tests/fixtures/search_library/.TagStudio/ts_library.sqlite and b/tests/fixtures/search_library/.TagStudio/ts_library.sqlite differ