implement /packages/{name} as its own route

A few things added with this commit:

- aurweb.packages.util
    - A module providing package and pkgbase helpers.
- aurweb.template.register_filter
    - A decorator that can be used to register a filter:
      @register_filter("some_filter") def f(): pass

Additionally, template partials have been split off a bit
differently. Changes:

- /packages/{name} is defined in packages/show.html.
- partials/packages/package_actions.html is now
  partials/packages/actions.html.
- partials/packages/details.html has been added.
- partials/packages/comments.html has been added.
- partials/packages/comment.html has been added.
- models.dependency_type additions: name and id constants.
- models.relation_type additions: name and id constants.
- models.official_provider additions: base official url constant.

Signed-off-by: Kevin Morris <kevr@0cost.org>
This commit is contained in:
Kevin Morris 2021-07-15 22:48:26 -07:00
parent 2d3d03e01e
commit ae3d302c47
22 changed files with 1166 additions and 254 deletions

View file

@ -1,7 +1,13 @@
from sqlalchemy import Column, Integer from sqlalchemy import Column, Integer
from aurweb import db
from aurweb.models.declarative import Base from aurweb.models.declarative import Base
DEPENDS = "depends"
MAKEDEPENDS = "makedepends"
CHECKDEPENDS = "checkdepends"
OPTDEPENDS = "optdepends"
class DependencyType(Base): class DependencyType(Base):
__tablename__ = "DependencyTypes" __tablename__ = "DependencyTypes"
@ -12,3 +18,13 @@ class DependencyType(Base):
def __init__(self, Name: str = None): def __init__(self, Name: str = None):
self.Name = Name self.Name = Name
DEPENDS_ID = db.query(DependencyType).filter(
DependencyType.Name == DEPENDS).first().ID
MAKEDEPENDS_ID = db.query(DependencyType).filter(
DependencyType.Name == MAKEDEPENDS).first().ID
CHECKDEPENDS_ID = db.query(DependencyType).filter(
DependencyType.Name == CHECKDEPENDS).first().ID
OPTDEPENDS_ID = db.query(DependencyType).filter(
DependencyType.Name == OPTDEPENDS).first().ID

View file

@ -3,6 +3,8 @@ from sqlalchemy.exc import IntegrityError
from aurweb.models.declarative import Base from aurweb.models.declarative import Base
OFFICIAL_BASE = "https://aur.archlinux.org"
class OfficialProvider(Base): class OfficialProvider(Base):
__tablename__ = "OfficialProviders" __tablename__ = "OfficialProviders"

View file

@ -61,3 +61,12 @@ class PackageDependency(Base):
self.DepDesc = DepDesc self.DepDesc = DepDesc
self.DepCondition = DepCondition self.DepCondition = DepCondition
self.DepArch = DepArch self.DepArch = DepArch
def is_package(self) -> bool:
from aurweb import db
from aurweb.models.official_provider import OfficialProvider
from aurweb.models.package import Package
pkg = db.query(Package, Package.Name == self.DepName)
official = db.query(OfficialProvider,
OfficialProvider.Name == self.DepName)
return pkg.count() > 0 or official.count() > 0

View file

@ -1,7 +1,12 @@
from sqlalchemy import Column, Integer from sqlalchemy import Column, Integer
from aurweb import db
from aurweb.models.declarative import Base from aurweb.models.declarative import Base
CONFLICTS = "conflicts"
PROVIDES = "provides"
REPLACES = "replaces"
class RelationType(Base): class RelationType(Base):
__tablename__ = "RelationTypes" __tablename__ = "RelationTypes"
@ -12,3 +17,11 @@ class RelationType(Base):
def __init__(self, Name: str = None): def __init__(self, Name: str = None):
self.Name = Name self.Name = Name
CONFLICTS_ID = db.query(RelationType).filter(
RelationType.Name == CONFLICTS).first().ID
PROVIDES_ID = db.query(RelationType).filter(
RelationType.Name == PROVIDES).first().ID
REPLACES_ID = db.query(RelationType).filter(
RelationType.Name == REPLACES).first().ID

View file

117
aurweb/packages/util.py Normal file
View file

@ -0,0 +1,117 @@
from http import HTTPStatus
from fastapi import HTTPException
from sqlalchemy import and_
from aurweb import db
from aurweb.models.official_provider import OFFICIAL_BASE, OfficialProvider
from aurweb.models.package import Package
from aurweb.models.package_base import PackageBase
from aurweb.models.package_dependency import PackageDependency
from aurweb.models.package_relation import PackageRelation
from aurweb.models.relation_type import PROVIDES_ID, RelationType
from aurweb.templates import register_filter
def dep_depends_extra(dep: PackageDependency) -> str:
""" A function used to produce extra text for dependency display. """
return str()
def dep_makedepends_extra(dep: PackageDependency) -> str:
""" A function used to produce extra text for dependency display. """
return "(make)"
def dep_checkdepends_extra(dep: PackageDependency) -> str:
""" A function used to produce extra text for dependency display. """
return "(check)"
def dep_optdepends_extra(dep: PackageDependency) -> str:
""" A function used to produce extra text for dependency display. """
return "(optional)"
@register_filter("dep_extra")
def dep_extra(dep: PackageDependency) -> str:
""" Some dependency types have extra text added to their
display. This function provides that output. However, it
**assumes** that the dep passed is bound to a valid one
of: depends, makedepends, checkdepends or optdepends. """
f = globals().get(f"dep_{dep.DependencyType.Name}_extra")
return f(dep)
@register_filter("dep_extra_desc")
def dep_extra_desc(dep: PackageDependency) -> str:
extra = dep_extra(dep)
return extra + f" {dep.DepDesc}"
@register_filter("pkgname_link")
def pkgname_link(pkgname: str) -> str:
base = "/".join([OFFICIAL_BASE, "packages"])
pkg = db.query(Package).filter(Package.Name == pkgname)
official = db.query(OfficialProvider).filter(
OfficialProvider.Name == pkgname)
if not pkg.count() or official.count():
return f"{base}/?q={pkgname}"
return f"/packages/{pkgname}"
@register_filter("package_link")
def package_link(package: Package) -> str:
base = "/".join([OFFICIAL_BASE, "packages"])
official = db.query(OfficialProvider).filter(
OfficialProvider.Name == package.Name)
if official.count():
return f"{base}/?q={package.Name}"
return f"/packages/{package.Name}"
@register_filter("provides_list")
def provides_list(package: Package, depname: str) -> list:
providers = db.query(Package).join(
PackageRelation).join(RelationType).filter(
and_(
PackageRelation.RelName == depname,
RelationType.ID == PROVIDES_ID
)
)
string = str()
has_providers = providers.count() > 0
if has_providers:
string += "<em>("
string += ", ".join([
f'<a href="{package_link(pkg)}">{pkg.Name}</a>'
for pkg in providers
])
if has_providers:
string += ")</em>"
return string
def get_pkgbase(name: str) -> PackageBase:
""" Get a PackageBase instance by its name or raise a 404 if
it can't be foudn in the database.
:param name: PackageBase.Name
:raises HTTPException: With status code 404 if PackageBase doesn't exist
:return: PackageBase instance
"""
pkgbase = db.query(PackageBase).filter(PackageBase.Name == name).first()
if not pkgbase:
raise HTTPException(status_code=int(HTTPStatus.NOT_FOUND))
provider = db.query(OfficialProvider).filter(
OfficialProvider.Name == name).first()
if provider:
raise HTTPException(status_code=int(HTTPStatus.NOT_FOUND))
return pkgbase

