From a5d73e864eba4c669dbd298329910f671b4a4fb0 Mon Sep 17 00:00:00 2001
From: Your Name <you@example.com>
Date: Wed, 12 Jul 2023 01:11:15 -0400
Subject: [PATCH] hide search category

---
 catalog/api.py                         |  5 +-
 catalog/search/models.py               |  8 +--
 catalog/search/typesense.py            |  9 +--
 catalog/search/views.py                | 23 ++++++-
 catalog/templates/search_results.html  | 88 +++++++++++++++-----------
 common/templates/_header.html          | 38 +++++++----
 common/templatetags/duration.py        | 12 ++++
 doc/catalog.md                         |  2 +-
 users/account.py                       |  7 ++
 users/data.py                          |  3 +
 users/models.py                        |  8 ++-
 users/templates/users/preferences.html | 13 ++++
 12 files changed, 149 insertions(+), 67 deletions(-)

diff --git a/catalog/api.py b/catalog/api.py
index 89a1dbc3..012060ea 100644
--- a/catalog/api.py
+++ b/catalog/api.py
@@ -45,7 +45,10 @@ def search_item(
     if not query:
         return 400, {"message": "Invalid query"}
     items, num_pages, count, _ = query_index(
-        query, page=page, category=category, prepare_external=False
+        query,
+        page=page,
+        categories=[category] if category else None,
+        prepare_external=False,
     )
     return 200, {"data": items, "pages": num_pages, "count": count}
 
diff --git a/catalog/search/models.py b/catalog/search/models.py
index 210b347a..23025963 100644
--- a/catalog/search/models.py
+++ b/catalog/search/models.py
@@ -18,7 +18,7 @@ _logger = logging.getLogger(__name__)
 
 class DbIndexer:
     @classmethod
-    def search(cls, q, page=1, category=None, tag=None, sort=None):
+    def search(cls, q, page=1, categories=None, tag=None, sort=None):
         result = lambda: None
         result.items = Item.objects.filter(title__contains=q)[:10]
         result.num_pages = 1
@@ -47,14 +47,14 @@ else:
     Indexer = DbIndexer
 
 
-def query_index(keywords, category=None, tag=None, page=1, prepare_external=True):
+def query_index(keywords, categories=None, tag=None, page=1, prepare_external=True):
     if (
         page < 1
         or page > 99
         or (not tag and isinstance(keywords, str) and len(keywords) < 2)
     ):
         return [], 0, 0, []
-    result = Indexer.search(keywords, page=page, category=category, tag=tag)
+    result = Indexer.search(keywords, page=page, categories=categories, tag=tag)
     keys = set()
     items = []
     duplicated_items = []
@@ -92,7 +92,7 @@ def query_index(keywords, category=None, tag=None, page=1, prepare_external=True
 
     if prepare_external:
         # store site url to avoid dups in external search
-        cache_key = f"search_{category}_{keywords}"
+        cache_key = f"search_{','.join(categories or [])}_{keywords}"
         urls = list(set(cache.get(cache_key, []) + urls))
         cache.set(cache_key, urls, timeout=300)
 
diff --git a/catalog/search/typesense.py b/catalog/search/typesense.py
index 98459d46..b636bcd4 100644
--- a/catalog/search/typesense.py
+++ b/catalog/search/typesense.py
@@ -311,13 +311,10 @@ class Indexer:
             logger.warn(f"delete item error: \n{e}")
 
     @classmethod
-    def search(cls, q, page=1, category=None, tag=None, sort=None):
+    def search(cls, q, page=1, categories=None, tag=None, sort=None):
         f = []
-        if category:
-            if category == "movietv":
-                f.append("category:= [movie,tv]")
-            else:
-                f.append("category:= " + category)
+        if categories:
+            f.append(f"category:= [{','.join(categories)}]")
         if tag:
             f.append(f"tags:= '{tag}'")
         filters = " && ".join(f)
diff --git a/catalog/search/views.py b/catalog/search/views.py
index b88cebc5..41224e75 100644
--- a/catalog/search/views.py
+++ b/catalog/search/views.py
@@ -5,7 +5,7 @@ from django.shortcuts import render, redirect
 from django.contrib.auth.decorators import login_required
 from django.utils.translation import gettext_lazy as _
 from django.http import HttpResponseRedirect
-from catalog.common.models import SiteName
+from catalog.common.models import ItemCategory, SiteName
 from catalog.common.sites import AbstractSite, SiteManager
 from ..models import *
 from django.conf import settings
@@ -82,10 +82,27 @@ def fetch(request, url, is_refetch: bool = False, site: AbstractSite | None = No
     )
 
 
+def visible_categories(request):
+    vc = request.session.get("p_categories", None)
+    if vc is None:
+        vc = [
+            x
+            for x in item_categories()
+            if x.value not in request.user.preference.hidden_categories
+        ]
+        request.session["p_categories"] = vc
+    return vc
+
+
 def search(request):
     category = request.GET.get("c", default="all").strip().lower()
-    if category == "all":
+    if category == "all" or not category:
         category = None
+        categories = visible_categories(request)
+    elif category == "movietv":
+        categories = [ItemCategory.Movie, ItemCategory.TV]
+    else:
+        categories = [ItemCategory(category)]
     keywords = request.GET.get("q", default="").strip()
     tag = request.GET.get("tag", default="").strip()
     p = request.GET.get("page", default="1")
@@ -105,7 +122,7 @@ def search(request):
         if site:
             return fetch(request, keywords, False, site)
 
-    items, num_pages, _, dup_items = query_index(keywords, category, tag, p)
+    items, num_pages, _, dup_items = query_index(keywords, categories, tag, p)
     return render(
         request,
         "search_results.html",
diff --git a/catalog/templates/search_results.html b/catalog/templates/search_results.html
index 42621785..2648ed3f 100644
--- a/catalog/templates/search_results.html
+++ b/catalog/templates/search_results.html
@@ -4,6 +4,7 @@
 {% load humanize %}
 {% load admin_url %}
 {% load mastodon %}
+{% load duration %}
 {% load oauth_token %}
 {% load truncate %}
 {% load highlight %}
@@ -26,46 +27,59 @@
               <hgroup>
                 <h5>“{{ request.GET.q }}” {% trans '的搜索结果' %}</h5>
                 <div>
+                  {% visible_categories as cats %}
                   {% if request.GET.c and request.GET.c != 'all' %}
-                    <a href="?q={{ request.GET.q }}&c=all">全部</a>
+                    <a href="?q={{ request.GET.q }}&amp;c=all">全部</a>
                   {% else %}
                     全部
                   {% endif %}
-                  |
-                  {% if request.GET.c != 'book' %}
-                    <a href="?q={{ request.GET.q }}&c=book">书籍</a>
-                  {% else %}
-                    书籍
+                  {% if 'book' in cats %}
+                    |
+                    {% if request.GET.c != 'book' %}
+                      <a href="?q={{ request.GET.q }}&amp;c=book">书籍</a>
+                    {% else %}
+                      书籍
+                    {% endif %}
                   {% endif %}
-                  |
-                  {% if request.GET.c != 'movietv' %}
-                    <a href="?q={{ request.GET.q }}&c=movietv">影视</a>
-                  {% else %}
-                    影视
+                  {% if 'movie' in cats or 'tv' in cats %}
+                    |
+                    {% if request.GET.c != 'movietv' %}
+                      <a href="?q={{ request.GET.q }}&amp;c=movietv">影视</a>
+                    {% else %}
+                      影视
+                    {% endif %}
                   {% endif %}
-                  |
-                  {% if request.GET.c != 'podcast' %}
-                    <a href="?q={{ request.GET.q }}&c=podcast">播客</a>
-                  {% else %}
-                    播客
+                  {% if 'podcast' in cats %}
+                    |
+                    {% if request.GET.c != 'podcast' %}
+                      <a href="?q={{ request.GET.q }}&amp;c=podcast">播客</a>
+                    {% else %}
+                      播客
+                    {% endif %}
                   {% endif %}
-                  |
-                  {% if request.GET.c != 'music' %}
-                    <a href="?q={{ request.GET.q }}&c=music">音乐</a>
-                  {% else %}
-                    音乐
+                  {% if 'music' in cats %}
+                    |
+                    {% if request.GET.c != 'music' %}
+                      <a href="?q={{ request.GET.q }}&amp;c=music">音乐</a>
+                    {% else %}
+                      音乐
+                    {% endif %}
                   {% endif %}
-                  |
-                  {% if request.GET.c != 'game' %}
-                    <a href="?q={{ request.GET.q }}&c=game">游戏</a>
-                  {% else %}
-                    游戏
+                  {% if 'game' in cats %}
+                    |
+                    {% if request.GET.c != 'game' %}
+                      <a href="?q={{ request.GET.q }}&amp;c=game">游戏</a>
+                    {% else %}
+                      游戏
+                    {% endif %}
                   {% endif %}
-                  |
-                  {% if request.GET.c != 'performance' %}
-                    <a href="?q={{ request.GET.q }}&c=performance">演出</a>
-                  {% else %}
-                    演出
+                  {% if 'performance' in cats %}
+                    |
+                    {% if request.GET.c != 'performance' %}
+                      <a href="?q={{ request.GET.q }}&amp;c=performance">演出</a>
+                    {% else %}
+                      演出
+                    {% endif %}
                   {% endif %}
                 </div>
               </hgroup>
@@ -116,24 +130,24 @@
           </div>
           <div class="pagination">
             {% if pagination.has_prev %}
-              <a href="?page=1&{% if request.GET.q %}q={{ request.GET.q }}{% elif request.GET.tag %}tag={{ request.GET.tag }}{% endif %}{% if request.GET.c %}&c={{ request.GET.c }}{% endif %}"
+              <a href="?page=1&amp;{% if request.GET.q %}q={{ request.GET.q }}{% elif request.GET.tag %}tag={{ request.GET.tag }}{% endif %}{% if request.GET.c %}&c={{ request.GET.c }}{% endif %}"
                  class="pagination__nav-link pagination__nav-link">&laquo;</a>
-              <a href="?page={{ pagination.previous_page }}&{% if request.GET.q %}q={{ request.GET.q }}{% elif request.GET.tag %}tag={{ request.GET.tag }}{% endif %}{% if request.GET.c %}&c={{ request.GET.c }}{% endif %}"
+              <a href="?page={{ pagination.previous_page }}&amp;{% if request.GET.q %}q={{ request.GET.q }}{% elif request.GET.tag %}tag={{ request.GET.tag }}{% endif %}{% if request.GET.c %}&c={{ request.GET.c }}{% endif %}"
                  class="pagination__nav-link pagination__nav-link--right-margin pagination__nav-link">&lsaquo;</a>
             {% endif %}
             {% for page in pagination.page_range %}
               {% if page == pagination.current_page %}
-                <a href="?page={{ page }}&{% if request.GET.q %}q={{ request.GET.q }}{% elif request.GET.tag %}tag={{ request.GET.tag }}{% endif %}{% if request.GET.c %}&c={{ request.GET.c }}{% endif %}"
+                <a href="?page={{ page }}&amp;{% if request.GET.q %}q={{ request.GET.q }}{% elif request.GET.tag %}tag={{ request.GET.tag }}{% endif %}{% if request.GET.c %}&c={{ request.GET.c }}{% endif %}"
                    class="pagination__page-link pagination__page-link--current">{{ page }}</a>
               {% else %}
-                <a href="?page={{ page }}&{% if request.GET.q %}q={{ request.GET.q }}{% elif request.GET.tag %}tag={{ request.GET.tag }}{% endif %}{% if request.GET.c %}&c={{ request.GET.c }}{% endif %}"
+                <a href="?page={{ page }}&amp;{% if request.GET.q %}q={{ request.GET.q }}{% elif request.GET.tag %}tag={{ request.GET.tag }}{% endif %}{% if request.GET.c %}&c={{ request.GET.c }}{% endif %}"
                    class="pagination__page-link">{{ page }}</a>
               {% endif %}
             {% endfor %}
             {% if pagination.has_next %}
-              <a href="?page={{ pagination.next_page }}&{% if request.GET.q %}q={{ request.GET.q }}{% elif request.GET.tag %}tag={{ request.GET.tag }}{% endif %}{% if request.GET.c %}&c={{ request.GET.c }}{% endif %}"
+              <a href="?page={{ pagination.next_page }}&amp;{% if request.GET.q %}q={{ request.GET.q }}{% elif request.GET.tag %}tag={{ request.GET.tag }}{% endif %}{% if request.GET.c %}&c={{ request.GET.c }}{% endif %}"
                  class="pagination__nav-link pagination__nav-link--left-margin">&rsaquo;</a>
-              <a href="?page={{ pagination.last_page }}&{% if request.GET.q %}q={{ request.GET.q }}{% elif request.GET.tag %}tag={{ request.GET.tag }}{% endif %}{% if request.GET.c %}&c={{ request.GET.c }}{% endif %}"
+              <a href="?page={{ pagination.last_page }}&amp;{% if request.GET.q %}q={{ request.GET.q }}{% elif request.GET.tag %}tag={{ request.GET.tag }}{% endif %}{% if request.GET.c %}&c={{ request.GET.c }}{% endif %}"
                  class="pagination__nav-link">&raquo;</a>
             {% endif %}
           </div>
diff --git a/common/templates/_header.html b/common/templates/_header.html
index 0ddaccbc..35884c1e 100644
--- a/common/templates/_header.html
+++ b/common/templates/_header.html
@@ -1,4 +1,5 @@
 {% load admin_url %}
+{% load duration %}
 {% load static %}
 {% load i18n %}
 <header class="container-fluid">
@@ -18,19 +19,32 @@
                  class="search"
                  value="{{ request.GET.q|default:'' }}" />
           <select name="c">
+            {% visible_categories as cats %}
             <option value="all">全部</option>
-            <option {% if request.GET.c and request.GET.c == 'book' or '/book/' in request.path %}selected{% endif %}
-                    value="book">书籍</option>
-            <option {% if request.GET.c and request.GET.c == 'movietv' or '/movie/' in request.path or '/tv/' in request.path %}selected{% endif %}
-                    value="movietv">影视</option>
-            <option {% if request.GET.c and request.GET.c == 'podcast' or '/podcast/' in request.path %}selected{% endif %}
-                    value="podcast">播客</option>
-            <option {% if request.GET.c and request.GET.c == 'music' or '/album/' in request.path %}selected{% endif %}
-                    value="music">音乐</option>
-            <option {% if request.GET.c and request.GET.c == 'game' or '/game/' in request.path %}selected{% endif %}
-                    value="game">游戏</option>
-            <option {% if request.GET.c and request.GET.c == 'performance' or '/performance/' in request.path %}selected{% endif %}
-                    value="performance">演出</option>
+            {% if 'book' in cats %}
+              <option {% if request.GET.c == 'book' or '/book/' in request.path %}selected{% endif %}
+                      value="book">书籍</option>
+            {% endif %}
+            {% if 'movie' in cats or 'tv' in cats %}
+              <option {% if request.GET.c and request.GET.c == 'movietv' or '/movie/' in request.path or '/tv/' in request.path %}selected{% endif %}
+                      value="movietv">影视</option>
+            {% endif %}
+            {% if 'podcast' in cats %}
+              <option {% if request.GET.c and request.GET.c == 'podcast' or '/podcast/' in request.path %}selected{% endif %}
+                      value="podcast">播客</option>
+            {% endif %}
+            {% if 'music' in cats %}
+              <option {% if request.GET.c and request.GET.c == 'music' or '/album/' in request.path %}selected{% endif %}
+                      value="music">音乐</option>
+            {% endif %}
+            {% if 'game' in cats %}
+              <option {% if request.GET.c and request.GET.c == 'game' or '/game/' in request.path %}selected{% endif %}
+                      value="game">游戏</option>
+            {% endif %}
+            {% if 'performance' in cats %}
+              <option {% if request.GET.c == 'performance' or '/performance/' in request.path %}selected{% endif %}
+                      value="performance">演出</option>
+            {% endif %}
           </select>
           <input type="submit" value="&#xf002;" class="fa-solid" />
         </form>
diff --git a/common/templatetags/duration.py b/common/templatetags/duration.py
index f095427e..90dff12e 100644
--- a/common/templatetags/duration.py
+++ b/common/templatetags/duration.py
@@ -2,10 +2,22 @@ from django import template
 from django.template.defaultfilters import stringfilter
 from django.utils.text import Truncator
 from django.utils.safestring import mark_safe
+from catalog.common.models import ItemCategory, item_categories
+from catalog.search.views import visible_categories as _visible_categories
 
 register = template.Library()
 
 
+@register.simple_tag(takes_context=True)
+def visible_categories(context):
+    return _visible_categories(context["request"])
+
+
+@register.simple_tag
+def all_categories():
+    return item_categories()
+
+
 @register.filter(is_safe=True)
 @stringfilter
 def duration_format(value, unit):
diff --git a/doc/catalog.md b/doc/catalog.md
index ec8be421..e8996226 100644
--- a/doc/catalog.md
+++ b/doc/catalog.md
@@ -88,7 +88,7 @@ classDiagram
 
 Add a new site
 --------------
-> **Site official API** should be the prioritised way to get data when adding a new site.
+ - If official API is available for the site, it should be the preferred way to get data.
  - add a new value to `IdType` and `SiteName` in `catalog/common/models.py`
  - add a new file in `catalog/sites/`, a new class inherits `AbstractSite`, with:
     * `SITE_NAME`
diff --git a/users/account.py b/users/account.py
index abeef85d..4da010ee 100644
--- a/users/account.py
+++ b/users/account.py
@@ -457,9 +457,16 @@ def swap_login(request, token, site, refresh_token):
     return redirect(reverse("users:data"))
 
 
+def clear_preference_cache(request):
+    for key in list(request.session.keys()):
+        if key.startswith("p_"):
+            del request.session[key]
+
+
 def auth_login(request, user):
     """Decorates django ``login()``. Attach token to session."""
     auth.login(request, user, backend="mastodon.auth.OAuth2Backend")
+    clear_preference_cache(request)
     if (
         user.mastodon_last_refresh < timezone.now() - timedelta(hours=1)
         or user.mastodon_account == {}
diff --git a/users/data.py b/users/data.py
index 024322f0..cd046aa7 100644
--- a/users/data.py
+++ b/users/data.py
@@ -29,6 +29,7 @@ def preferences(request):
         preference.default_no_share = bool(request.POST.get("default_no_share"))
         preference.no_anonymous_view = bool(request.POST.get("no_anonymous_view"))
         preference.classic_homepage = int(request.POST.get("classic_homepage"))
+        preference.hidden_categories = request.POST.getlist("hidden_categories")
         preference.mastodon_publish_public = bool(
             request.POST.get("mastodon_publish_public")
         )
@@ -45,8 +46,10 @@ def preferences(request):
                 "mastodon_publish_public",
                 "mastodon_append_tag",
                 "show_last_edit",
+                "hidden_categories",
             ]
         )
+        clear_preference_cache(request)
     return render(request, "users/preferences.html")
 
 
diff --git a/users/models.py b/users/models.py
index cde3c8f4..9d687f06 100644
--- a/users/models.py
+++ b/users/models.py
@@ -20,7 +20,7 @@ from django.templatetags.static import static
 import hashlib
 from loguru import logger
 
-RESERVED_USERNAMES = [
+_RESERVED_USERNAMES = [
     "connect",
     "oauth2_login",
     "__",
@@ -40,7 +40,7 @@ class UsernameValidator(validators.RegexValidator):
     flags = re.ASCII
 
     def __call__(self, value):
-        if value and value.lower() in RESERVED_USERNAMES:
+        if value and value.lower() in _RESERVED_USERNAMES:
             raise ValidationError(self.message, code=self.code)
         return super().__call__(value)
 
@@ -52,6 +52,7 @@ def report_image_path(instance, filename):
 
 
 class User(AbstractUser):
+    preference: "Preference"
     username_validator = UsernameValidator()
     username = models.CharField(
         _("username"),
@@ -457,7 +458,7 @@ class User(AbstractUser):
                 or target.mastodon_site in self.mastodon_domain_blocks
             )
             if target.is_authenticated
-            else self.preference.no_anonymous_view  # type: ignore
+            else self.preference.no_anonymous_view
         )
 
     def is_blocked_by(self, target):
@@ -556,6 +557,7 @@ class Preference(models.Model):
     mastodon_append_tag = models.CharField(max_length=2048, default="")
     show_last_edit = models.PositiveSmallIntegerField(default=0)
     no_anonymous_view = models.PositiveSmallIntegerField(default=0)
+    hidden_categories = models.JSONField(default=list)
 
     def __str__(self):
         return str(self.user)
diff --git a/users/templates/users/preferences.html b/users/templates/users/preferences.html
index 1d06ee6a..01619859 100644
--- a/users/templates/users/preferences.html
+++ b/users/templates/users/preferences.html
@@ -4,6 +4,7 @@
 {% load mastodon %}
 {% load oauth_token %}
 {% load truncate %}
+{% load duration %}
 {% load thumb %}
 <!DOCTYPE html>
 <html lang="zh" class="classic-page">
@@ -108,6 +109,18 @@
                        placeholder="例如 #我的书影音"
                        value="{{ request.user.preference.mastodon_append_tag }}">
               </fieldset>
+              <fieldset>
+                <legend>{% trans '搜索时不显示以下类型:' %}</legend>
+                <select name="hidden_categories" size="3" multiple>
+                  {% all_categories as categories %}
+                  {% for c in categories %}
+                    <option value="{{ c.value }}"
+                            {% if c in request.user.preference.hidden_categories %}selected{% endif %}>
+                      {{ c.label }}
+                    </option>
+                  {% endfor %}
+                </select>
+              </fieldset>
               <input type="submit" value="{% trans '保存' %}">
             </form>
           </details>