diff --git a/src/tagstudio/core/library/alchemy/constants.py b/src/tagstudio/core/library/alchemy/constants.py index 73493c9af..8cb657849 100644 --- a/src/tagstudio/core/library/alchemy/constants.py +++ b/src/tagstudio/core/library/alchemy/constants.py @@ -9,7 +9,7 @@ DB_VERSION_CURRENT_KEY: str = "CURRENT" DB_VERSION_INITIAL_KEY: str = "INITIAL" -DB_VERSION: int = 300 +DB_VERSION: int = 301 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 d8d520d78..19540d13c 100644 --- a/src/tagstudio/core/library/alchemy/db.py +++ b/src/tagstudio/core/library/alchemy/db.py @@ -60,8 +60,14 @@ def make_tables(engine: Engine) -> None: conn.execute( text( "INSERT INTO tags " - "(id, name, color_namespace, color_slug, is_category, is_hidden) VALUES " - f"({RESERVED_TAG_END}, 'temp', NULL, NULL, false, false)" + "(id," + "name," + "color_namespace," + "color_slug," + "is_category," + "is_hidden," + "is_pinned) VALUES " + f"({RESERVED_TAG_END}, 'temp', NULL, NULL, false, false, false)" ) ) conn.execute(text(f"DELETE FROM tags WHERE id = {RESERVED_TAG_END}")) diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py index 41ada274f..f979946ce 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -570,6 +570,7 @@ def open_sqlite_library( (self.__apply_db201_migration, 201, 200), # changes: field tables (self.__apply_db202_migration, 202, None), # changes: tag_parents (self.__apply_db300_migration, 300, None), # changes: deletes folders + (self.__apply_db301_migration, 301, None), # changes: tags ] for migration, v, iv in migrations: if loaded_db_version < v and (iv is None or initial_db_version < iv): @@ -914,6 +915,13 @@ def __apply_db300_migration(self, session: Session, library_dir: Path): session.execute(text("DROP TABLE folders")) session.flush() + def __apply_db301_migration(self, session: Session, library_dir: Path): + """Migrate DB to DB_VERSION 301.""" + # Add the new pinned column for tags + session.execute(text("ALTER TABLE tags ADD COLUMN is_pinned BOOLEAN NOT NULL DEFAULT 0")) + session.flush() + logger.info("[Library][Migration][103] Added is_pinned column to tags table") + @property def field_templates(self) -> Sequence[BaseFieldTemplate]: with Session(self.engine) as session: diff --git a/src/tagstudio/core/library/alchemy/models.py b/src/tagstudio/core/library/alchemy/models.py index 0b0a31c22..ed75d76e0 100644 --- a/src/tagstudio/core/library/alchemy/models.py +++ b/src/tagstudio/core/library/alchemy/models.py @@ -95,6 +95,7 @@ class Tag(Base): color: Mapped[TagColorGroup | None] = relationship(lazy="joined") is_category: Mapped[bool] is_hidden: Mapped[bool] + is_pinned: Mapped[bool] icon: Mapped[str | None] aliases: Mapped[set[TagAlias]] = relationship(back_populates="tag") parent_tags: Mapped[set["Tag"]] = relationship( @@ -137,6 +138,7 @@ def __init__( disambiguation_id: int | None = None, is_category: bool = False, is_hidden: bool = False, + is_pinned: bool = False, ): self.name = name self.aliases = aliases or set() @@ -148,6 +150,7 @@ def __init__( self.disambiguation_id = disambiguation_id self.is_category = is_category self.is_hidden = is_hidden + self.is_pinned = is_pinned self.id = id # pyright: ignore[reportAttributeAccessIssue] super().__init__() diff --git a/src/tagstudio/qt/controllers/search_panel_controller.py b/src/tagstudio/qt/controllers/search_panel_controller.py index 0b743931c..0afe4e521 100644 --- a/src/tagstudio/qt/controllers/search_panel_controller.py +++ b/src/tagstudio/qt/controllers/search_panel_controller.py @@ -170,11 +170,12 @@ def update_items(self, query: str | None = None) -> None: search_results: tuple[list[T], list[T]] = self.search_items(query_lower) # Sort and prioritize the results + # not item.is_pinned is used beacuse False sorts before True. direct_results = list({item for item in search_results[0] if not self._is_excluded(item)}) - direct_results.sort(key=lambda item: _item_name(item).lower()) + direct_results.sort(key=lambda item: (not item.is_pinned, _item_name(item).lower())) # pyright: ignore[reportAttributeAccessIssue] ancestor_results = list({item for item in search_results[1] if not self._is_excluded(item)}) - ancestor_results.sort(key=lambda item: _item_name(item).lower()) + ancestor_results.sort(key=lambda item: (not item.is_pinned, _item_name(item).lower())) # pyright: ignore[reportAttributeAccessIssue] raw_results = list(direct_results + ancestor_results) priority_results: set[T] = set() diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py index d590902bd..4f26848b1 100644 --- a/src/tagstudio/qt/mixed/build_tag.py +++ b/src/tagstudio/qt/mixed/build_tag.py @@ -238,6 +238,20 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: self.hidden_layout.addWidget(self.hidden_checkbox) self.hidden_layout.addWidget(self.hidden_title) + # Pinned --------------------------------------------------------------- + self.pinned_widget = QWidget() + self.pinned_layout = QHBoxLayout(self.pinned_widget) + self.pinned_layout.setStretch(1, 1) + self.pinned_layout.setContentsMargins(0, 0, 0, 0) + self.pinned_layout.setSpacing(6) + self.pinned_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.pinned_title = QLabel(Translations["tag.is_pinned"]) + self.pinned_checkbox = QCheckBox() + self.pinned_checkbox.setFixedSize(22, 22) + self.pinned_checkbox.setStyleSheet(checkbox_style()) + self.pinned_layout.addWidget(self.pinned_checkbox) + self.pinned_layout.addWidget(self.pinned_title) + # Add Widgets to Layout ================================================ self.root_layout.addWidget(self.name_widget) self.root_layout.addWidget(self.shorthand_widget) @@ -249,6 +263,7 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: self.root_layout.addWidget(QLabel(header(Translations["tag.properties"], 3))) self.root_layout.addWidget(self.cat_widget) self.root_layout.addWidget(self.hidden_widget) + self.root_layout.addWidget(self.pinned_widget) self.set_tag(tag or Tag(name=Translations["tag.new"])) @@ -488,6 +503,7 @@ def set_tag(self, tag: Tag): self.cat_checkbox.setChecked(tag.is_category) self.hidden_checkbox.setChecked(tag.is_hidden) + self.pinned_checkbox.setChecked(tag.is_pinned) def _on_name_change(self): is_empty = not self.name_field.text().strip() @@ -505,6 +521,7 @@ def build_tag(self) -> Tag: tag.color_slug = self.tag_color_slug tag.is_category = self.cat_checkbox.isChecked() tag.is_hidden = self.hidden_checkbox.isChecked() + tag.is_pinned = self.pinned_checkbox.isChecked() logger.info("[BuildTag] Build Tag", tag_id=tag.id, tag_name=tag.name) return tag diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index 8bee65149..e571ae451 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -388,6 +388,7 @@ "tag.edit": "Edit Tag", "tag.is_category": "Is Category", "tag.is_hidden": "Is Hidden", + "tag.is_pinned": "Is Pinned", "tag.name": "Name", "tag.new": "New Tag", "tag.parent_tags": "Parent Tags",