Skip to content

Commit

Permalink
feat: add smartcase and globless path searches (#743)
Browse files Browse the repository at this point in the history
* fix: return path_strings in session

* feat: add smartcase and globless path search

Known issues: failing tests, sluggish autocomplete

* fix: all operational searches

* fix: limit path autocomplete to 100 items

* tests: add test cases
  • Loading branch information
CyanVoxel authored Feb 10, 2025
1 parent 319ef9a commit 297fdf2
Show file tree
Hide file tree
Showing 4 changed files with 97 additions and 7 deletions.
10 changes: 6 additions & 4 deletions tagstudio/src/core/library/alchemy/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -716,13 +716,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,
Expand Down
28 changes: 26 additions & 2 deletions tagstudio/src/core/library/alchemy/visitors.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,21 @@
# 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

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:
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion tagstudio/src/qt/ts_qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -1505,7 +1505,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,
Expand Down
62 changes: 62 additions & 0 deletions tagstudio/tests/test_library.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
Expand Down

0 comments on commit 297fdf2

Please sign in to comment.