View file

@ -1,39 +1,98 @@
from http import HTTPStatus from http import HTTPStatus
from typing import Any, Dict
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, Request, Response
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from sqlalchemy import and_
import aurweb.models.package
import aurweb.models.package_comment import aurweb.models.package_comment
import aurweb.models.package_keyword import aurweb.models.package_keyword
import aurweb.packages.util
from aurweb import db from aurweb import db
from aurweb.models.license import License
from aurweb.models.package import Package
from aurweb.models.package_base import PackageBase from aurweb.models.package_base import PackageBase
from aurweb.models.package_dependency import PackageDependency
from aurweb.models.package_license import PackageLicense
from aurweb.models.package_notification import PackageNotification
from aurweb.models.package_relation import PackageRelation
from aurweb.models.package_source import PackageSource
from aurweb.models.package_vote import PackageVote
from aurweb.models.relation_type import CONFLICTS_ID, RelationType
from aurweb.packages.util import get_pkgbase
from aurweb.templates import make_variable_context, render_template from aurweb.templates import make_variable_context, render_template
router = APIRouter() router = APIRouter()
@router.get("/packages/{package}") async def make_single_context(request: Request,
async def package_base(request: Request, package: str): pkgbase: PackageBase) -> Dict[str, Any]:
package = db.query(PackageBase).filter(PackageBase.Name == package).first() """ Make a basic context for package or pkgbase.
if not package:
raise HTTPException(status_code=int(HTTPStatus.NOT_FOUND))
context = await make_variable_context(request, package.Name) :param request: FastAPI request
context["git_clone_uri_anon"] = aurweb.config.get("options", "git_clone_uri_anon") :param pkgbase: PackageBase instance
context["git_clone_uri_priv"] = aurweb.config.get("options", "git_clone_uri_priv") :return: A pkgbase context without specific differences
context["pkgbase"] = package """
context["packages"] = package.packages.all() context = await make_variable_context(request, pkgbase.Name)
context["packages_count"] = package.packages.count() context["git_clone_uri_anon"] = aurweb.config.get("options",
context["keywords"] = package.keywords.all() "git_clone_uri_anon")
context["comments"] = package.comments.all() context["git_clone_uri_priv"] = aurweb.config.get("options",
context["is_maintainer"] = request.user.is_authenticated() \ "git_clone_uri_priv")
and request.user.Username == package.Maintainer.Username context["pkgbase"] = pkgbase
context["packages_count"] = pkgbase.packages.count()
context["keywords"] = pkgbase.keywords
context["comments"] = pkgbase.comments
context["is_maintainer"] = (request.user.is_authenticated()
and request.user == pkgbase.Maintainer)
context["notified"] = db.query(
PackageNotification).join(PackageBase).filter(
and_(PackageBase.ID == pkgbase.ID,
PackageNotification.UserID == request.user.ID)).count() > 0
return render_template(request, "pkgbase.html", context) context["out_of_date"] = bool(pkgbase.OutOfDateTS)
context["voted"] = pkgbase.package_votes.filter(
PackageVote.UsersID == request.user.ID).count() > 0
context["notifications_enabled"] = db.query(
PackageNotification).join(PackageBase).filter(
PackageBase.ID == pkgbase.ID).count() > 0
return context
@router.get("/pkgbase/{package}") @router.get("/packages/{name}")
async def package_base_redirect(request: Request, package: str): async def package(request: Request, name: str) -> Response:
return RedirectResponse(f"/packages/{package}") # Get the PackageBase.
pkgbase = get_pkgbase(name)
# Add our base information.
context = await make_single_context(request, pkgbase)
# Package sources.
sources = db.query(PackageSource).join(Package).filter(
Package.PackageBaseID == pkgbase.ID)
context["sources"] = sources
# Package dependencies.
dependencies = db.query(PackageDependency).join(Package).filter(
Package.PackageBaseID == pkgbase.ID)
context["dependencies"] = dependencies
# Package requirements (other packages depend on this one).
required_by = db.query(PackageDependency).join(Package).filter(
PackageDependency.DepName == pkgbase.Name).order_by(
Package.Name.asc())
context["required_by"] = required_by
licenses = db.query(License).join(PackageLicense).join(Package).filter(
PackageLicense.PackageID == pkgbase.packages.first().ID)
context["licenses"] = licenses
conflicts = db.query(PackageRelation).join(RelationType).join(Package).join(PackageBase).filter(
and_(RelationType.ID == CONFLICTS_ID,
PackageBase.ID == pkgbase.ID))
context["conflicts"] = conflicts
return render_template(request, "packages/show.html", context)

View file

