diff --git a/bookwyrm/book_search.py b/bookwyrm/book_search.py new file mode 100644 index 00000000..8682b83e --- /dev/null +++ b/bookwyrm/book_search.py @@ -0,0 +1,120 @@ +""" using a bookwyrm instance as a source of book data """ +from functools import reduce +import operator + +from django.contrib.postgres.search import SearchRank, SearchQuery +from django.db.models import OuterRef, Subquery, F, Q + +from bookwyrm import models +from bookwyrm.connectors.abstract_connector import SearchResult +from bookwyrm.settings import MEDIA_FULL_URL + + +# pylint: disable=arguments-differ +def search(query, min_confidence=0, filters=None): + """search your local database""" + filters = filters or [] + if not query: + return [] + # first, try searching unqiue identifiers + results = search_identifiers(query, *filters) + if not results: + # then try searching title/author + results = search_title_author(query, min_confidence, *filters) + return results + + +def isbn_search(query): + """search your local database""" + if not query: + return [] + + filters = [{f: query} for f in ["isbn_10", "isbn_13"]] + results = models.Edition.objects.filter( + reduce(operator.or_, (Q(**f) for f in filters)) + ).distinct() + + # when there are multiple editions of the same work, pick the default. + # it would be odd for this to happen. + + default_editions = models.Edition.objects.filter( + parent_work=OuterRef("parent_work") + ).order_by("-edition_rank") + results = ( + results.annotate(default_id=Subquery(default_editions.values("id")[:1])).filter( + default_id=F("id") + ) + or results + ) + return results + + +def format_search_result(search_result): + """convert a book object into a search result object""" + cover = None + if search_result.cover: + cover = f"{MEDIA_FULL_URL}{search_result.cover}" + + return SearchResult( + title=search_result.title, + key=search_result.remote_id, + author=search_result.author_text, + year=search_result.published_date.year + if search_result.published_date + else None, + cover=cover, + confidence=search_result.rank if hasattr(search_result, "rank") else 1, + connector="", + ).json() + + +def search_identifiers(query, *filters): + """tries remote_id, isbn; defined as dedupe fields on the model""" + # pylint: disable=W0212 + or_filters = [ + {f.name: query} + for f in models.Edition._meta.get_fields() + if hasattr(f, "deduplication_field") and f.deduplication_field + ] + results = models.Edition.objects.filter( + *filters, reduce(operator.or_, (Q(**f) for f in or_filters)) + ).distinct() + if results.count() <= 1: + return results + + # when there are multiple editions of the same work, pick the default. + # it would be odd for this to happen. + default_editions = models.Edition.objects.filter( + parent_work=OuterRef("parent_work") + ).order_by("-edition_rank") + return ( + results.annotate(default_id=Subquery(default_editions.values("id")[:1])).filter( + default_id=F("id") + ) + or results + ) + + +def search_title_author(query, min_confidence, *filters): + """searches for title and author""" + query = SearchQuery(query, config="simple") | SearchQuery(query, config="english") + results = ( + models.Edition.objects.filter(*filters, search_vector=query) + .annotate(rank=SearchRank(F("search_vector"), query)) + .filter(rank__gt=min_confidence) + .order_by("-rank") + ) + + # when there are multiple editions of the same work, pick the closest + editions_of_work = results.values("parent_work__id").values_list("parent_work__id") + + # filter out multiple editions of the same work + for work_id in set(editions_of_work): + editions = results.filter(parent_work=work_id) + default = editions.order_by("-edition_rank").first() + default_rank = default.rank if default else 0 + # if mutliple books have the top rank, pick the default edition + if default_rank == editions.first().rank: + yield default + else: + yield editions.first() diff --git a/bookwyrm/templates/search/book.html b/bookwyrm/templates/search/book.html index 12164bb3..98590f20 100644 --- a/bookwyrm/templates/search/book.html +++ b/bookwyrm/templates/search/book.html @@ -60,7 +60,33 @@ diff --git a/bookwyrm/templates/snippets/search_result_text.html b/bookwyrm/templates/snippets/search_result_text.html deleted file mode 100644 index 973f1f5b..00000000 --- a/bookwyrm/templates/snippets/search_result_text.html +++ /dev/null @@ -1,37 +0,0 @@ -{% load i18n %} -
-
- {% include 'snippets/book_cover.html' with book=result cover_class='is-w-xs is-h-xs' external_path=True %} -
- -
-

- - {{ result.title }} - -

-

- {% if result.author %} - {{ result.author }} - {% endif %} - - {% if result.year %} - ({{ result.year }}) - {% endif %} -

- -
- {% csrf_token %} - - - - -
-
-
diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py index 6acf75cb..15086a66 100644 --- a/bookwyrm/urls.py +++ b/bookwyrm/urls.py @@ -221,6 +221,7 @@ urlpatterns = [ ), # search re_path(r"^search/?$", views.Search.as_view(), name="search"), + re_path(r"^search.json/?$", views.Search.as_view(), name="search"), # imports re_path(r"^import/?$", views.Import.as_view(), name="import"), re_path(r"^import/(\d+)/?$", views.ImportStatus.as_view(), name="import-status"), diff --git a/bookwyrm/views/helpers.py b/bookwyrm/views/helpers.py index b1e5f68c..aa26ae18 100644 --- a/bookwyrm/views/helpers.py +++ b/bookwyrm/views/helpers.py @@ -32,7 +32,9 @@ def get_user_from_username(viewer, username): def is_api_request(request): """check whether a request is asking for html or data""" - return "json" in request.headers.get("Accept", "") or request.path[-5:] == ".json" + return "json" in request.headers.get("Accept", "") or re.match( + r".*\.json/?$", request.path + ) def is_bookwyrm_request(request): diff --git a/bookwyrm/views/search.py b/bookwyrm/views/search.py index 6c9593a1..60a1879a 100644 --- a/bookwyrm/views/search.py +++ b/bookwyrm/views/search.py @@ -10,7 +10,7 @@ from django.views import View from bookwyrm import models from bookwyrm.connectors import connector_manager -from bookwyrm.book_search import search +from bookwyrm.book_search import search, format_search_result from bookwyrm.settings import PAGE_LENGTH from bookwyrm.utils import regex from .helpers import is_api_request, privacy_filter @@ -33,7 +33,9 @@ class Search(View): if is_api_request(request): # only return local book results via json so we don't cascade book_results = search(query, min_confidence=min_confidence) - return JsonResponse([r.json() for r in book_results], safe=False) + return JsonResponse( + [format_search_result(r) for r in book_results], safe=False + ) if query and not search_type: search_type = "user" if "@" in query else "book"