From f086be5eaf9fbd75a078473e86c4e06f3f1e6c52 Mon Sep 17 00:00:00 2001 From: Paul Hebble Date: Fri, 12 Nov 2021 21:22:08 -0600 Subject: [PATCH 01/37] Quoted and negated search terms, up front search help --- KerbalStuff/search.py | 75 +++++++++++++++++++-------------- frontend/coffee/global.coffee | 6 +++ frontend/styles/navigation.scss | 52 ++++++++++++++++++++++- templates/browse-list.html | 33 --------------- templates/layout.html | 52 +++++++++++++++++++++-- 5 files changed, 149 insertions(+), 69 deletions(-) diff --git a/KerbalStuff/search.py b/KerbalStuff/search.py index 16a4ca01..c936caf1 100644 --- a/KerbalStuff/search.py +++ b/KerbalStuff/search.py @@ -1,9 +1,11 @@ import math +import re from datetime import datetime from typing import List, Iterable, Tuple, Optional from packaging import version -from sqlalchemy import and_, or_, desc +from sqlalchemy import and_, or_, not_, desc +from sqlalchemy.orm import Query from .database import db from .objects import Mod, ModVersion, User, Game, GameVersion @@ -65,42 +67,21 @@ def game_versions(game: Game) -> Iterable[version.Version]: pass +# Optional '-' at start, followed by: +# 1. "term with spaces", OR +# 2. termwithoutquotesorspaces +SEARCH_TOKEN_PATTERN = re.compile(r'-?(?:"[^"]*"|[^" ]+)') + + def search_mods(game_id: Optional[int], text: str, page: int, limit: int) -> Tuple[List[Mod], int]: - terms = text.split(' ') + terms = [term.replace('"', '') for term in SEARCH_TOKEN_PATTERN.findall(text)] query = db.query(Mod).join(Mod.user).join(Mod.game) if game_id: query = query.filter(Mod.game_id == game_id) query = query.filter(Mod.published) - # ALL of the special search parameters have to match - and_filters = list() - for term in terms: - if term.startswith("ver:"): - and_filters.append(Mod.versions.any(ModVersion.gameversion.has( - GameVersion.friendly_version == term[4:]))) - elif term.startswith("user:"): - and_filters.append(User.username == term[5:]) - elif term.startswith("game:"): - and_filters.append(Mod.game_id == int(term[5:])) - elif term.startswith("downloads:>"): - and_filters.append(Mod.download_count > int(term[11:])) - elif term.startswith("downloads:<"): - and_filters.append(Mod.download_count < int(term[11:])) - elif term.startswith("followers:>"): - and_filters.append(Mod.follower_count > int(term[11:])) - elif term.startswith("followers:<"): - and_filters.append(Mod.follower_count < int(term[11:])) - else: - continue - terms.remove(term) - query = query.filter(and_(*and_filters)) - # Now the leftover is probably what the user thinks the mod name is. - # ALL of them have to match again, however we don't care if it's in the name or description. - for term in terms: - or_filters = list() - or_filters.append(Mod.name.ilike('%' + term + '%')) - or_filters.append(Mod.short_description.ilike('%' + term + '%')) - or_filters.append(Mod.description.ilike('%' + term + '%')) - query = query.filter(or_(*or_filters)) + + # All of the terms must match + query = query.filter(*(term_to_filter(term) for term in terms)) query = query.order_by(desc(Mod.score)) @@ -114,6 +95,36 @@ def search_mods(game_id: Optional[int], text: str, page: int, limit: int) -> Tup return mods, total_pages +def term_to_filter(term: str) -> Query: + if term.startswith('-'): + return not_(term_to_filter(term[1:])) + elif term.startswith("ver:"): + return Mod.versions.any(ModVersion.gameversion.has(or_( + GameVersion.friendly_version == term[4:], + GameVersion.friendly_version.ilike(f'{term[4:]}.%')))) + elif term.startswith("user:"): + return User.username == term[5:] + elif term.startswith("game:"): + to_match = term[5:] + return (Mod.game_id == int(to_match) + if to_match.isnumeric() else + Game.name.ilike(f'%{to_match}%')) + elif term.startswith("downloads:>"): + return Mod.download_count > int(term[11:]) + elif term.startswith("downloads:<"): + return Mod.download_count < int(term[11:]) + elif term.startswith("followers:>"): + return Mod.follower_count > int(term[11:]) + elif term.startswith("followers:<"): + return Mod.follower_count < int(term[11:]) + else: + # Now the leftover is probably what the user thinks the mod name is. + # ALL of them have to match again, however we don't care if it's in the name or description. + return or_(Mod.name.ilike('%' + term + '%'), + Mod.short_description.ilike('%' + term + '%'), + Mod.description.ilike('%' + term + '%')) + + def search_users(text: str, page: int) -> Iterable[User]: terms = text.split(' ') query = db.query(User) diff --git a/frontend/coffee/global.coffee b/frontend/coffee/global.coffee index 24d16276..ba4060f9 100644 --- a/frontend/coffee/global.coffee +++ b/frontend/coffee/global.coffee @@ -63,6 +63,12 @@ $('#alert-error').on 'close.bs.alert', () -> $('#alert-error').addClass 'hidden' return false +$('.search-tips-button').on 'click', (e) -> + $('.search-tips').addClass 'search-tips-visible' + +$('.search-tips').on 'click', (e) -> + $('.search-tips').removeClass 'search-tips-visible' + link.addEventListener('click', (e) -> e.preventDefault() xhr = new XMLHttpRequest() diff --git a/frontend/styles/navigation.scss b/frontend/styles/navigation.scss index cab9d4e9..395d8b6c 100644 --- a/frontend/styles/navigation.scss +++ b/frontend/styles/navigation.scss @@ -198,4 +198,54 @@ .nav > li > a:hover,.nav > li > a:focus { text-decoration:none; background-color:transparent -} \ No newline at end of file +} + +.search-tips { + font: 4mm "glacial_indifferenceregular"; + text-transform: none; + letter-spacing: normal; + box-shadow: 0 0 15px rgba(0,0,0,0.2); + position: absolute; + top: 55px; + @media (max-width: 767px) { + left: 2px; + right: 2px; + } + @media (min-width: 768px) { + left: 15%; + right: 30%; + } + visibility: hidden; + opacity: 0; + transition: visibility 0.5s, opacity 0.5s ease; + code { + white-space: nowrap; + } +} + +.search-tips-button { + float: left; +} + +.search-tips-visible { + visibility: visible; + opacity: 1; +} + +@media (max-width: 767px) { + form.navbar-form.navbar-search div.form-group input.form-control.search-box { + margin-left: 1ex; + width: auto; + } +} + +@media (min-width: 768px) { + /* On mobile you get a button. On desktop you get hover. */ + .search-tips-button { + display: none; + } + #form-mod-search:focus-within .search-tips { + visibility: visible; + opacity: 1; + } +} diff --git a/templates/browse-list.html b/templates/browse-list.html index ee071273..fb266f8e 100644 --- a/templates/browse-list.html +++ b/templates/browse-list.html @@ -9,13 +9,6 @@ {{ name }} on {{ site_name }} {% endif %} {% endblock %} -{% block search %} - -{% endblock %} {% block body %}
@@ -23,7 +16,6 @@ {% endif %} {% if search %} - Advanced Search

Search results for "{{ query }}"

{% else %}

{{ name }}

@@ -66,29 +58,4 @@

{{ name }}

- {% endblock %} diff --git a/templates/layout.html b/templates/layout.html index 548abd13..2bc201f1 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -150,9 +150,55 @@ {% endif %} {% block search %} -