diff --git a/api/funkwhale_api/common/fields.py b/api/funkwhale_api/common/fields.py index 890aee42566ffd7883857f90ac1a7ec70f19358b..a0f10efe3a22cb2c903336a073b905f06994e69c 100644 --- a/api/funkwhale_api/common/fields.py +++ b/api/funkwhale_api/common/fields.py @@ -1,7 +1,7 @@ import django_filters from django.db import models -from funkwhale_api.music import utils +from . import search PRIVACY_LEVEL_CHOICES = [ ("me", "Only me"), @@ -34,5 +34,17 @@ class SearchFilter(django_filters.CharFilter): def filter(self, qs, value): if not value: return qs - query = utils.get_query(value, self.search_fields) + query = search.get_query(value, self.search_fields) return qs.filter(query) + + +class SmartSearchFilter(django_filters.CharFilter): + def __init__(self, *args, **kwargs): + self.config = kwargs.pop("config") + super().__init__(*args, **kwargs) + + def filter(self, qs, value): + if not value: + return qs + cleaned = self.config.clean(value) + return search.apply(qs, cleaned) diff --git a/api/funkwhale_api/common/search.py b/api/funkwhale_api/common/search.py new file mode 100644 index 0000000000000000000000000000000000000000..5fc6f6804ca4c5e5289fb224c6e9adce503be978 --- /dev/null +++ b/api/funkwhale_api/common/search.py @@ -0,0 +1,130 @@ +import re + +from django.db.models import Q + + +QUERY_REGEX = re.compile('(((?P<key>\w+):)?(?P<value>"[^"]+"|[\S]+))') + + +def parse_query(query): + """ + Given a search query such as "hello is:issue status:opened", + returns a list of dictionnaries discribing each query token + """ + matches = [m.groupdict() for m in QUERY_REGEX.finditer(query.lower())] + for m in matches: + if m["value"].startswith('"') and m["value"].endswith('"'): + m["value"] = m["value"][1:-1] + return matches + + +def normalize_query( + query_string, + findterms=re.compile(r'"([^"]+)"|(\S+)').findall, + normspace=re.compile(r"\s{2,}").sub, +): + """ Splits the query string in invidual keywords, getting rid of unecessary spaces + and grouping quoted words together. + Example: + + >>> normalize_query(' some random words "with quotes " and spaces') + ['some', 'random', 'words', 'with quotes', 'and', 'spaces'] + + """ + return [normspace(" ", (t[0] or t[1]).strip()) for t in findterms(query_string)] + + +def get_query(query_string, search_fields): + """ Returns a query, that is a combination of Q objects. That combination + aims to search keywords within a model by testing the given search fields. + + """ + query = None # Query to search for every search term + terms = normalize_query(query_string) + for term in terms: + or_query = None # Query to search for a given term in each field + for field_name in search_fields: + q = Q(**{"%s__icontains" % field_name: term}) + if or_query is None: + or_query = q + else: + or_query = or_query | q + if query is None: + query = or_query + else: + query = query & or_query + return query + + +def filter_tokens(tokens, valid): + return [t for t in tokens if t["key"] in valid] + + +def apply(qs, config_data): + for k in ["filter_query", "search_query"]: + q = config_data.get(k) + if q: + qs = qs.filter(q) + return qs + + +class SearchConfig: + def __init__(self, search_fields={}, filter_fields={}, types=[]): + self.filter_fields = filter_fields + self.search_fields = search_fields + self.types = types + + def clean(self, query): + tokens = parse_query(query) + cleaned_data = {} + + cleaned_data["types"] = self.clean_types(filter_tokens(tokens, ["is"])) + cleaned_data["search_query"] = self.clean_search_query( + filter_tokens(tokens, [None, "in"]) + ) + unhandled_tokens = [t for t in tokens if t["key"] not in [None, "is", "in"]] + cleaned_data["filter_query"] = self.clean_filter_query(unhandled_tokens) + return cleaned_data + + def clean_search_query(self, tokens): + if not self.search_fields or not tokens: + return + + fields_subset = { + f for t in filter_tokens(tokens, ["in"]) for f in t["value"].split(",") + } or set(self.search_fields.keys()) + fields_subset = set(self.search_fields.keys()) & fields_subset + to_fields = [self.search_fields[k]["to"] for k in fields_subset] + query_string = " ".join([t["value"] for t in filter_tokens(tokens, [None])]) + return get_query(query_string, sorted(to_fields)) + + def clean_filter_query(self, tokens): + if not self.filter_fields or not tokens: + return + + matching = [t for t in tokens if t["key"] in self.filter_fields] + queries = [ + Q(**{self.filter_fields[t["key"]]["to"]: t["value"]}) for t in matching + ] + query = None + for q in queries: + if not query: + query = q + else: + query = query & q + return query + + def clean_types(self, tokens): + if not self.types: + return [] + + if not tokens: + # no filtering on type, we return all types + return [t for key, t in self.types] + types = [] + for token in tokens: + for key, t in self.types: + if key.lower() == token["value"]: + types.append(t) + + return types diff --git a/api/funkwhale_api/federation/filters.py b/api/funkwhale_api/federation/filters.py index 3b5bfd7395782f2285a8b7451888078119e8cf10..ff7575ba5a006aee54826ed86ea62875dc215e15 100644 --- a/api/funkwhale_api/federation/filters.py +++ b/api/funkwhale_api/federation/filters.py @@ -1,6 +1,7 @@ import django_filters from funkwhale_api.common import fields +from funkwhale_api.common import search from . import models @@ -23,8 +24,21 @@ class LibraryFilter(django_filters.FilterSet): class LibraryTrackFilter(django_filters.FilterSet): library = django_filters.CharFilter("library__uuid") status = django_filters.CharFilter(method="filter_status") - q = fields.SearchFilter( - search_fields=["artist_name", "title", "album_title", "library__actor__domain"] + q = fields.SmartSearchFilter( + config=search.SearchConfig( + search_fields={ + "domain": {"to": "library__actor__domain"}, + "artist": {"to": "artist_name"}, + "album": {"to": "album_title"}, + "title": {"to": "title"}, + }, + filter_fields={ + "domain": {"to": "library__actor__domain"}, + "artist": {"to": "artist_name__iexact"}, + "album": {"to": "album_title__iexact"}, + "title": {"to": "title__iexact"}, + }, + ) ) def filter_status(self, queryset, field_name, value): diff --git a/api/funkwhale_api/music/utils.py b/api/funkwhale_api/music/utils.py index 3080c1c6c056b84aa29842d1040087d3f0492ad6..30f62f3485838f2a6b626e5d0b24a44144fb885c 100644 --- a/api/funkwhale_api/music/utils.py +++ b/api/funkwhale_api/music/utils.py @@ -1,47 +1,9 @@ import mimetypes -import re import magic import mutagen -from django.db.models import Q - - -def normalize_query( - query_string, - findterms=re.compile(r'"([^"]+)"|(\S+)').findall, - normspace=re.compile(r"\s{2,}").sub, -): - """ Splits the query string in invidual keywords, getting rid of unecessary spaces - and grouping quoted words together. - Example: - - >>> normalize_query(' some random words "with quotes " and spaces') - ['some', 'random', 'words', 'with quotes', 'and', 'spaces'] - - """ - return [normspace(" ", (t[0] or t[1]).strip()) for t in findterms(query_string)] - - -def get_query(query_string, search_fields): - """ Returns a query, that is a combination of Q objects. That combination - aims to search keywords within a model by testing the given search fields. - - """ - query = None # Query to search for every search term - terms = normalize_query(query_string) - for term in terms: - or_query = None # Query to search for a given term in each field - for field_name in search_fields: - q = Q(**{"%s__icontains" % field_name: term}) - if or_query is None: - or_query = q - else: - or_query = or_query | q - if query is None: - query = or_query - else: - query = query & or_query - return query + +from funkwhale_api.common.search import normalize_query, get_query # noqa def guess_mimetype(f): diff --git a/api/tests/common/test_search.py b/api/tests/common/test_search.py new file mode 100644 index 0000000000000000000000000000000000000000..e5be7bc900f0d215f27909603390dd115fbb68d8 --- /dev/null +++ b/api/tests/common/test_search.py @@ -0,0 +1,83 @@ +import pytest + +from django.db.models import Q + +from funkwhale_api.common import search +from funkwhale_api.music import models as music_models + + +@pytest.mark.parametrize( + "query,expected", + [ + ("", [music_models.Album, music_models.Artist]), + ("is:album", [music_models.Album]), + ("is:artist is:album", [music_models.Artist, music_models.Album]), + ], +) +def test_search_config_is(query, expected): + s = search.SearchConfig( + types=[("album", music_models.Album), ("artist", music_models.Artist)] + ) + + cleaned = s.clean(query) + assert cleaned["types"] == expected + + +@pytest.mark.parametrize( + "query,expected", + [ + ("", None), + ("hello world", search.get_query("hello world", ["f1", "f2", "f3"])), + ("hello in:field2", search.get_query("hello", ["f2"])), + ("hello in:field1,field2", search.get_query("hello", ["f1", "f2"])), + ], +) +def test_search_config_query(query, expected): + s = search.SearchConfig( + search_fields={ + "field1": {"to": "f1"}, + "field2": {"to": "f2"}, + "field3": {"to": "f3"}, + } + ) + + cleaned = s.clean(query) + assert cleaned["search_query"] == expected + + +@pytest.mark.parametrize( + "query,expected", + [ + ("", None), + ("status:pending", Q(status="pending")), + ('user:"silent bob"', Q(user__username__iexact="silent bob")), + ( + "user:me status:pending", + Q(user__username__iexact="me") & Q(status="pending"), + ), + ], +) +def test_search_config_filter(query, expected): + s = search.SearchConfig( + filter_fields={ + "user": {"to": "user__username__iexact"}, + "status": {"to": "status"}, + } + ) + + cleaned = s.clean(query) + assert cleaned["filter_query"] == expected + + +def test_apply(): + cleaned = { + "filter_query": Q(batch__submitted_by__username__iexact="me"), + "search_query": Q(source="test"), + } + result = search.apply(music_models.ImportJob.objects.all(), cleaned) + + assert str(result.query) == str( + music_models.ImportJob.objects.filter( + Q(batch__submitted_by__username__iexact="me"), Q(source="test") + ).query + ) diff --git a/changes/changelog.d/344.feature b/changes/changelog.d/344.feature new file mode 100644 index 0000000000000000000000000000000000000000..6dc146673f3cda89b48f2aeae19a7a18272a2b4a --- /dev/null +++ b/changes/changelog.d/344.feature @@ -0,0 +1,22 @@ +Implemented a basic but functionnal Github-like search on federated tracks list (#344) + + +Improved search on federated tracks list +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Having a powerful but easy-to-use search is important but difficult to achieve, especially +if you do not want to have a real complex search interface. + +Github does a pretty good job with that, using a structured but simple query system +(See https://help.github.com/articles/searching-issues-and-pull-requests/#search-only-issues-or-pull-requests). + +This release implements a limited but working subset of this query system. You can use it only on the federated +tracks list (/manage/federation/tracks) at the moment, but depending on feedback it will be rolled-out on other pages as well. + +This is the type of query you can run: + +- ``hello world``: search for "hello" and "world" in all the available fields +- ``hello in:artist`` search for results where artist name is "hello" +- ``spring in:artist,album`` search for results where artist name or album title contain "spring" +- ``artist:hello`` search for results where artist name equals "hello" +- ``artist:"System of a Down" domain:instance.funkwhale`` search for results where artist name equals "System of a Down" and inside "instance.funkwhale" library diff --git a/front/Dockerfile b/front/Dockerfile index 3d4c65e6418514a3db595bce3896655edaac82a7..69771c326d6fc61ae8a9b57e590d9a5c6b54eda6 100644 --- a/front/Dockerfile +++ b/front/Dockerfile @@ -1,5 +1,8 @@ FROM node:9 +# needed to compile translations +RUN curl -L -o /usr/local/bin/jq https://github.com/stedolan/jq/releases/download/jq-1.5/jq-linux64 && chmod +x /usr/local/bin/jq + EXPOSE 8080 WORKDIR /app/ ADD package.json . diff --git a/front/src/App.vue b/front/src/App.vue index a6da038b252dd83cd97d807ffa3176f736887649..8e1abae7ff65c05c19f1cf5fa2931f42c8adb4e5 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -236,6 +236,7 @@ html, body { .discrete.link { color: rgba(0, 0, 0, 0.87); + cursor: pointer; } .floated.buttons .button ~ .dropdown { diff --git a/front/src/components/federation/LibraryTrackTable.vue b/front/src/components/federation/LibraryTrackTable.vue index 645663964e86be71b4d7ad9c8e4d0149d056c291..082629197adbef65232b27184add7bc410dbece5 100644 --- a/front/src/components/federation/LibraryTrackTable.vue +++ b/front/src/components/federation/LibraryTrackTable.vue @@ -2,7 +2,7 @@ <div> <div class="ui inline form"> <div class="fields"> - <div class="ui field"> + <div class="ui six wide field"> <label><translate>Search</translate></label> <input type="text" v-model="search" :placeholder="labels.searchPlaceholder" /> </div> @@ -56,16 +56,16 @@ <span :title="scope.obj.title">{{ scope.obj.title|truncate(30) }}</span> </td> <td> - <span :title="scope.obj.artist_name">{{ scope.obj.artist_name|truncate(30) }}</span> + <span class="discrete link" @click="updateSearch({key: 'artist', value: scope.obj.artist_name})" :title="scope.obj.artist_name">{{ scope.obj.artist_name|truncate(30) }}</span> </td> <td> - <span :title="scope.obj.album_title">{{ scope.obj.album_title|truncate(20) }}</span> + <span class="discrete link" @click="updateSearch({key: 'album', value: scope.obj.album_title})" :title="scope.obj.album_title">{{ scope.obj.album_title|truncate(20) }}</span> </td> <td> <human-date :date="scope.obj.published_date"></human-date> </td> <td v-if="showLibrary"> - {{ scope.obj.library.actor.domain }} + <span class="discrete link" @click="updateSearch({key: 'domain', value: scope.obj.library.actor.domain})">{{ scope.obj.library.actor.domain }}</span> </td> </template> </action-table> @@ -120,6 +120,12 @@ export default { this.fetchData() }, methods: { + updateSearch ({key, value}) { + if (value.indexOf(' ') > -1) { + value = `"${value}"` + } + this.search = `${key}:${value}` + }, fetchData () { let params = _.merge({ 'page': this.page,