Skip to content

Commit 297fdf2

Browse files
authored
feat: add smartcase and globless path searches (#743)
* 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
1 parent 319ef9a commit 297fdf2

File tree

4 files changed

+97
-7
lines changed

4 files changed

+97
-7
lines changed

tagstudio/src/core/library/alchemy/library.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -716,13 +716,15 @@ def has_path_entry(self, path: Path) -> bool:
716716
with Session(self.engine) as session:
717717
return session.query(exists().where(Entry.path == path)).scalar()
718718

719-
def get_paths(self, glob: str | None = None) -> list[str]:
719+
def get_paths(self, glob: str | None = None, limit: int = -1) -> list[str]:
720720
path_strings: list[str] = []
721721
with Session(self.engine) as session:
722-
paths = session.scalars(select(Entry.path)).unique()
722+
if limit > 0:
723+
paths = session.scalars(select(Entry.path).limit(limit)).unique()
724+
else:
725+
paths = session.scalars(select(Entry.path)).unique()
723726
path_strings = list(map(lambda x: x.as_posix(), paths))
724-
725-
return path_strings
727+
return path_strings
726728

727729
def search_library(
728730
self,

tagstudio/src/core/library/alchemy/visitors.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,21 @@
22
# Licensed under the GPL-3.0 License.
33
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
44

5+
import re
56
from typing import TYPE_CHECKING
67

78
import structlog
89
from sqlalchemy import ColumnElement, and_, distinct, func, or_, select, text
910
from sqlalchemy.orm import Session
11+
from sqlalchemy.sql.operators import ilike_op
1012
from src.core.media_types import FILETYPE_EQUIVALENTS, MediaCategories
1113
from src.core.query_lang import BaseVisitor
1214
from src.core.query_lang.ast import ANDList, Constraint, ConstraintType, Not, ORList, Property
1315

1416
from .joins import TagEntry
1517
from .models import Entry, Tag, TagAlias
1618

17-
# workaround to have autocompletion in the Editor
19+
# Only import for type checking/autocompletion, will not be imported at runtime.
1820
if TYPE_CHECKING:
1921
from .library import Library
2022
else:
@@ -97,7 +99,29 @@ def visit_constraint(self, node: Constraint) -> ColumnElement[bool]:
9799
elif node.type == ConstraintType.TagID:
98100
return self.__entry_matches_tag_ids([int(node.value)])
99101
elif node.type == ConstraintType.Path:
100-
return Entry.path.op("GLOB")(node.value)
102+
ilike = False
103+
glob = False
104+
105+
# Smartcase check
106+
if node.value == node.value.lower():
107+
ilike = True
108+
if node.value.startswith("*") or node.value.endswith("*"):
109+
glob = True
110+
111+
if ilike and glob:
112+
logger.info("ConstraintType.Path", ilike=True, glob=True)
113+
return func.lower(Entry.path).op("GLOB")(f"{node.value.lower()}")
114+
elif ilike:
115+
logger.info("ConstraintType.Path", ilike=True, glob=False)
116+
return ilike_op(Entry.path, f"%{node.value}%")
117+
elif glob:
118+
logger.info("ConstraintType.Path", ilike=False, glob=True)
119+
return Entry.path.op("GLOB")(node.value)
120+
else:
121+
logger.info(
122+
"ConstraintType.Path", ilike=False, glob=False, re=re.escape(node.value)
123+
)
124+
return Entry.path.regexp_match(re.escape(node.value))
101125
elif node.type == ConstraintType.MediaType:
102126
extensions: set[str] = set[str]()
103127
for media_cat in MediaCategories.ALL_CATEGORIES:

tagstudio/src/qt/ts_qt.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1505,7 +1505,9 @@ def update_completions_list(self, text: str) -> None:
15051505
elif query_type == "tag_id":
15061506
completion_list = list(map(lambda x: prefix + "tag_id:" + str(x.id), self.lib.tags))
15071507
elif query_type == "path":
1508-
completion_list = list(map(lambda x: prefix + "path:" + x, self.lib.get_paths()))
1508+
completion_list = list(
1509+
map(lambda x: prefix + "path:" + x, self.lib.get_paths(limit=100))
1510+
)
15091511
elif query_type == "mediatype":
15101512
single_word_completions = map(
15111513
lambda x: prefix + "mediatype:" + x.name,

tagstudio/tests/test_library.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,24 @@ class TestPrefs(DefaultEnum):
414414
assert TestPrefs.BAR.value
415415

416416

417+
def test_path_search_ilike(library: Library):
418+
results = library.search_library(FilterState.from_path("bar.md"))
419+
assert results.total_count == 1
420+
assert len(results.items) == 1
421+
422+
423+
def test_path_search_like(library: Library):
424+
results = library.search_library(FilterState.from_path("BAR.MD"))
425+
assert results.total_count == 0
426+
assert len(results.items) == 0
427+
428+
429+
def test_path_search_default_with_sep(library: Library):
430+
results = library.search_library(FilterState.from_path("one/two"))
431+
assert results.total_count == 1
432+
assert len(results.items) == 1
433+
434+
417435
def test_path_search_glob_after(library: Library):
418436
results = library.search_library(FilterState.from_path("foo*"))
419437
assert results.total_count == 1
@@ -432,6 +450,50 @@ def test_path_search_glob_both_sides(library: Library):
432450
assert len(results.items) == 1
433451

434452

453+
def test_path_search_ilike_glob_equality(library: Library):
454+
results_ilike = library.search_library(FilterState.from_path("one/two"))
455+
results_glob = library.search_library(FilterState.from_path("*one/two*"))
456+
assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items]
457+
results_ilike, results_glob = None, None
458+
459+
results_ilike = library.search_library(FilterState.from_path("bar.md"))
460+
results_glob = library.search_library(FilterState.from_path("*bar.md*"))
461+
assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items]
462+
results_ilike, results_glob = None, None
463+
464+
results_ilike = library.search_library(FilterState.from_path("bar"))
465+
results_glob = library.search_library(FilterState.from_path("*bar*"))
466+
assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items]
467+
results_ilike, results_glob = None, None
468+
469+
results_ilike = library.search_library(FilterState.from_path("bar.md"))
470+
results_glob = library.search_library(FilterState.from_path("*bar.md*"))
471+
assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items]
472+
results_ilike, results_glob = None, None
473+
474+
475+
def test_path_search_like_glob_equality(library: Library):
476+
results_ilike = library.search_library(FilterState.from_path("ONE/two"))
477+
results_glob = library.search_library(FilterState.from_path("*ONE/two*"))
478+
assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items]
479+
results_ilike, results_glob = None, None
480+
481+
results_ilike = library.search_library(FilterState.from_path("BAR.MD"))
482+
results_glob = library.search_library(FilterState.from_path("*BAR.MD*"))
483+
assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items]
484+
results_ilike, results_glob = None, None
485+
486+
results_ilike = library.search_library(FilterState.from_path("BAR.MD"))
487+
results_glob = library.search_library(FilterState.from_path("*bar.md*"))
488+
assert [e.id for e in results_ilike.items] != [e.id for e in results_glob.items]
489+
results_ilike, results_glob = None, None
490+
491+
results_ilike = library.search_library(FilterState.from_path("bar.md"))
492+
results_glob = library.search_library(FilterState.from_path("*BAR.MD*"))
493+
assert [e.id for e in results_ilike.items] != [e.id for e in results_glob.items]
494+
results_ilike, results_glob = None, None
495+
496+
435497
@pytest.mark.parametrize(["filetype", "num_of_filetype"], [("md", 1), ("txt", 1), ("png", 0)])
436498
def test_filetype_search(library, filetype, num_of_filetype):
437499
results = library.search_library(FilterState.from_filetype(filetype))

0 commit comments

Comments
 (0)