diff --git a/aurweb/packages/search.py b/aurweb/packages/search.py
new file mode 100644
index 00000000..854834ee
--- /dev/null
+++ b/aurweb/packages/search.py
@@ -0,0 +1,195 @@
+from sqlalchemy import and_, case, or_, orm
+
+from aurweb import config, db
+from aurweb.models.package import Package
+from aurweb.models.package_base import PackageBase
+from aurweb.models.package_comaintainer import PackageComaintainer
+from aurweb.models.package_keyword import PackageKeyword
+from aurweb.models.package_notification import PackageNotification
+from aurweb.models.package_vote import PackageVote
+from aurweb.models.user import User
+
+DEFAULT_MAX_RESULTS = 2500
+
+
+class PackageSearch:
+ """ A Package search query builder. """
+
+ # A constant mapping of short to full name sort orderings.
+ FULL_SORT_ORDER = {"d": "desc", "a": "asc"}
+
+ def __init__(self, user: User):
+ """ Construct an instance of PackageSearch.
+
+ This constructors performs several steps during initialization:
+ 1. Setup self.query: an ORM query of Package joined by PackageBase.
+ """
+ self.user = user
+ self.query = db.query(Package).join(PackageBase).join(
+ PackageVote,
+ and_(PackageVote.PackageBaseID == PackageBase.ID,
+ PackageVote.UsersID == self.user.ID),
+ isouter=True
+ ).join(
+ PackageNotification,
+ and_(PackageNotification.PackageBaseID == PackageBase.ID,
+ PackageNotification.UserID == self.user.ID),
+ isouter=True
+ )
+ self.ordering = "d"
+
+ # Setup SeB (Search By) callbacks.
+ self.search_by_cb = {
+ "nd": self._search_by_namedesc,
+ "n": self._search_by_name,
+ "b": self._search_by_pkgbase,
+ "N": self._search_by_exact_name,
+ "B": self._search_by_exact_pkgbase,
+ "k": self._search_by_keywords,
+ "m": self._search_by_maintainer,
+ "c": self._search_by_comaintainer,
+ "M": self._search_by_co_or_maintainer,
+ "s": self._search_by_submitter
+ }
+
+ # Setup SB (Sort By) callbacks.
+ self.sort_by_cb = {
+ "n": self._sort_by_name,
+ "v": self._sort_by_votes,
+ "p": self._sort_by_popularity,
+ "w": self._sort_by_voted,
+ "o": self._sort_by_notify,
+ "m": self._sort_by_maintainer,
+ "l": self._sort_by_last_modified
+ }
+
+ def _search_by_namedesc(self, keywords: str) -> orm.Query:
+ self.query = self.query.filter(
+ or_(Package.Name.like(f"%{keywords}%"),
+ Package.Description.like(f"%{keywords}%"))
+ )
+ return self
+
+ def _search_by_name(self, keywords: str) -> orm.Query:
+ self.query = self.query.filter(Package.Name.like(f"%{keywords}%"))
+ return self
+
+ def _search_by_exact_name(self, keywords: str) -> orm.Query:
+ self.query = self.query.filter(Package.Name == keywords)
+ return self
+
+ def _search_by_pkgbase(self, keywords: str) -> orm.Query:
+ self.query = self.query.filter(PackageBase.Name.like(f"%{keywords}%"))
+ return self
+
+ def _search_by_exact_pkgbase(self, keywords: str) -> orm.Query:
+ self.query = self.query.filter(PackageBase.Name == keywords)
+ return self
+
+ def _search_by_keywords(self, keywords: str) -> orm.Query:
+ self.query = self.query.join(PackageKeyword).filter(
+ PackageKeyword.Keyword == keywords
+ )
+ return self
+
+ def _search_by_maintainer(self, keywords: str) -> orm.Query:
+ self.query = self.query.join(
+ User, User.ID == PackageBase.MaintainerUID
+ ).filter(User.Username == keywords)
+ return self
+
+ def _search_by_comaintainer(self, keywords: str) -> orm.Query:
+ self.query = self.query.join(PackageComaintainer).join(
+ User, User.ID == PackageComaintainer.UsersID
+ ).filter(User.Username == keywords)
+ return self
+
+ def _search_by_co_or_maintainer(self, keywords: str) -> orm.Query:
+ self.query = self.query.join(
+ PackageComaintainer,
+ isouter=True
+ ).join(
+ User, or_(User.ID == PackageBase.MaintainerUID,
+ User.ID == PackageComaintainer.UsersID)
+ ).filter(User.Username == keywords)
+ return self
+
+ def _search_by_submitter(self, keywords: str) -> orm.Query:
+ self.query = self.query.join(
+ User, User.ID == PackageBase.SubmitterUID
+ ).filter(User.Username == keywords)
+ return self
+
+ def search_by(self, search_by: str, keywords: str) -> orm.Query:
+ if search_by not in self.search_by_cb:
+ search_by = "nd" # Default: Name, Description
+ callback = self.search_by_cb.get(search_by)
+ result = callback(keywords)
+ return result
+
+ def _sort_by_name(self, order: str):
+ column = getattr(Package.Name, order)
+ self.query = self.query.order_by(column())
+ return self
+
+ def _sort_by_votes(self, order: str):
+ column = getattr(PackageBase.NumVotes, order)
+ self.query = self.query.order_by(column())
+ return self
+
+ def _sort_by_popularity(self, order: str):
+ column = getattr(PackageBase.Popularity, order)
+ self.query = self.query.order_by(column())
+ return self
+
+ def _sort_by_voted(self, order: str):
+ # FIXME: Currently, PHP is destroying this implementation
+ # in terms of performance. We should improve this; there's no
+ # reason it should take _longer_.
+ column = getattr(
+ case([(PackageVote.UsersID == self.user.ID, 1)], else_=0),
+ order
+ )
+ self.query = self.query.order_by(column(), Package.Name.desc())
+ return self
+
+ def _sort_by_notify(self, order: str):
+ # FIXME: Currently, PHP is destroying this implementation
+ # in terms of performance. We should improve this; there's no
+ # reason it should take _longer_.
+ column = getattr(
+ case([(PackageNotification.UserID == self.user.ID, 1)], else_=0),
+ order
+ )
+ self.query = self.query.order_by(column(), Package.Name.desc())
+ return self
+
+ def _sort_by_maintainer(self, order: str):
+ column = getattr(User.Username, order)
+ self.query = self.query.join(
+ User, User.ID == PackageBase.MaintainerUID, isouter=True
+ ).order_by(column())
+ return self
+
+ def _sort_by_last_modified(self, order: str):
+ column = getattr(PackageBase.ModifiedTS, order)
+ self.query = self.query.order_by(column())
+ return self
+
+ def sort_by(self, sort_by: str, ordering: str = "d") -> orm.Query:
+ if sort_by not in self.sort_by_cb:
+ sort_by = "n" # Default: Name.
+ callback = self.sort_by_cb.get(sort_by)
+ if ordering not in self.FULL_SORT_ORDER:
+ ordering = "d" # Default: Descending.
+ ordering = self.FULL_SORT_ORDER.get(ordering)
+ return callback(ordering)
+
+ def results(self) -> orm.Query:
+ # Store the total count of all records found up to limit.
+ limit = (config.getint("options", "max_search_results")
+ or DEFAULT_MAX_RESULTS)
+ self.total_count = self.query.limit(limit).count()
+
+ # Return the query to the user.
+ return self.query
diff --git a/aurweb/routers/packages.py b/aurweb/routers/packages.py
index a20c97b1..3eda2539 100644
--- a/aurweb/routers/packages.py
+++ b/aurweb/routers/packages.py
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Request, Response
from fastapi.responses import RedirectResponse
from sqlalchemy import and_
+import aurweb.filters
import aurweb.models.package_comment
import aurweb.models.package_keyword
import aurweb.packages.util
@@ -21,12 +22,92 @@ from aurweb.models.package_request import PackageRequest
from aurweb.models.package_source import PackageSource
from aurweb.models.package_vote import PackageVote
from aurweb.models.relation_type import CONFLICTS_ID
-from aurweb.packages.util import get_pkgbase
+from aurweb.packages.search import PackageSearch
+from aurweb.packages.util import get_pkgbase, query_notified, query_voted
from aurweb.templates import make_context, render_template
router = APIRouter()
+async def packages_get(request: Request, context: Dict[str, Any]):
+ # Query parameters used in this request.
+ context["q"] = dict(request.query_params)
+
+ # Per page and offset.
+ per_page = context["PP"] = int(request.query_params.get("PP", 50))
+ offset = context["O"] = int(request.query_params.get("O", 0))
+
+ # Query search by.
+ search_by = context["SeB"] = request.query_params.get("SeB", "nd")
+
+ # Query sort by.
+ sort_by = context["SB"] = request.query_params.get("SB", "n")
+
+ # Query sort order.
+ sort_order = request.query_params.get("SO", None)
+
+ # Apply ordering, limit and offset.
+ search = PackageSearch(request.user)
+
+ # For each keyword found in K, apply a search_by filter.
+ # This means that for any sentences separated by spaces,
+ # they are used as if they were ANDed.
+ keywords = context["K"] = request.query_params.get("K", str())
+ keywords = keywords.split(" ")
+ for keyword in keywords:
+ search.search_by(search_by, keyword)
+
+ flagged = request.query_params.get("outdated", None)
+ if flagged:
+ # If outdated was given, set it up in the context.
+ context["outdated"] = flagged
+
+ # When outdated is set to "on," we filter records which do have
+ # an OutOfDateTS. When it's set to "off," we filter out any which
+ # do **not** have OutOfDateTS.
+ criteria = None
+ if flagged == "on":
+ criteria = PackageBase.OutOfDateTS.isnot
+ else:
+ criteria = PackageBase.OutOfDateTS.is_
+
+ # Apply the flag criteria to our PackageSearch.query.
+ search.query = search.query.filter(criteria(None))
+
+ submit = request.query_params.get("submit", "Go")
+ if submit == "Orphans":
+ # If the user clicked the "Orphans" button, we only want
+ # orphaned packages.
+ search.query = search.query.filter(PackageBase.MaintainerUID.is_(None))
+
+ # Apply user-specified specified sort column and ordering.
+ search.sort_by(sort_by, sort_order)
+
+ # If no SO was given, default the context SO to 'a' (Ascending).
+ # By default, if no SO is given, the search should sort by 'd'
+ # (Descending), but display "Ascending" for the Sort order select.
+ if sort_order is None:
+ sort_order = "a"
+ context["SO"] = sort_order
+
+ # Insert search results into the context.
+ results = search.results()
+ context["packages"] = results.limit(per_page).offset(offset)
+ context["packages_voted"] = query_voted(
+ context.get("packages"), request.user)
+ context["packages_notified"] = query_notified(
+ context.get("packages"), request.user)
+ context["packages_count"] = search.total_count
+
+ return render_template(request, "packages.html", context)
+
+
+@router.get("/packages")
+async def packages(request: Request) -> Response:
+ context = make_context(request, "Packages")
+ return await packages_get(request, context)
+
+
async def make_single_context(request: Request,
pkgbase: PackageBase) -> Dict[str, Any]:
""" Make a basic context for package or pkgbase.
diff --git a/aurweb/templates.py b/aurweb/templates.py
index 6a1b6a1c..09be049c 100644
--- a/aurweb/templates.py
+++ b/aurweb/templates.py
@@ -1,5 +1,6 @@
import copy
import functools
+import math
import os
import zoneinfo
@@ -35,6 +36,7 @@ _env.filters["urlencode"] = util.to_qs
_env.filters["quote_plus"] = quote_plus
_env.filters["get_vote"] = util.get_vote
_env.filters["number_format"] = util.number_format
+_env.filters["ceil"] = math.ceil
# Add captcha filters.
_env.filters["captcha_salt"] = captcha.captcha_salt_filter
diff --git a/conf/config.defaults b/conf/config.defaults
index 1c96a55d..988859a0 100644
--- a/conf/config.defaults
+++ b/conf/config.defaults
@@ -22,6 +22,7 @@ aur_location = https://aur.archlinux.org
git_clone_uri_anon = https://aur.archlinux.org/%s.git
git_clone_uri_priv = ssh://aur@aur.archlinux.org/%s.git
max_rpc_results = 5000
+max_search_results = 2500
max_depends = 1000
aur_request_ml = aur-requests@lists.archlinux.org
request_idle_time = 1209600
diff --git a/setup.cfg b/setup.cfg
index 1d67ca96..4f2bdf7d 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,5 +1,6 @@
[pycodestyle]
max-line-length = 127
+ignore = E741, W503
[flake8]
max-line-length = 127
@@ -25,6 +26,7 @@ max-complexity = 10
per-file-ignores =
aurweb/routers/accounts.py:E741,C901
test/test_ssh_pub_key.py:E501
+ aurweb/routers/packages.py:E741
[isort]
line_length = 127
diff --git a/templates/packages.html b/templates/packages.html
new file mode 100644
index 00000000..8b5b06d1
--- /dev/null
+++ b/templates/packages.html
@@ -0,0 +1,84 @@
+{% extends "partials/layout.html" %}
+
+{% block pageContent %}
+ {% if errors %}
+
+
+ {% for error in errors %}
+
{{ error | tr }}
+ {% endfor %}
+
+ {% include "partials/packages/search.html" %}
+
+ {% else %}
+
+ {% set pages = (packages_count / PP) | ceil %}
+ {% set page = O / PP %}
+
+ {% if success %}
+
+ {% for message in success %}
+
{{ message | tr }}
+ {% endfor %}
+
+ {% endif %}
+
+ {# Search form #}
+ {% include "partials/packages/search.html" %}
+
+
+ {# /packages does things a bit roundabout-wise:
+
+ If SeB is not given, "nd" is the default.
+ If SB is not given, "n" is the default.
+ If SO is not given, "d" is the default.
+
+ However, we depend on flipping SO for column sorting.
+
+ This section sets those defaults for the context if
+ they are not already setup. #}
+ {% if not SeB %}
+ {% set SeB = "nd" %}
+ {% endif %}
+ {% if not SB %}
+ {% set SB = "n" %}
+ {% endif %}
+ {% if not SO %}
+ {% set SO = "d" %}
+ {% endif %}
+
+ {# Pagination widget #}
+ {% with total = packages_count,
+ singular = "%d package found.",
+ plural = "%d packages found.",
+ prefix = "/packages" %}
+ {% include "partials/widgets/pager.html" %}
+ {% endwith %}
+
+ {# Package action form #}
+
+