From bec513f558437278f6dc7c0c083876b0a7736d07 Mon Sep 17 00:00:00 2001 From: python357-1 <30739625+python357-1@users.noreply.github.com> Date: Mon, 18 Nov 2024 12:45:51 -0600 Subject: [PATCH] feat: add autocomplete for search engine (#586) * feat: add autocomplete for mediatype, filetype, path, tag, and tag_id searches * fix: address issues brought up in review * fix: fix mypy issue * fix: fix mypy issues for real this time --- tagstudio/src/core/library/alchemy/library.py | 7 +++ tagstudio/src/qt/main_window.py | 9 ++- tagstudio/src/qt/ts_qt.py | 59 +++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index 3a1a7678c..9badbe7a0 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -395,6 +395,13 @@ def has_path_entry(self, path: Path) -> bool: with Session(self.engine) as session: return session.query(exists().where(Entry.path == path)).scalar() + def get_paths(self, glob: str | None = None) -> list[str]: + with Session(self.engine) as session: + paths = session.scalars(select(Entry.path)).unique() + + path_strings: list[str] = list(map(lambda x: x.as_posix(), paths)) + return path_strings + def search_library( self, search: FilterState, diff --git a/tagstudio/src/qt/main_window.py b/tagstudio/src/qt/main_window.py index d3274c7ee..621ed9cf3 100644 --- a/tagstudio/src/qt/main_window.py +++ b/tagstudio/src/qt/main_window.py @@ -15,13 +15,13 @@ import logging import typing -from PySide6.QtCore import (QCoreApplication, QMetaObject, QRect,QSize, Qt) +from PySide6.QtCore import (QCoreApplication, QMetaObject, QRect,QSize, Qt, QStringListModel) from PySide6.QtGui import QFont from PySide6.QtWidgets import (QComboBox, QFrame, QGridLayout, QHBoxLayout, QVBoxLayout, QLayout, QLineEdit, QMainWindow, QPushButton, QScrollArea, QSizePolicy, QStatusBar, QWidget, QSplitter, QCheckBox, - QSpacerItem) + QSpacerItem, QCompleter) from src.qt.pagination import Pagination from src.qt.widgets.landing import LandingWidget @@ -167,6 +167,11 @@ def setupUi(self, MainWindow): font2.setBold(False) self.searchField.setFont(font2) + self.searchFieldCompletionList = QStringListModel() + self.searchFieldCompleter = QCompleter(self.searchFieldCompletionList, self.searchField) + self.searchFieldCompleter.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + self.searchField.setCompleter(self.searchFieldCompleter) + self.horizontalLayout_2.addWidget(self.searchField) self.searchButton = QPushButton(self.centralwidget) diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index a63e970e5..c780a6a6b 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -11,6 +11,7 @@ import dataclasses import math import os +import re import sys import time import webbrowser @@ -72,6 +73,7 @@ ) from src.core.library.alchemy.fields import _FieldID from src.core.library.alchemy.library import LibraryStatus +from src.core.media_types import MediaCategories from src.core.ts_core import TagStudioCore from src.core.utils.refresh_dir import RefreshDirTracker from src.core.utils.web import strip_web_protocol @@ -445,6 +447,8 @@ def create_folders_tags_modal(): menu_bar.addMenu(window_menu) menu_bar.addMenu(help_menu) + self.main_window.searchField.textChanged.connect(self.update_completions_list) + self.preview_panel = PreviewPanel(self.lib, self) splitter = self.main_window.splitter splitter.addWidget(self.preview_panel) @@ -949,6 +953,61 @@ def select_item(self, grid_index: int, append: bool, bridge: bool): def set_macro_menu_viability(self): self.autofill_action.setDisabled(not self.selected) + def update_completions_list(self, text: str) -> None: + matches = re.search(r"(mediatype|filetype|path|tag):(\"?[A-Za-z0-9\ \t]+\"?)?", text) + + completion_list: list[str] = [] + if len(text) < 3: + completion_list = ["mediatype:", "filetype:", "path:", "tag:"] + self.main_window.searchFieldCompletionList.setStringList(completion_list) + + if not matches: + return + + query_type: str + query_value: str | None + query_type, query_value = matches.groups() + + if not query_value: + return + + if query_type == "tag": + completion_list = list(map(lambda x: "tag:" + x.name, self.lib.tags)) + elif query_type == "path": + completion_list = list(map(lambda x: "path:" + x, self.lib.get_paths())) + elif query_type == "mediatype": + single_word_completions = map( + lambda x: "mediatype:" + x.name, + filter(lambda y: " " not in y.name, MediaCategories.ALL_CATEGORIES), + ) + single_word_completions_quoted = map( + lambda x: 'mediatype:"' + x.name + '"', + filter(lambda y: " " not in y.name, MediaCategories.ALL_CATEGORIES), + ) + multi_word_completions = map( + lambda x: 'mediatype:"' + x.name + '"', + filter(lambda y: " " in y.name, MediaCategories.ALL_CATEGORIES), + ) + + all_completions = [ + single_word_completions, + single_word_completions_quoted, + multi_word_completions, + ] + completion_list = [j for i in all_completions for j in i] + elif query_type == "filetype": + extensions_list: set[str] = set() + for media_cat in MediaCategories.ALL_CATEGORIES: + extensions_list = extensions_list | media_cat.extensions + completion_list = list(map(lambda x: "filetype:" + x.replace(".", ""), extensions_list)) + + update_completion_list: bool = ( + completion_list != self.main_window.searchFieldCompletionList.stringList() + or self.main_window.searchFieldCompletionList == [] + ) + if update_completion_list: + self.main_window.searchFieldCompletionList.setStringList(completion_list) + def update_thumbs(self): """Update search thumbnails.""" # start_time = time.time()