diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index f34724429..a5474c1cc 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -692,13 +692,15 @@ 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]: + def get_paths(self, glob: str | None = None, limit: int = -1) -> list[str]: path_strings: list[str] = [] with Session(self.engine) as session: - paths = session.scalars(select(Entry.path)).unique() + if limit > 0: + paths = session.scalars(select(Entry.path).limit(limit)).unique() + else: + paths = session.scalars(select(Entry.path)).unique() path_strings = list(map(lambda x: x.as_posix(), paths)) - - return path_strings + return path_strings def search_library( self, diff --git a/tagstudio/src/core/library/alchemy/visitors.py b/tagstudio/src/core/library/alchemy/visitors.py index 8622f1fad..1ba7b467e 100644 --- a/tagstudio/src/core/library/alchemy/visitors.py +++ b/tagstudio/src/core/library/alchemy/visitors.py @@ -2,11 +2,13 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio +import re from typing import TYPE_CHECKING import structlog from sqlalchemy import ColumnElement, and_, distinct, func, or_, select, text from sqlalchemy.orm import Session +from sqlalchemy.sql.operators import ilike_op from src.core.media_types import FILETYPE_EQUIVALENTS, MediaCategories from src.core.query_lang import BaseVisitor from src.core.query_lang.ast import ANDList, Constraint, ConstraintType, Not, ORList, Property @@ -14,7 +16,7 @@ from .joins import TagEntry from .models import Entry, Tag, TagAlias -# workaround to have autocompletion in the Editor +# Only import for type checking/autocompletion, will not be imported at runtime. if TYPE_CHECKING: from .library import Library else: @@ -97,7 +99,29 @@ def visit_constraint(self, node: Constraint) -> ColumnElement[bool]: elif node.type == ConstraintType.TagID: return self.__entry_matches_tag_ids([int(node.value)]) elif node.type == ConstraintType.Path: - return Entry.path.op("GLOB")(node.value) + ilike = False + glob = False + + # Smartcase check + if node.value == node.value.lower(): + ilike = True + if node.value.startswith("*") or node.value.endswith("*"): + glob = True + + if ilike and glob: + logger.info("ConstraintType.Path", ilike=True, glob=True) + return func.lower(Entry.path).op("GLOB")(f"{node.value.lower()}") + elif ilike: + logger.info("ConstraintType.Path", ilike=True, glob=False) + return ilike_op(Entry.path, f"%{node.value}%") + elif glob: + logger.info("ConstraintType.Path", ilike=False, glob=True) + return Entry.path.op("GLOB")(node.value) + else: + logger.info( + "ConstraintType.Path", ilike=False, glob=False, re=re.escape(node.value) + ) + return Entry.path.regexp_match(re.escape(node.value)) elif node.type == ConstraintType.MediaType: extensions: set[str] = set[str]() for media_cat in MediaCategories.ALL_CATEGORIES: diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 3e3c83904..29d3ad156 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -1506,7 +1506,9 @@ def update_completions_list(self, text: str) -> None: elif query_type == "tag_id": completion_list = list(map(lambda x: prefix + "tag_id:" + str(x.id), self.lib.tags)) elif query_type == "path": - completion_list = list(map(lambda x: prefix + "path:" + x, self.lib.get_paths())) + completion_list = list( + map(lambda x: prefix + "path:" + x, self.lib.get_paths(limit=100)) + ) elif query_type == "mediatype": single_word_completions = map( lambda x: prefix + "mediatype:" + x.name, diff --git a/tagstudio/tests/test_library.py b/tagstudio/tests/test_library.py index eb8c44011..ed1f5b2e9 100644 --- a/tagstudio/tests/test_library.py +++ b/tagstudio/tests/test_library.py @@ -414,6 +414,24 @@ class TestPrefs(DefaultEnum): assert TestPrefs.BAR.value +def test_path_search_ilike(library: Library): + results = library.search_library(FilterState.from_path("bar.md")) + assert results.total_count == 1 + assert len(results.items) == 1 + + +def test_path_search_like(library: Library): + results = library.search_library(FilterState.from_path("BAR.MD")) + assert results.total_count == 0 + assert len(results.items) == 0 + + +def test_path_search_default_with_sep(library: Library): + results = library.search_library(FilterState.from_path("one/two")) + assert results.total_count == 1 + assert len(results.items) == 1 + + def test_path_search_glob_after(library: Library): results = library.search_library(FilterState.from_path("foo*")) assert results.total_count == 1 @@ -432,6 +450,50 @@ def test_path_search_glob_both_sides(library: Library): assert len(results.items) == 1 +def test_path_search_ilike_glob_equality(library: Library): + results_ilike = library.search_library(FilterState.from_path("one/two")) + results_glob = library.search_library(FilterState.from_path("*one/two*")) + assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items] + results_ilike, results_glob = None, None + + results_ilike = library.search_library(FilterState.from_path("bar.md")) + results_glob = library.search_library(FilterState.from_path("*bar.md*")) + assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items] + results_ilike, results_glob = None, None + + results_ilike = library.search_library(FilterState.from_path("bar")) + results_glob = library.search_library(FilterState.from_path("*bar*")) + assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items] + results_ilike, results_glob = None, None + + results_ilike = library.search_library(FilterState.from_path("bar.md")) + results_glob = library.search_library(FilterState.from_path("*bar.md*")) + assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items] + results_ilike, results_glob = None, None + + +def test_path_search_like_glob_equality(library: Library): + results_ilike = library.search_library(FilterState.from_path("ONE/two")) + results_glob = library.search_library(FilterState.from_path("*ONE/two*")) + assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items] + results_ilike, results_glob = None, None + + results_ilike = library.search_library(FilterState.from_path("BAR.MD")) + results_glob = library.search_library(FilterState.from_path("*BAR.MD*")) + assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items] + results_ilike, results_glob = None, None + + results_ilike = library.search_library(FilterState.from_path("BAR.MD")) + results_glob = library.search_library(FilterState.from_path("*bar.md*")) + assert [e.id for e in results_ilike.items] != [e.id for e in results_glob.items] + results_ilike, results_glob = None, None + + results_ilike = library.search_library(FilterState.from_path("bar.md")) + results_glob = library.search_library(FilterState.from_path("*BAR.MD*")) + assert [e.id for e in results_ilike.items] != [e.id for e in results_glob.items] + results_ilike, results_glob = None, None + + @pytest.mark.parametrize(["filetype", "num_of_filetype"], [("md", 1), ("txt", 1), ("png", 0)]) def test_filetype_search(library, filetype, num_of_filetype): results = library.search_library(FilterState.from_filetype(filetype))