Repository: bookwyrm-social/bookwyrm

Issue:
[Discussion] Re-organize the search (results)
This is a bit of loud thinking, but I thought about this every time I'm searching anything so maybe others have opinions on this too?

**Is your feature request related to a problem? Please describe.**
The user usually knows whether they are looking for a book, a user or a list. The results of what they are not looking for are not necessary.
Searches take pretty long currently. I assume this is because many different searches are handled at the same time, even though in most cases, only some of them are necessary.

**Describe the solution you'd like**
Ask the user what they are looking for, before the search is executed. I'm not sure what a good ui solution would look like here. My first idea is to have 3 search buttons in a row or a dropdown where the appropriate mode is chosen.
![A mockup where the bookwyrm search is modified to have 2 different search buttons, one with a user and one with a book](https://user-images.githubusercontent.com/66644064/116754929-e0549800-aa09-11eb-83d2-cc20987c4709.png)
Seeing this, I don't like the idea of having 3 buttons anymore, also starting the search with `Enter` wouldn't work anymore in a clear way. But you get the idea.

Maybe the tabbed search in Tusky could be an inspiration.

**Describe alternatives you've considered**
I thought maybe the context can get detected by the search input, but probably there are books out there containing an email address that should be found by searching "user@domain.tld" or users choose a name that looks like ISBN-13, so that's not an option I guess.

**Additional context**
I assume that once there are more bw instance connectors, (let's say bw.social is connected to 100 other instances), the current model will reach it's limits.
One failing search will also cause the result page to crash (I think), so only running necessary searches might help with that, even though it's a workaround.

A bit of another issue, but it'd be great to be able to search for known remote users by just entering their name without the instance.


Discussion:
This is a good point! I tried putting a dropdown in the top bar, but it felt too busy to me for something that's in the header on every page:
<img width="684" alt="Screen Shot 2021-04-30 at 2 48 00 PM" src="https://user-images.githubusercontent.com/1807695/116757563-2e927880-a9c3-11eb-9322-c1b980ebb609.png">

I like the idea of attempting to guess the query type from context and then offering tabs for the other types. The `/search` page with no query can also be a more "advanced search" view.

(I believe I fixed the problem where one search endpoint failing causes the whole results page to 500)
Guessing but offering the others tabs (not pre-loaded or loaded after the guessed tab finished) sounds very good!  
Another idea that came to my mind would be that the search bar gets removed from the header except for the icon. Clicking the icon would bring the bar alongside with the drop-down from your mockup.  
 I think I like the guess-tab approach better though.

Resolving patch:
diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py
--- a/bookwyrm/urls.py
+++ b/bookwyrm/urls.py
@@ -163,7 +163,7 @@
         name="direct-messages-user",
     ),
     # search
-    re_path(r"^search/?$", views.Search.as_view()),
+    re_path(r"^search/?$", views.Search.as_view(), name="search"),
     # imports
     re_path(r"^import/?$", views.Import.as_view()),
     re_path(r"^import/(\d+)/?$", views.ImportStatus.as_view()),
diff --git a/bookwyrm/views/search.py b/bookwyrm/views/search.py
--- a/bookwyrm/views/search.py
+++ b/bookwyrm/views/search.py
@@ -2,6 +2,7 @@
 import re
 
 from django.contrib.postgres.search import TrigramSimilarity
+from django.core.paginator import Paginator
 from django.db.models.functions import Greatest
 from django.http import JsonResponse
 from django.template.response import TemplateResponse
@@ -9,6 +10,7 @@
 
 from bookwyrm import models
 from bookwyrm.connectors import connector_manager
+from bookwyrm.settings import PAGE_LENGTH
 from bookwyrm.utils import regex
 from .helpers import is_api_request, privacy_filter
 from .helpers import handle_remote_webfinger
@@ -22,6 +24,10 @@ def get(self, request):
         """that search bar up top"""
         query = request.GET.get("q")
         min_confidence = request.GET.get("min_confidence", 0.1)
+        search_type = request.GET.get("type")
+        search_remote = (
+            request.GET.get("remote", False) and request.user.is_authenticated
+        )
 
         if is_api_request(request):
             # only return local book results via json so we don't cascade
@@ -30,48 +36,86 @@ def get(self, request):
             )
             return JsonResponse([r.json() for r in book_results], safe=False)
 
-        data = {"query": query or ""}
-
-        # use webfinger for mastodon style account@domain.com username
-        if query and re.match(regex.full_username, query):
-            handle_remote_webfinger(query)
-
-        # do a user search
-        if request.user.is_authenticated:
-            data["user_results"] = (
-                models.User.viewer_aware_objects(request.user)
-                .annotate(
-                    similarity=Greatest(
-                        TrigramSimilarity("username", query),
-                        TrigramSimilarity("localname", query),
-                    )
-                )
-                .filter(
-                    similarity__gt=0.5,
-                )
-                .order_by("-similarity")[:10]
-            )
+        if not search_type:
+            search_type = "user" if "@" in query else "book"
 