@ -1,9 +1,11 @@
import copy import copy
import functools
import os import os
import zoneinfo import zoneinfo
from datetime import datetime from datetime import datetime
from http import HTTPStatus from http import HTTPStatus
from typing import Callable
from urllib.parse import quote_plus from urllib.parse import quote_plus
import jinja2 import jinja2
@ -40,6 +42,31 @@ env.filters["captcha_cmdline"] = captcha.captcha_cmdline_filter
env.filters["account_url"] = util.account_url env.filters["account_url"] = util.account_url
def register_filter(name: str) -> Callable:
""" A decorator that can be used to register a filter.
Example
@register_filter("some_filter")
def some_filter(some_value: str) -> str:
return some_value.replace("-", "_")
Jinja2
{{ 'blah-blah' | some_filter }}
:param name: Filter name
:return: Callable used for filter
"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
if name in env.filters:
raise KeyError(f"Jinja already has a filter named '{name}'")
env.filters[name] = wrapper
return wrapper
return decorator
def make_context(request: Request, title: str, next: str = None): def make_context(request: Request, title: str, next: str = None):
""" Create a context for a jinja2 TemplateResponse. """ """ Create a context for a jinja2 TemplateResponse. """

14
aurweb/testing/html.py Normal file
View file

@ -0,0 +1,14 @@
from io import StringIO
from lxml import etree
parser = etree.HTMLParser()
def parse_root(html: str) -> etree.Element:
""" Parse an lxml.etree.ElementTree root from html content.
:param html: HTML markup
:return: etree.Element
"""
return etree.parse(StringIO(html), parser)

View file

@ -0,0 +1,23 @@
{% extends "partials/layout.html" %}
{% block pageContent %}
{% include "partials/packages/search.html" %}
<div id="pkgdetails" class="box">
<h2>{{ 'Package Details' | tr }}: {{ pkgbase.Name }} {{ pkgbase.packages.first().Version }}</h2>
{% set result = pkgbase %}
{% include "partials/packages/actions.html" %}
{% set show_package_details = True %}
{% include "partials/packages/details.html" %}
<div id="metadata">
{% include "partials/packages/package_metadata.html" %}
</div>
</div>
{% set pkgname = result.Name %}
{% set pkgbase_id = result.ID %}
{% set comments = comments %}
{% include "partials/packages/comments.html" %}
{% endblock %}

View file

@ -0,0 +1,162 @@
<!--
This partial requires result.Name to render
-->
<div id="detailslinks" class="listing">
<div id="actionlist">
<h4>{{ "Package Actions" | tr }}</h4>
<ul class="small">
<li>
<a href="/cgit/aur.git/tree/PKGBUILD?h={{ result.Name }}">
{{ "View PKGBUILD" | tr }}
</a>
/
<a href="/cgit/aur.git/log/?h={{ result.Name }}">
{{ "View Changes" | tr }}
</a>
</li>
<li>
<a href="/cgit/aur.git/snapshot/{{ result.Name }}.tar.gz">
{{ "Download snapshot" | tr }}
</a>
<li>
<a href="https://wiki.archlinux.org/title/Special:Search?search={{ result.Name }}">
{{ "Search wiki" | tr }}
</a>
</li>
{% if not request.user.is_authenticated() %}
{% if not out_of_date %}
<li>
<a href="/pkgbase/{{ result.Name }}/flag/">
{{ "Flag package out-of-date" | tr }}
</a>
</li>
{% else %}
<li>
<span class="flagged">
{% set ood_ts = result.OutOfDateTS | dt | as_timezone(timezone) %}
{{
"Flagged out-of-date (%s)"
| tr | format(ood_ts.strftime("%Y-%m-%d"))
}}
</span>
</li>
{% endif %}
<li>
<a href="/login?next={{ request.url.path | urlencode }}">
{{ "Vote for this package" | tr }}
</a>
</li>
<li>
<a href="/login?next={{ request.url.path | urlencode }}">
{{ "Enable notifications" | tr }}
</a>
</li>
{% else %}
{% if not out_of_date %}
<li>
<a href="/pkgbase/{{ result.Name }}/flag/">
{{ "Flag package out-of-date" | tr }}
</a>
</li>
{% else %}
<li>
<span class="flagged">
{% set ood_ts = result.OutOfDateTS | dt | as_timezone(timezone) %}
{{
"Flagged out-of-date (%s)"
| tr | format(ood_ts.strftime("%Y-%m-%d"))
}}
</span>
</li>
<li>
<form action="/pkgbase/{{ result.Name }}/unflag" method="post">
<input class="button text-button"
type="submit"
name="do_UnFlag"
value="{{ 'Unflag package' | tr }}"
/>
</form>
</li>
{% endif %}
<li>
{% if not voted %}
<form action="/pkgbase/{{ result.Name }}/vote/" method="post">
<input type="submit"
class="button text-button"
name="do_Vote"
value="{{ 'Vote for this package' | tr }}" />
</form>
{% else %}
<form action="/pkgbase/{{ result.Name }}/unvote/" method="post">
<input type="submit"
class="button text-button"
name="do_UnVote"
value="{{ 'Remove vote' | tr }}" />
</form>
{% endif %}
</li>
<li>
{% if notified %}
<form action="/pkgbase/{{ result.Name }}/unnotify/" method="post">
<input type="submit"
class="button text-button"
name="do_UnNotify"
value="{{ 'Disable notifications' | tr }}"
/>
</form>
{% else %}
<form action="/pkgbase/{{ result.Name }}/notify/" method="post">
<input type="submit"
class="button text-button"
name="do_Notify"
value="{{ 'Enable notifications' | tr }}"
/>
</form>
{% endif %}
</li>
{% endif %}
</form>
{% if is_maintainer %}
<li>
<a href="/pkgbase/{{ result.Name }}/comaintainers/">
{{ "Manage Co-Maintainers" | tr }}
</a>
</li>
{% endif %}
<li><span class="flagged"></span></li>
<li>
{% if not request.user.is_authenticated() %}
<a href="/login?next={{ '/pkgbase/%s/request' | format(result.Name) | urlencode }}">
{{ "Submit Request" | tr }}
</a>
{% else %}
<a href="/pkgbase/{{ result.Name }}/request/">
{{ "Submit Request" | tr }}
</a>
{% endif %}
</li>
{% if is_maintainer %}
<li>
<a href="/pkgbase/{{ result.Name }}/delete/">
{{ "Delete Package" | tr }}
</a>
</li>
<li>
<a href="/pkgbase/{{ result.Name }}/merge/">
{{ "Merge Package" | tr }}
</a>
</li>
<li>
<form action="/pkgbase/{{ result.Name }}/disown/" method="post">
<input type="submit"
class="button text-button"
name="do_Disown"
value="{{ 'Disown Package' | tr }}"
/>
</form>
</li>
{% endif %}
</ul>
</div>
</div>

View file

@ -0,0 +1,48 @@
<h4 id="comment-{{ comment.ID }}" class="comment-header">
{% set commented_at = comment.CommentTS | dt | as_timezone(timezone) %}
{% set view_account_info = 'View account information for %s' | tr | format(comment.User.Username) %}
{{
"%s commented on %s" | tr | format(
('<a href="/account/%s" title="%s">%s</a>' | format(
comment.User.Username,
view_account_info,
comment.User.Username
)) if request.user.is_authenticated() else
(comment.User.Username),
'<a href="#comment-%s" class="date">%s</a>' | format(
comment.ID,
commented_at.strftime("%Y-%m-%d %H:%M")
)
)
| safe
}}
{% if is_maintainer %}
<form class="delete-comment-form" method="post" action="/pkgbase/{{ pkgname }}/">
<fieldset style="display:inline;">
<input type="hidden" name="action" value="do_DeleteComment" />
<input type="hidden" name="comment_id" value="{{ comment.ID }}"/>
<input type="hidden" name="return_to" value="/pkgbase/{{ pkgname }}/"/>
<input type="image" class="delete-comment" src="/images/x.min.svg" width="11" height="11" alt="{{ 'Delete comment' | tr }}" title="{{ 'Delete comment' | tr }}" name="submit" value="1" />
</fieldset>
</form>
<a href="/pkgbase/{{ pkgname }}/edit-comment/?comment_id={{ comment.ID }}" class="edit-comment" title="Edit comment"><img src="/images/pencil.min.svg" alt="Edit comment" width="11" height="11"></a>
{% endif %}
<form class="pin-comment-form" method="post" action="/pkgbase/{{ pkgname }}/">
<fieldset style="display:inline;">
<input type="hidden" name="action" value="do_PinComment"/>
<input type="hidden" name="comment_id" value="{{ comment.ID }}"/>
<input type="hidden" name="package_base" value="{{ pkgbase_id }}"/>
<input type="hidden" name="return_to" value="/pkgbase/{{ pkgname }}/"/>
<input type="image" class="pin-comment" src="/images/pin.min.svg" width="11" height="11" alt="{{ 'Pin comment' | tr }}" title="{{ 'Pin comment' | tr }}" name="submit" value="1"/>
</fieldset>
</form>
</h4>
<div id="comment-{{ comment.ID }}-content" class="article-content">
<div>
{% if comment.RenderedComment %}
{{ comment.RenderedComment | safe }}
{% else %}
{{ comment.Comments }}
{% endif %}
</div>
</div>

View file

@ -5,6 +5,7 @@
- comments (list) - comments (list)
--> -->
{% if request.user.is_authenticated() %}
<div id="generic-form" class="box"> <div id="generic-form" class="box">
<h2>Add Comment</h2> <h2>Add Comment</h2>
<form action="/pkgbase/{{ pkgname }}/" method="post"> <form action="/pkgbase/{{ pkgname }}/" method="post">
@ -31,11 +32,24 @@
</p> </p>
<p> <p>
<input type="submit" value="{{ 'Add Comment' | tr }}"/> <input type="submit" value="{{ 'Add Comment' | tr }}"/>
{% if not notifications_enabled %}
<span class="comment-enable-notifications">
<input id="id_enable_notifications"
type="checkbox"
name="enable_notifications"
/>
<label for="id_enable_notifications">
{{ "Enable notifications" | tr }}
</label>
</span>
{% endif %}
</p> </p>
</fieldset> </fieldset>
</form> </form>
</div> </div>
{% endif %}
{% if comments.count() %}
<div class="comments package-comments"> <div class="comments package-comments">
<div class="comments-header"> <div class="comments-header">
<h3> <h3>
@ -43,49 +57,78 @@
<span class="arrow"></span> <span class="arrow"></span>
</h3> </h3>
</div> </div>
{% for comment in comments %} {% for comment in comments.all() %}
<h4 id="comment-{{ comment.ID }}" class="comment-header"> {% include "partials/packages/comment.html" %}
{% set commented_at = comment.CommentTS | dt | as_timezone(timezone) %}
{% set view_account_info = 'View account information for %s' | tr | format(comment.User.Username) %}
{{
"%s commented on %s" | tr | format(
'<a href="/account/%s" title="%s">%s</a>' | format(
comment.User.Username,
view_account_info,
comment.User.Username
),
'<a href="#comment-%s" class="date">%s</a>' | format(
comment.ID,
commented_at.strftime("%Y-%m-%d %H:%M")
)
)
| safe
}}
{% if is_maintainer %}
<form class="delete-comment-form" method="post" action="/pkgbase/{{ pkgname }}/">
<fieldset style="display:inline;">
<input type="hidden" name="action" value="do_DeleteComment" />
<input type="hidden" name="comment_id" value="{{ comment.ID }}"/>
<input type="hidden" name="return_to" value="/pkgbase/{{ pkgname }}/"/>
<input type="image" class="delete-comment" src="/images/x.min.svg" width="11" height="11" alt="{{ 'Delete comment' | tr }}" title="{{ 'Delete comment' | tr }}" name="submit" value="1" />
</fieldset>
</form>
<a href="/pkgbase/{{ pkgname }}/edit-comment/?comment_id={{ comment.ID }}" class="edit-comment" title="Edit comment"><img src="/images/pencil.min.svg" alt="Edit comment" width="11" height="11"></a>
{% endif %}
<form class="pin-comment-form" method="post" action="/pkgbase/{{ pkgname }}/">
<fieldset style="display:inline;">
<input type="hidden" name="action" value="do_PinComment"/>
<input type="hidden" name="comment_id" value="{{ comment.ID }}"/>
<input type="hidden" name="package_base" value="{{ pkgbase_id }}"/>
<input type="hidden" name="return_to" value="/pkgbase/{{ pkgname }}/"/>
<input type="image" class="pin-comment" src="/images/pin.min.svg" width="11" height="11" alt="{{ 'Pin comment' | tr }}" title="{{ 'Pin comment' | tr }}" name="submit" value="1"/>
</fieldset>
</form>
</h4>
<div id="comment-{{ comment.ID }}-content" class="article-content">
<div>
<p>{{ comment.RenderedComment | safe }}</p>
</div>
</div>
{% endfor %} {% endfor %}
</div> </div>
{% endif %}
<script type="text/javascript" nonce="{{ request.user.nonce }}">
function add_busy_indicator(sibling) {
const img = document.createElement('img');
img.src = "/images/ajax-loader.gif";
img.classList.add('ajax-loader');
img.style.height = 11;
img.style.width = 16;
img.alt = "Busy…";
sibling.insertAdjacentElement('afterend', img);
}
function remove_busy_indicator(sibling) {
const elem = sibling.nextElementSibling;
elem.parentNode.removeChild(elem);
}
function getParentsUntil(elem, className) {
// Limit to 10 depth
for ( ; elem && elem !== document; elem = elem.parentNode) {
if (elem.matches(className)) {
break;
}
}
return elem;
}
function handleEditCommentClick(event) {
event.preventDefault();
const parent_element = getParentsUntil(event.target, '.comment-header');
const parent_id = parent_element.id;
const comment_id = parent_id.substr(parent_id.indexOf('-') + 1);
// The div class="article-content" which contains the comment
const edit_form = parent_element.nextElementSibling;
const params = new URLSearchParams({
type: "get-comment-form",
arg: comment_id,
base_id: {{ pkgbase.ID }},
pkgbase_name: {{ pkgbase.Name }}
});
const url = '/rpc?' + params.toString();
add_busy_indicator(event.target);
fetch(url, {
method: 'GET'
})
.then(function(response) { return response.json(); })
.then(function(data) {
remove_busy_indicator(event.target);
if (data.success) {
edit_form.innerHTML = data.form;
edit_form.querySelector('textarea').focus();
} else {
alert(data.error);
}
});
}
document.addEventListener('DOMContentLoaded', function() {
const divs = document.querySelectorAll('.edit-comment');;
for (let div of divs) {
div.addEventListener('click', handleEditCommentClick);
}
});
</script>

View file

@ -0,0 +1,145 @@
<table id="pkginfo">
<tr>
<th>{{ "Git Clone URL" | tr }}:</th>
<td>
<a class="copy" href="{{ git_clone_uri_anon | format(pkgbase.Name) }}">{{ git_clone_uri_anon | format(pkgbase.Name) }}</a> ({{ "read-only" | tr }}, {{ "click to copy" | tr }})
{% if is_maintainer %}
<br /> <a class="copy" href="{{ git_clone_uri_priv | format(pkgbase.Name) }}">{{ git_clone_uri_priv | format(pkgbase.Name) }}</a> ({{ "click to copy" | tr }})
{% endif %}
</td>
</tr>
{% if show_package_details | default(False) %}
<tr>
<th>{{ "Package Base" | tr }}:</th>
<td class="wrap">
<a href="/pkgbase/{{ pkgbase.Name }}">
{{ pkgbase.Name }}
</a>
</td>
</tr>
<tr>
<th>{{ "Description" | tr }}:</th>
<td class="wrap">{{ pkgbase.packages.first().Description }}</td>
</tr>
<tr>
<th>{{ "Upstream URL" | tr }}:</th>
<td class="wrap">
{% set pkg = pkgbase.packages.first() %}
{% if pkg.URL %}
<a href="{{ pkg.URL }}">{{ pkg.URL }}</a>
{% else %}
{{ "None" | tr }}
{% endif %}
</td>
</tr>
{% endif %}
{% if pkgbase.keywords.count() %}
<tr>
<th>{{ "Keywords" | tr }}:</th>
{% if is_maintainer %}
<td>
<form method="update"
action="/pkgbase/{{ pkgbase.Name }}/keywords/"
>
<div>
<input type="text"
name="keywords"
value="{{ pkgbase.keywords | join(' ', attribute='Keyword') }}"
/>
<input type="submit" value="{{ 'Update' | tr }}"/>
</div>
</form>
</td>
{% else %}
<td>
{% for keyword in pkgbase.keywords %}
<a class="keyword"
href="/packages/?K={{ keyword.Keyword }}&amp;SB=p"
>
{{ keyword.Keyword }}
</a>
{% endfor %}
</td>
{% endif %}
</tr>
{% endif %}
{% if licenses and licenses.count() and show_package_details | default(False) %}
<tr>
<th>{{ "Licenses" | tr }}:</th>
<td>{{ licenses | join(', ', attribute='Name') | default('None' | tr) }} </td>
</tr>
{% endif %}
{% if show_package_details | default(False) %}
<tr>
<th>{{ "Conflicts" | tr }}:</th>
<td class="wrap">
{{ conflicts | join(', ', attribute='RelName') }}
</td>
</tr>
{% endif %}
<tr>
<th>{{ "Submitter" | tr }}:</th>
<td>
{% if request.user.is_authenticated() %}
<a href="/account/{{ pkgbase.Submitter.Username }}">
{{ pkgbase.Submitter.Username | default("None" | tr) }}
</a>
{% else %}
{{ pkgbase.Submitter.Username | default("None" | tr) }}
{% endif %}
</td>
</tr>
<tr>
<th>{{ "Maintainer" | tr }}:</th>
<td>
{% if request.user.is_authenticated() %}
<a href="/account/{{ pkgbase.Maintainer.Username }}">
{{ pkgbase.Maintainer.Username | default("None" | tr) }}
</a>
{% else %}
{{ pkgbase.Maintainer.Username | default("None" | tr) }}
{% endif %}
</td>
</tr>
<tr>
<th>{{ "Last Packager" | tr }}:</th>
<td>
{% if request.user.is_authenticated() %}
<a href="/account/{{ pkgbase.Packager.Username }}">
{{ pkgbase.Packager.Username | default("None" | tr) }}
</a>
{% else %}
{{ pkgbase.Packager.Username | default("None" | tr) }}
{% endif %}
</td>
</tr>
<tr>
<th>{{ "Votes" | tr }}:</th>
{% if not is_maintainer %}
<td>{{ pkgbase.package_votes.count() }}</td>
{% else %}
<td>
<a href="/pkgbase/{{ pkgbase.Name }}/voters">
{{ pkgbase.package_votes.count() }}
</a>
</td>
{% endif %}
</tr>
<tr>
<th>{{ "Popularity" | tr }}:</th>
<td>{{ pkgbase.Popularity | number_format(6 if pkgbase.Popularity <= 0.2 else 2) }}</td>
</tr>
<tr>
{% set submitted = pkgbase.SubmittedTS | dt | as_timezone(timezone) %}
<th>{{ "First Submitted" | tr }}:</th>
<td>{{ "%s" | format(submitted.strftime("%Y-%m-%d %H:%M")) }}</td>
</tr>
<tr>
<th>{{ "Last Updated" | tr }}:</th>
{% set updated = pkgbase.ModifiedTS | dt | as_timezone(timezone) %}
<td>{{ "%s" | format(updated.strftime("%Y-%m-%d %H:%M")) }}</td>
</tr>
</table>
<script type="text/javascript" src="/static/js/copy.js"></script>

View file

@ -1,87 +0,0 @@
<!--
This partial requires pkgname to render
-->
<div id="detailslinks" class="listing">
<div id="actionlist">
<h4>{{ "Package Actions" | tr }}</h4>
<ul class="small">
<li>
<a href="/cgit/aur.git/tree/PKGBUILD?h={{ pkgname }}">
{{ "View PKGBUILD" | tr }}
</a>
/
<a href="/cgit/aur.git/log/?h={{ pkgname }}">
{{ "View Changes" | tr }}
</a>
</li>
<li>
<a href="/cgit/aur.git/snapshot/{{ pkgname }}.tar.gz">
{{ "Download snapshot" | tr }}
</a>
<li>
<a href="https://wiki.archlinux.org/title/Special:Search?search={{ pkgname }}">
{{ "Search wiki" | tr }}
</a>
</li>
<li><span class="flagged"></span></li>
<li>
<a href="/pkgbase/{{ pkgname }}/flag/">
{{ "Flag package out-of-date" | tr }}
</a>
</li>
<li>
<form action="/pkgbase/{{ pkgname }}/vote/" method="post">
<input type="submit"
class="button text-button"
name="do_Vote"
value="{{ 'Vote for this package' | tr }}" />
</form>
</li>
<li>
<form action="/pkgbase/{{ pkgname }}/unnotify/" method="post">
<input type="submit"
class="button text-button"
name="do_UnNotify"
value="{{ 'Disable notifications' | tr }}"
/>
</form>
</li>
{% if is_maintainer %}
<li>
<a href="/pkgbase/{{ pkgname }}/comaintainers/">
{{ "Manage Co-Maintainers" | tr }}
</a>
</li>
{% endif %}
<li><span class="flagged"></span></li>
{% if request.user.is_authenticated() %}
<li>
<a href="/pkgbase/{{ pkgname }}/request/">
{{ "Submit Request" | tr }}
</a>
</li>
{% endif %}
{% if is_maintainer %}
<li>
<a href="/pkgbase/{{ pkgname }}/delete/">
{{ "Delete Package" | tr }}
</a>
</li>
<li>
<a href="/pkgbase/{{ pkgname }}/merge/">
{{ "Merge Package" | tr }}
</a>
</li>
<li>
<form action="/pkgbase/{{ pkgname }}/disown/" method="post">
<input type="submit"
class="button text-button"
name="do_Disown"
value="{{ 'Disown Package' | tr }}"
/>
</form>
</li>
{% endif %}
</ul>
</div>
</div>

View file

@ -0,0 +1,54 @@
<div id="pkgdeps" class="listing">
<h3>Dependencies ({{ dependencies.count() }})</h3>
<ul id="pkgdepslist">
{% for dep in dependencies.all() %}
<li>
{% set broken = not dep.is_package() %}
{% if broken %}
<span class="broken">
{% else %}
<a href="{{ dep.DepName | pkgname_link }}">
{% endif %}
{{ dep.DepName }}
{% if broken %}
</span>
{% else %}
</a>
{% endif %}
{{ dep.Package | provides_list(dep.DepName) | safe }}
{% set extra = dep | dep_extra %}
{% if extra %}
<em>{{ dep | dep_extra_desc }}</em>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
<div id="pkgreqs" class="listing">
<h3>Required by ({{ required_by.count() }})</h3>
<ul id="pkgreqslist">
{% for depender in required_by.all() %}
<li>
<a href="{{ depender.Package | package_link }}">
{{ depender.Package.Name }}
</a>
<em>{{ depender | dep_extra }}</em>
</li>
{% endfor %}
</ul>
</div>
<div id="pkgfiles" class="listing">
<h3>Sources ({{ sources.count() }})</h3>
</div>
<div>
<ul id="pkgsrcslist">
{% for source in sources.all() %}
<li>
<a href="{{ source.Source }}">{{ source.Source }}</a>
</li>
{% endfor %}
</ul>
</div>

View file

@ -1,95 +0,0 @@
{% extends "partials/layout.html" %}
{% block pageContent %}
{% include "partials/packages/search.html" %}
<div id="pkgdetails" class="box">
<h2>Package Details: {{ pkgbase.Name }}</h2>
{% set result = pkgbase %}
{% set pkgname = "result.Name" %}
{% include "partials/packages/package_actions.html" %}
<table id="pkginfo">
<tr>
<th>{{ "Git Clone URL" | tr }}:</th>
<td>
<a class="copy" href="{{ git_clone_uri_anon | format(pkgbase.Name) }}">{{ git_clone_uri_anon | format(pkgbase.Name) }}</a> ({{ "read-only" | tr }}, {{ "click to copy" | tr }})
{% if is_maintainer %}
<br /> <a class="copy" href="{{ git_clone_uri_priv | format(pkgbase.Name) }}">{{ git_clone_uri_priv | format(pkgbase.Name) }}</a> ({{ "click to copy" | tr }})
{% endif %}
</td>
</tr>
<tr>
<th>{{ "Keywords" | tr }}:</th>
{% if is_maintainer %}
<td>
<form method="post" action="/pkgbase/{{ pkgbase.Name }}/">
<div>
<input type="hidden" name="action" value="do_SetKeywords" />
<input type="text" name="keywords" value="{{ pkgbase.keywords | join(' ', attribute='Keyword') }}"/>
<input type="submit" value="Update"/>
</div>
</form>
</td>
{% else %}
<td>
{% for item in pkgbase.keywords %}
<a class="keyword" href="/packages/?K={{ item.Keyword }}&amp;SB=p">{{ item.Keyword }}</a>
{% endfor %}
</td>
{% endif %}
</tr>
<tr>
<th>{{ "Submitter" | tr }}:</th>
<td>{{ pkgbase.Submitter.Username | default("None") }}</td>
</tr>
<tr>
<th>{{ "Maintainer" | tr }}:</th>
<td>{{ pkgbase.Maintainer.Username | default("None") }}</td>
</tr>
<tr>
<th>{{ "Last Packager" | tr }}:</th>
<td>{{ pkgbase.Packager.Username | default("None") }}</td>
</tr>
<tr>
<th>{{ "Votes" | tr }}:</th>
<td>{{ pkgbase.NumVotes }}</td>
</tr>
<tr>
<th>{{ "Popularity" | tr }}:</th>
<td>{{ '%0.2f' % pkgbase.Popularity | float }}</td>
</tr>
<tr>
{% set submitted = pkgbase.SubmittedTS | dt | as_timezone(timezone) %}
<th>{{ "First Submitted" | tr }}:</th>
<td>{{ "%s" | tr | format(submitted.strftime("%Y-%m-%d %H:%M")) }}</td>
</tr>
<tr>
<th>{{ "Last Updated" | tr }}:</th>
{% set updated = pkgbase.ModifiedTS | dt | as_timezone(timezone) %}
<td>{{ "%s" | tr | format(updated.strftime("%Y-%m-%d %H:%M")) }}</td>
</tr>
</table>
<div id="metadata">
<div id="pkgs" class="listing">
<!-- This needs to be replaced with the real implementation. -->
<h3>Packages ({{ packages_count }})</h3>
<ul>
{% for result in packages %}
<li>
<a href="/packages/{{ result.Name }}/"
title="{{ 'View packages details for' | tr }} {{ result.Name }}">
{{ result.Name }}
</a>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% set pkgname = result.Name %}
{% set pkgbase_id = result.ID %}
{% set comments = comments %}
{% include "partials/packages/comments.html" %}
{% endblock %}

View file

@ -77,6 +77,13 @@ def test_package_dependencies():
assert pkgdep in optdepends.package_dependencies assert pkgdep in optdepends.package_dependencies
assert pkgdep in package.package_dependencies assert pkgdep in package.package_dependencies
assert not pkgdep.is_package()
base = create(PackageBase, Name=pkgdep.DepName, Maintainer=user)
create(Package, PackageBase=base, Name=pkgdep.DepName)
assert pkgdep.is_package()
def test_package_dependencies_null_package_raises_exception(): def test_package_dependencies_null_package_raises_exception():
from aurweb.db import session from aurweb.db import session

View file

@ -0,0 +1,283 @@
from datetime import datetime
from http import HTTPStatus
import pytest
from fastapi.testclient import TestClient
from aurweb import asgi, db
from aurweb.models.account_type import USER_ID, AccountType
from aurweb.models.dependency_type import DependencyType
from aurweb.models.official_provider import OfficialProvider
from aurweb.models.package import Package
from aurweb.models.package_base import PackageBase
from aurweb.models.package_comment import PackageComment
from aurweb.models.package_dependency import PackageDependency
from aurweb.models.package_keyword import PackageKeyword
from aurweb.models.package_relation import PackageRelation
from aurweb.models.relation_type import PROVIDES_ID, RelationType
from aurweb.models.user import User
from aurweb.testing import setup_test_db
from aurweb.testing.html import parse_root
from aurweb.testing.requests import Request
def package_endpoint(package: Package) -> str:
return f"/packages/{package.Name}"
def create_package(pkgname: str, maintainer: User,
autocommit: bool = True) -> Package:
pkgbase = db.create(PackageBase,
Name=pkgname,
Maintainer=maintainer,
autocommit=False)
return db.create(Package, Name=pkgbase.Name, PackageBase=pkgbase,
autocommit=autocommit)
def create_package_dep(package: Package, depname: str,
dep_type_name: str = "depends",
autocommit: bool = True) -> PackageDependency:
dep_type = db.query(DependencyType,
DependencyType.Name == dep_type_name).first()
return db.create(PackageDependency,
DependencyType=dep_type,
Package=package,
DepName=depname,
autocommit=autocommit)
def create_package_rel(package: Package,
relname: str,
autocommit: bool = True) -> PackageRelation:
rel_type = db.query(RelationType,
RelationType.ID == PROVIDES_ID).first()
return db.create(PackageRelation,
RelationType=rel_type,
Package=package,
RelName=relname)
@pytest.fixture(autouse=True)
def setup():
setup_test_db(
User.__tablename__,
Package.__tablename__,
PackageBase.__tablename__,
PackageDependency.__tablename__,
PackageRelation.__tablename__,
PackageKeyword.__tablename__,
OfficialProvider.__tablename__
)
@pytest.fixture
def client() -> TestClient:
""" Yield a FastAPI TestClient. """
yield TestClient(app=asgi.app)
@pytest.fixture
def user() -> User:
""" Yield a user. """
account_type = db.query(AccountType, AccountType.ID == USER_ID).first()
yield db.create(User, Username="test",
Email="test@example.org",
Passwd="testPassword",
AccountType=account_type)
@pytest.fixture
def maintainer() -> User:
""" Yield a specific User used to maintain packages. """
account_type = db.query(AccountType, AccountType.ID == USER_ID).first()
yield db.create(User, Username="test_maintainer",
Email="test_maintainer@example.org",
Passwd="testPassword",
AccountType=account_type)
@pytest.fixture
def package(maintainer: User) -> Package:
""" Yield a Package created by user. """
pkgbase = db.create(PackageBase,
Name="test-package",
Maintainer=maintainer)
yield db.create(Package,
PackageBase=pkgbase,
Name=pkgbase.Name)
def test_package_not_found(client: TestClient):
with client as request:
resp = request.get("/packages/not_found")
assert resp.status_code == int(HTTPStatus.NOT_FOUND)
def test_package_official_not_found(client: TestClient, package: Package):
""" When a Package has a matching OfficialProvider record, it is not
hosted on AUR, but in the official repositories. Getting a package
with this kind of record should return a status code 404. """
db.create(OfficialProvider,
Name=package.Name,
Repo="core",
Provides=package.Name)
with client as request:
resp = request.get(package_endpoint(package))
assert resp.status_code == int(HTTPStatus.NOT_FOUND)
def test_package(client: TestClient, package: Package):
""" Test a single /packages/{name} route. """
with client as request:
resp = request.get(package_endpoint(package))
assert resp.status_code == int(HTTPStatus.OK)
root = parse_root(resp.text)
h2 = root.find('.//div[@id="pkgdetails"]/h2')
sections = h2.text.split(":")
assert sections[0] == "Package Details"
name, version = sections[1].lstrip().split(" ")
assert name == package.Name
version == package.Version
rows = root.findall('.//table[@id="pkginfo"]//tr')
row = rows[1] # Second row is our target.
pkgbase = row.find("./td/a")
assert pkgbase.text.strip() == package.PackageBase.Name
def test_package_comments(client: TestClient, user: User, package: Package):
now = (datetime.utcnow().timestamp())
comment = db.create(PackageComment, PackageBase=package.PackageBase,
User=user, Comments="Test comment", CommentTS=now)
cookies = {"AURSID": user.login(Request(), "testPassword")}
with client as request:
resp = request.get(package_endpoint(package), cookies=cookies)
assert resp.status_code == int(HTTPStatus.OK)
root = parse_root(resp.text)
expected = [
comment.Comments
]
comments = root.xpath('.//div[contains(@class, "package-comments")]'
'/div[@class="article-content"]/div/text()')
for i, row in enumerate(expected):
assert comments[i].strip() == row
def test_package_authenticated(client: TestClient, user: User,
package: Package):
""" We get the same here for either authenticated or not
authenticated. Form inputs are presented to maintainers.
This process also occurs when pkgbase.html is rendered. """
cookies = {"AURSID": user.login(Request(), "testPassword")}
with client as request:
resp = request.get(package_endpoint(package), cookies=cookies)
assert resp.status_code == int(HTTPStatus.OK)
expected = [
"View PKGBUILD",
"View Changes",
"Download snapshot",
"Search wiki",
"Flag package out-of-date",
"Vote for this package",
"Enable notifications",
"Submit Request"
]
for expected_text in expected:
assert expected_text in resp.text
def test_package_authenticated_maintainer(client: TestClient,
maintainer: User,
package: Package):
cookies = {"AURSID": maintainer.login(Request(), "testPassword")}
with client as request:
resp = request.get(package_endpoint(package), cookies=cookies)
assert resp.status_code == int(HTTPStatus.OK)
expected = [
"View PKGBUILD",
"View Changes",
"Download snapshot",
"Search wiki",
"Flag package out-of-date",
"Vote for this package",
"Enable notifications",
"Manage Co-Maintainers",
"Submit Request",
"Delete Package",
"Merge Package",
"Disown Package"
]
for expected_text in expected:
assert expected_text in resp.text
def test_package_dependencies(client: TestClient, maintainer: User,
package: Package):
# Create a normal dependency of type depends.
dep_pkg = create_package("test-dep-1", maintainer, autocommit=False)
dep = create_package_dep(package, dep_pkg.Name, autocommit=False)
# Also, create a makedepends.
make_dep_pkg = create_package("test-dep-2", maintainer, autocommit=False)
make_dep = create_package_dep(package, make_dep_pkg.Name,
dep_type_name="makedepends",
autocommit=False)
# And... a checkdepends!
check_dep_pkg = create_package("test-dep-3", maintainer, autocommit=False)
check_dep = create_package_dep(package, check_dep_pkg.Name,
dep_type_name="checkdepends",
autocommit=False)
# Geez. Just stop. This is optdepends.
opt_dep_pkg = create_package("test-dep-4", maintainer, autocommit=False)
opt_dep = create_package_dep(package, opt_dep_pkg.Name,
dep_type_name="optdepends",
autocommit=False)
broken_dep = create_package_dep(package, "test-dep-5",
dep_type_name="depends",
autocommit=False)
# Create an official provider record.
db.create(OfficialProvider, Name="test-dep-99",
Repo="core", Provides="test-dep-99",
autocommit=False)
official_dep = create_package_dep(package, "test-dep-99",
autocommit=False)
# Also, create a provider who provides our test-dep-99.
provider = create_package("test-provider", maintainer, autocommit=False)
create_package_rel(provider, dep.DepName)
with client as request:
resp = request.get(package_endpoint(package))
assert resp.status_code == int(HTTPStatus.OK)
root = parse_root(resp.text)
expected = [
dep.DepName,
make_dep.DepName,
check_dep.DepName,
opt_dep.DepName,
official_dep.DepName
]
pkgdeps = root.findall('.//ul[@id="pkgdepslist"]/li/a')
for i, expectation in enumerate(expected):
assert pkgdeps[i].text.strip() == expectation
broken_node = root.find('.//ul[@id="pkgdepslist"]/li/span')
assert broken_node.text.strip() == broken_dep.DepName

View file

@ -0,0 +1,51 @@
import pytest
from fastapi.testclient import TestClient
from aurweb import asgi, db
from aurweb.models.account_type import USER_ID, AccountType
from aurweb.models.official_provider import OFFICIAL_BASE, OfficialProvider
from aurweb.models.package import Package
from aurweb.models.package_base import PackageBase
from aurweb.models.user import User
from aurweb.packages import util
from aurweb.testing import setup_test_db
@pytest.fixture(autouse=True)
def setup():
setup_test_db(
User.__tablename__,
Package.__tablename__,
PackageBase.__tablename__,
OfficialProvider.__tablename__
)
@pytest.fixture
def maintainer() -> User:
account_type = db.query(AccountType, AccountType.ID == USER_ID).first()
yield db.create(User, Username="test_maintainer",
Email="test_maintainer@examepl.org",
Passwd="testPassword",
AccountType=account_type)
@pytest.fixture
def package(maintainer: User) -> Package:
pkgbase = db.create(PackageBase, Name="test-pkg", Maintainer=maintainer)
yield db.create(Package, Name=pkgbase.Name, PackageBase=pkgbase)
@pytest.fixture
def client() -> TestClient:
yield TestClient(app=asgi.app)
def test_package_link(client: TestClient, maintainer: User, package: Package):
db.create(OfficialProvider,
Name=package.Name,
Repo="core",
Provides=package.Name)
expected = f"{OFFICIAL_BASE}/packages/?q={package.Name}"
assert util.package_link(package) == expected

15
test/test_templates.py Normal file
View file

@ -0,0 +1,15 @@
import pytest
from aurweb.templates import register_filter
@register_filter("func")
def func(): pass
def test_register_filter_exists_key_error():
""" Most instances of register_filter are tested through module
imports or template renders, so we only test failures here. """
with pytest.raises(KeyError):
@register_filter("func")
def some_func(): pass

6
web/html/js/copy.js Normal file
View file

@ -0,0 +1,6 @@
document.addEventListener('DOMContentLoaded', function() {
document.querySelector('.copy').addEventListener('click', function(e) {
e.preventDefault();
navigator.clipboard.writeText(event.target.text);
});
});