Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add smartcase and globless path searches #743

Merged
merged 5 commits into from
Feb 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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,
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 @@ -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,
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