-        # any relevent lists?
-        data["list_results"] = (
-            privacy_filter(
-                request.user,
-                models.List.objects,
-                privacy_levels=["public", "followers"],
+        endpoints = {
+            "book": book_search,
+            "user": user_search,
+            "list": list_search,
+        }
+        if not search_type in endpoints:
+            search_type = "book"
+
+        data = {
+            "query": query or "",
+            "type": search_type,
+            "remote": search_remote,
+        }
+        if query:
+            results = endpoints[search_type](
+                query, request.user, min_confidence, search_remote
             )
-            .annotate(
-                similarity=Greatest(
-                    TrigramSimilarity("name", query),
-                    TrigramSimilarity("description", query),
-                )
+            paginated = Paginator(results, PAGE_LENGTH).get_page(
+                request.GET.get("page")
             )
-            .filter(
-                similarity__gt=0.1,
+            data["results"] = paginated
+
+        return TemplateResponse(request, "search/{:s}.html".format(search_type), data)
+
+
+def book_search(query, _, min_confidence, search_remote=False):
+    """the real business is elsewhere"""
+    if search_remote:
+        return connector_manager.search(query, min_confidence=min_confidence)
+    results = connector_manager.local_search(query, min_confidence=min_confidence)
+    if not results:
+        return None
+    return [{"results": results}]
+
+
+def user_search(query, viewer, *_):
+    """cool kids members only user search"""
+    # logged out viewers can't search users
+    if not viewer.is_authenticated:
+        return models.User.objects.none()
+
+    # use webfinger for mastodon style account@domain.com username to load the user if
+    # they don't exist locally (handle_remote_webfinger will check the db)
+    if re.match(regex.full_username, query):
+        handle_remote_webfinger(query)
+
+    return (
+        models.User.viewer_aware_objects(viewer)
+        .annotate(
+            similarity=Greatest(
+                TrigramSimilarity("username", query),
+                TrigramSimilarity("localname", query),
             )
-            .order_by("-similarity")[:10]
         )
+        .filter(
+            similarity__gt=0.5,
+        )
+        .order_by("-similarity")[:10]
+    )
+
 
-        data["book_results"] = connector_manager.search(
-            query, min_confidence=min_confidence
+def list_search(query, viewer, *_):
+    """any relevent lists?"""
+    return (
+        privacy_filter(
+            viewer,
+            models.List.objects,
+            privacy_levels=["public", "followers"],
+        )
+        .annotate(
+            similarity=Greatest(
+                TrigramSimilarity("name", query),
+                TrigramSimilarity("description", query),
+            )
+        )
+        .filter(
+            similarity__gt=0.1,
         )
-        return TemplateResponse(request, "search_results.html", data)
+        .order_by("-similarity")[:10]
+    )


Test patch:
diff --git a/bookwyrm/tests/views/test_search.py b/bookwyrm/tests/views/test_search.py
--- a/bookwyrm/tests/views/test_search.py
+++ b/bookwyrm/tests/views/test_search.py
@@ -2,6 +2,7 @@
 import json
 from unittest.mock import patch
 
+from django.contrib.auth.models import AnonymousUser
 from django.http import JsonResponse
 from django.template.response import TemplateResponse
 from django.test import TestCase
@@ -12,7 +13,7 @@
 from bookwyrm.settings import DOMAIN
 
 
-class ShelfViews(TestCase):
+class Views(TestCase):
     """tag views"""
 
     def setUp(self):
@@ -52,7 +53,7 @@ def test_search_json_response(self):
         self.assertEqual(data[0]["title"], "Test Book")
         self.assertEqual(data[0]["key"], "https://%s/book/%d" % (DOMAIN, self.book.id))
 
-    def test_search_html_response(self):
+    def test_search_books(self):
         """searches remote connectors"""
         view = views.Search.as_view()
 
@@ -92,7 +93,7 @@ def parse_isbn_search_data(self, data):
             connector=connector,
         )
 
-        request = self.factory.get("", {"q": "Test Book"})
+        request = self.factory.get("", {"q": "Test Book", "remote": True})
         request.user = self.local_user
         with patch("bookwyrm.views.search.is_api_request") as is_api:
             is_api.return_value = False
@@ -101,19 +102,44 @@ def parse_isbn_search_data(self, data):
                 response = view(request)
         self.assertIsInstance(response, TemplateResponse)
         response.render()
-        self.assertEqual(
-            response.context_data["book_results"][0].title, "Gideon the Ninth"
-        )
+        self.assertEqual(response.context_data["results"][0].title, "Gideon the Ninth")
 
-    def test_search_html_response_users(self):
+    def test_search_users(self):
         """searches remote connectors"""
         view = views.Search.as_view()
-        request = self.factory.get("", {"q": "mouse"})
+        request = self.factory.get("", {"q": "mouse", "type": "user"})
         request.user = self.local_user
-        with patch("bookwyrm.views.search.is_api_request") as is_api:
-            is_api.return_value = False
-            with patch("bookwyrm.connectors.connector_manager.search"):
-                response = view(request)
+        response = view(request)
+
+        self.assertIsInstance(response, TemplateResponse)
+        response.render()
+        self.assertEqual(response.context_data["results"][0], self.local_user)
+
+    def test_search_users_logged_out(self):
+        """searches remote connectors"""
+        view = views.Search.as_view()
+        request = self.factory.get("", {"q": "mouse", "type": "user"})
+
+        anonymous_user = AnonymousUser
+        anonymous_user.is_authenticated = False
+        request.user = anonymous_user
+
+        response = view(request)
+
+        response.render()
+        self.assertEqual(response.context_data["results"].object_list.count(), 0)
+
+    def test_search_lists(self):
+        """searches remote connectors"""
+        with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
+            booklist = models.List.objects.create(
+                user=self.local_user, name="test list"
+            )
+        view = views.Search.as_view()
+        request = self.factory.get("", {"q": "test", "type": "list"})
+        request.user = self.local_user
+        response = view(request)
+
         self.assertIsInstance(response, TemplateResponse)
         response.render()
-        self.assertEqual(response.context_data["user_results"][0], self.local_user)
+        self.assertEqual(response.context_data["results"][0], booklist)