*: Implements tags for apps

- Add tags to Info component of apps. Use only English tags for all operations.
Localized tags are used for presentation to the user only. Add tags to all the
apps. Conventions (English):

  1. Tags describing use cases should be in kebab case.

  2. Protocols in tag names should be in their canonical format.

  3. Tags needn't be 100% technically correct. This can get in the way of
  comparing apps using a tag. Words that describe use cases that users can
  easily understand should be preferred over being pedantic.

  4. Tags should be short, ideally not more than 2 words. Avoid conjunctions
  like "and", "or" in tags.

  5. Avoid redundant words like "server", or "web-clients". Most apps on
  FreedomBox are either servers or web clients.

  6. Keep your nouns singular in tags.

- Use query
params to filter the Apps page by tags. When all tags are removed, redirect to /apps.

- Add UI elements to add and remove tag filters in the Apps page. Make the UI
similar to GitLab issue tags. Since there are 40 apps, there will be at least 40
tags. Selecting a tag from a dropdown will be difficult on mobile devices. A
fuzzy search is useful to find tags to add to the filter. Allow user to find the
best match for the search term and highlight it visually. The user can then
press Enter to select the highlighted tag. Make tag search case-insensitive.
Make the dropdown menu scrollable with a fixed size. User input is debounced by
300 ms during search.

- tests: Add missing mock in test_module_loader.py

- Add functional tests

[sunil]

- 'list' can be used instead of 'List' for typing in recent Python versions.

- Reserve tripe-quoted strings for docstrings.

- Undo some changes in module initialization, use module_name for logging
errors.

- isort and yapf changes.

- Encode parameters before adding them to the URL.

Tests:

- Tested the functionality of filtering by tag with one tag and two tags.

Signed-off-by: Joseph Nuthalapati <njoseph@riseup.net>
Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Sunil Mohan Adapa <sunil@medhas.org>
This commit is contained in:
Joseph Nuthalapati 2024-09-20 15:49:45 +05:30 committed by Sunil Mohan Adapa
parent e2ae29acb2
commit e5b7ed4faf
No known key found for this signature in database
GPG Key ID: 43EA1CFF0AA7C5F2
93 changed files with 622 additions and 72 deletions

View File

@ -7,7 +7,7 @@ import collections
import enum
import inspect
import logging
from typing import ClassVar, TypeAlias
from typing import ClassVar, Dict, List, TypeAlias
from plinth import cfg
from plinth.diagnostic_check import DiagnosticCheck
@ -434,7 +434,7 @@ class Info(FollowerComponent):
def __init__(self, app_id, version, is_essential=False, depends=None,
name=None, icon=None, icon_filename=None,
short_description=None, description=None, manual_page=None,
clients=None, donation_url=None):
clients=None, donation_url=None, tags=None):
"""Store the basic properties of an app as a component.
Each app must contain at least one component of this type to provide
@ -504,6 +504,9 @@ class Info(FollowerComponent):
'donation_url' is a link to a webpage that describes how to
donate to the upstream project.
'tags' is a list of tags that describe the app. Tags help users to find
similar apps or alternatives and discover use cases.
"""
self.component_id = app_id + '-info'
self.app_id = app_id
@ -518,9 +521,19 @@ class Info(FollowerComponent):
self.manual_page = manual_page
self.clients = clients
self.donation_url = donation_url
self.tags = tags or []
if clients:
clients_module.validate(clients)
@classmethod
def list_tags(self) -> list:
"""Return a list of untranslated tags."""
tags = set()
for app in App.list():
tags.update(app.info.tags)
return list(tags)
class EnableState(LeaderComponent):
"""A component to hold the enable state of an app using a simple flag.
@ -626,8 +639,8 @@ def _initialize_module(module_name, module):
for app_class in app_classes:
app_class()
except Exception as exception:
logger.exception('Exception while running init for %s: %s', module,
exception)
logger.exception('Exception while running init for module %s: %s',
module_name, exception)
if cfg.develop:
raise

View File

@ -3,7 +3,6 @@
Discover, load and manage FreedomBox applications.
"""
import collections
import importlib
import logging
import pathlib
@ -16,7 +15,7 @@ from plinth.signals import pre_module_loading
logger = logging.getLogger(__name__)
loaded_modules = collections.OrderedDict()
loaded_modules = dict()
_modules_to_load = None
@ -36,8 +35,8 @@ def load_modules():
for module_import_path in get_modules_to_load():
module_name = module_import_path.split('.')[-1]
try:
loaded_modules[module_name] = importlib.import_module(
module_import_path)
module = importlib.import_module(module_import_path)
loaded_modules[module_name] = module
except Exception as exception:
logger.exception('Could not import %s: %s', module_import_path,
exception)

View File

@ -58,7 +58,7 @@ class BepastyApp(app_module.App):
icon_filename='bepasty',
short_description=_('File & Snippet Sharing'),
description=_description, manual_page='bepasty',
clients=manifest.clients)
clients=manifest.clients, tags=manifest.tags)
self.add(info)
menu_item = menu.Menu('menu-bepasty', info.name,

View File

@ -19,3 +19,5 @@ backup = {
},
'services': ['uwsgi'],
}
tags = [_('File Sharing'), _('Pastebin')]

View File

@ -56,7 +56,7 @@ class CalibreApp(app_module.App):
name=_('calibre'), icon_filename='calibre',
short_description=_('E-book Library'),
description=_description, manual_page='Calibre',
clients=manifest.clients,
clients=manifest.clients, tags=manifest.tags,
donation_url='https://calibre-ebook.com/donate')
self.add(info)

View File

@ -16,3 +16,5 @@ backup = {
},
'services': ['calibre-server-freedombox']
}
tags = [_('Ebook'), _('Library'), _('Ebook Reader')]

View File

@ -51,7 +51,8 @@ class CoturnApp(app_module.App):
info = app_module.Info(app_id=self.app_id, version=self._version,
name=_('Coturn'), icon_filename='coturn',
short_description=_('VoIP Helper'),
description=_description, manual_page='Coturn')
description=_description, manual_page='Coturn',
tags=manifest.tags)
self.add(info)
menu_item = menu.Menu('menu-coturn', info.name, info.short_description,

View File

@ -1,3 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
from django.utils.translation import gettext_lazy as _
backup = {'secrets': {'directories': ['/etc/coturn']}, 'services': ['coturn']}
tags = [_('VoIP'), _('STUN'), _('TURN')]

View File

@ -47,7 +47,8 @@ class DelugeApp(app_module.App):
short_description=_('BitTorrent Web Client'),
description=_description, manual_page='Deluge',
clients=manifest.clients,
donation_url='https://www.patreon.com/deluge_cas')
donation_url='https://www.patreon.com/deluge_cas',
tags=manifest.tags)
self.add(info)
menu_item = menu.Menu('menu-deluge', info.name, info.short_description,

View File

@ -17,3 +17,5 @@ backup = {
},
'services': ['deluged', 'deluge-web']
}
tags = [_('File Sharing'), _('BitTorrent'), _('Client'), _('P2P')]

View File

@ -56,11 +56,13 @@ class EjabberdApp(app_module.App):
"""Create components for the app."""
super().__init__()
info = app_module.Info(
app_id=self.app_id, version=self._version, depends=['coturn'],
name=_('ejabberd'), icon_filename='ejabberd',
short_description=_('Chat Server'), description=_description,
manual_page='ejabberd', clients=manifest.clients)
info = app_module.Info(app_id=self.app_id, version=self._version,
depends=['coturn'], name=_('ejabberd'),
icon_filename='ejabberd',
short_description=_('Chat Server'),
description=_description,
manual_page='ejabberd',
clients=manifest.clients, tags=manifest.tags)
self.add(info)
menu_item = menu.Menu('menu-ejabberd', info.name,

View File

@ -119,3 +119,12 @@ backup = {
},
'services': ['ejabberd']
}
tags = [
_('XMPP'),
_('VoIP'),
_('IM'),
_('Encrypted Messaging'),
_('Audio Chat'),
_('Video Chat')
]

View File

@ -63,7 +63,7 @@ class EmailApp(plinth.app.App):
icon_filename='email',
short_description=_('Email Server'),
description=_description, manual_page='Email',
clients=manifest.clients,
clients=manifest.clients, tags=manifest.tags,
donation_url='https://rspamd.com/support.html')
self.add(info)

View File

@ -78,3 +78,5 @@ backup = {
},
'services': ['postfix', 'dovecot', 'rspamd']
}
tags = [_('Email')]

View File

@ -61,7 +61,7 @@ class FeatherWikiApp(app_module.App):
short_description=_('Personal Notebooks'),
description=_description,
manual_page='FeatherWiki',
clients=manifest.clients)
clients=manifest.clients, tags=manifest.tags)
self.add(info)
menu_item = menu.Menu('menu-featherwiki', info.name,

View File

@ -14,3 +14,5 @@ clients = [{
}]
backup = {'data': {'directories': [str(wiki_dir)]}}
tags = [_('Wiki'), _('Note Taking'), _('Website'), _('Quine'), _('non-Debian')]

View File

@ -48,7 +48,7 @@ class GitwebApp(app_module.App):
name=_('Gitweb'), icon_filename='gitweb',
short_description=_('Simple Git Hosting'),
description=_description, manual_page='GitWeb',
clients=manifest.clients)
clients=manifest.clients, tags=manifest.tags)
self.add(info)
menu_item = menu.Menu('menu-gitweb', info.name, info.short_description,

View File

@ -33,3 +33,5 @@ clients = [
]
backup = {'data': {'directories': [GIT_REPO_PATH]}}
tags = [_('Git'), _('Version Control'), _('Dev Tool')]

View File

@ -52,7 +52,7 @@ class I2PApp(app_module.App):
name=_('I2P'), icon_filename='i2p',
short_description=_('Anonymity Network'),
description=_description, manual_page='I2P',
clients=manifest.clients)
clients=manifest.clients, tags=manifest.tags)
self.add(info)
menu_item = menu.Menu('menu-i2p', info.name, info.short_description,

View File

@ -39,3 +39,5 @@ backup = {
},
'services': ['i2p']
}
tags = [_('Anonymity Network'), _('Censorship Resistance')]

View File

@ -45,7 +45,7 @@ class IkiwikiApp(app_module.App):
name=_('ikiwiki'), icon_filename='ikiwiki',
short_description=_('Wiki and Blog'),
description=_description, manual_page='Ikiwiki',
clients=manifest.clients,
clients=manifest.clients, tags=manifest.tags,
donation_url='https://ikiwiki.info/tipjar/')
self.add(info)

View File

@ -11,3 +11,5 @@ clients = [{
}]
backup = {'data': {'directories': ['/var/lib/ikiwiki/', '/var/www/ikiwiki/']}}
tags = [_('Wiki'), _('Blog'), _('Website')]

View File

@ -42,7 +42,7 @@ class InfinotedApp(app_module.App):
short_description=_('Gobby Server'),
description=_description,
manual_page='Infinoted',
clients=manifest.clients)
clients=manifest.clients, tags=manifest.tags)
self.add(info)
menu_item = menu.Menu('menu-infinoted', info.name,

View File

@ -42,3 +42,5 @@ backup = {
},
'services': ['infinoted']
}
tags = [_('Note Taking'), _('Collaborative Editing'), _('Gobby')]

View File

@ -43,7 +43,7 @@ class JanusApp(app_module.App):
icon_filename='janus',
short_description=_('Video Room'),
description=_description, manual_page='Janus',
clients=manifest.clients)
clients=manifest.clients, tags=manifest.tags)
self.add(info)
menu_item = menu.Menu('menu-janus', info.name, info.short_description,

View File

@ -12,3 +12,5 @@ clients = [{
}]
backup: dict = {}
tags = [_('Video Conferencing'), _('WebRTC')]

View File

@ -38,7 +38,7 @@ class JSXCApp(app_module.App):
name=_('JSXC'), icon_filename='jsxc',
short_description=_('Chat Client'),
description=_description, manual_page='JSXC',
clients=manifest.clients)
clients=manifest.clients, tags=manifest.tags)
self.add(info)
menu_item = menu.Menu('menu-jsxc', info.name, info.short_description,

View File

@ -12,3 +12,5 @@ clients = [{
}]
backup: dict = {}
tags = [_('XMPP'), _('Client')]

View File

@ -56,7 +56,7 @@ class KiwixApp(app_module.App):
app_id=self.app_id, version=self._version, name=_('Kiwix'),
icon_filename='kiwix', short_description=_('Offline Wikipedia'),
description=_description, manual_page='Kiwix',
clients=manifest.clients,
clients=manifest.clients, tags=manifest.tags,
donation_url='https://www.kiwix.org/en/support-us/')
self.add(info)

View File

@ -18,3 +18,5 @@ backup = {
},
'services': ['kiwix-server-freedombox']
}
tags = [_('Offline Reader'), _('Archival'), _('Censorship Resistance')]

View File

@ -54,7 +54,8 @@ class MatrixSynapseApp(app_module.App):
app_id=self.app_id, version=self._version, depends=['coturn'],
name=_('Matrix Synapse'), icon_filename='matrixsynapse',
short_description=_('Chat Server'), description=_description,
manual_page='MatrixSynapse', clients=manifest.clients)
manual_page='MatrixSynapse', clients=manifest.clients,
tags=manifest.tags)
self.add(info)
menu_item = menu.Menu('menu-matrixsynapse', info.name,

View File

@ -96,3 +96,13 @@ backup = {
},
'services': ['matrix-synapse']
}
tags = [
_('Chat Room'),
_('Encrypted Messaging'),
_('IM'),
_('Audio Chat'),
_('Video Chat'),
_('Matrix'),
_('VoIP')
]

View File

@ -52,7 +52,8 @@ class MediaWikiApp(app_module.App):
short_description=_('Wiki'),
description=_description,
manual_page='MediaWiki',
clients=manifest.clients)
clients=manifest.clients,
tags=manifest.tags)
self.add(info)
menu_item = menu.Menu('menu-mediawiki', info.name,

View File

@ -21,3 +21,5 @@ backup = {
},
'services': ['mediawiki-jobrunner']
}
tags = [_('Wiki'), _('Website')]

View File

@ -56,7 +56,7 @@ class MinetestApp(app_module.App):
app_id=self.app_id, version=self._version, name=_('Minetest'),
icon_filename='minetest', short_description=_('Block Sandbox'),
description=_description, manual_page='Minetest',
clients=manifest.clients,
clients=manifest.clients, tags=manifest.tags,
donation_url='https://www.minetest.net/get-involved/#donate')
self.add(info)

View File

@ -45,3 +45,5 @@ backup = {
},
'services': ['minetest-server']
}
tags = [_('Game'), _('Block Sandbox')]

View File

@ -46,7 +46,8 @@ class MiniDLNAApp(app_module.App):
short_description=_('Simple Media Server'),
description=_description,
manual_page='MiniDLNA',
clients=manifest.clients)
clients=manifest.clients,
tags=manifest.tags)
self.add(info)
menu_item = menu.Menu(

View File

@ -112,3 +112,5 @@ backup = {
},
'services': ['minidlna']
}
tags = [_('Media Server'), _('Television'), _('UPnP'), _('DLNA')]

View File

@ -44,7 +44,7 @@ class MinifluxApp(app_module.App):
short_description=_('News Feed Reader'),
description=_description,
manual_page='miniflux',
clients=manifest.clients,
clients=manifest.clients, tags=manifest.tags,
donation_url='https://miniflux.app/#donations')
self.add(info)

View File

@ -134,3 +134,5 @@ backup = {
},
'services': ['miniflux']
}
tags = [_('Feed Reader'), _('News'), _('RSS'), _('ATOM')]

View File

@ -45,7 +45,7 @@ class MumbleApp(app_module.App):
app_id=self.app_id, version=self._version, name=_('Mumble'),
icon_filename='mumble', short_description=_('Voice Chat'),
description=_description, manual_page='Mumble',
clients=manifest.clients,
clients=manifest.clients, tags=manifest.tags,
donation_url='https://wiki.mumble.info/wiki/Donate')
self.add(info)

View File

@ -63,3 +63,5 @@ backup = {
},
'services': ['mumble-server']
}
tags = [_('Audio Chat'), _('VoIP')]

View File

@ -59,7 +59,7 @@ class NextcloudApp(app_module.App):
icon_filename='nextcloud',
short_description=_('File Storage & Collaboration'),
description=_description, manual_page='Nextcloud',
clients=manifest.clients)
clients=manifest.clients, tags=manifest.tags)
self.add(info)
menu_item = menu.Menu('menu-nextcloud', info.name,

View File

@ -52,3 +52,5 @@ backup = {
'files': ['/var/lib/plinth/backups-data/nextcloud-database.sql']
}
}
tags = [_('Cloud Storage'), _('File Sharing'), _('non-Debian')]

View File

@ -46,7 +46,7 @@ class OpenVPNApp(app_module.App):
name=_('OpenVPN'), icon_filename='openvpn',
short_description=_('Virtual Private Network'),
description=_description, manual_page='OpenVPN',
clients=manifest.clients)
clients=manifest.clients, tags=manifest.tags)
self.add(info)
menu_item = menu.Menu('menu-openvpn', info.name,

View File

@ -56,3 +56,5 @@ clients = [{
'url': 'https://tunnelblick.net/downloads.html'
}]
}]
tags = [_('VPN'), _('Anonymity'), _('Remote Access')]

View File

@ -53,7 +53,8 @@ class PrivoxyApp(app_module.App):
app_id=self.app_id, version=self._version, name=_('Privoxy'),
icon_filename='privoxy', short_description=_('Web Proxy'),
description=_description, manual_page='Privoxy',
donation_url='https://www.privoxy.org/faq/general.html#DONATE')
donation_url='https://www.privoxy.org/faq/general.html#DONATE',
tags=manifest.tags)
self.add(info)
menu_item = menu.Menu('menu-privoxy', info.name,

View File

@ -3,4 +3,8 @@
Application manifest for privoxy.
"""
from django.utils.translation import gettext_lazy as _
backup: dict = {}
tags = [_('Ad Blocker'), _('Proxy'), _('Local Network')]

View File

@ -51,7 +51,7 @@ class QuasselApp(app_module.App):
name=_('Quassel'), icon_filename='quassel',
short_description=_('IRC Client'),
description=_description, manual_page='Quassel',
clients=manifest.clients)
clients=manifest.clients, tags=manifest.tags)
self.add(info)
menu_item = menu.Menu('menu-quassel', info.name,

View File

@ -50,3 +50,5 @@ backup = {
},
'services': ['quasselcore'],
}
tags = [_('Chat Room'), _('IRC'), _('Client')]

View File

@ -54,7 +54,8 @@ class RadicaleApp(app_module.App):
short_description=_('Calendar and Addressbook'),
description=_description,
manual_page='Radicale',
clients=manifest.clients)
clients=manifest.clients,
tags=manifest.tags)
self.add(info)
menu_item = menu.Menu('menu-radicale', info.name,

View File

@ -87,3 +87,11 @@ backup = {
},
'services': ['uwsgi']
}
tags = [
_('Calendar'),
_('Contacts'),
_('Synchronization'),
_('CalDAV'),
_('CardDAV')
]

View File

@ -51,7 +51,8 @@ class RoundcubeApp(app_module.App):
short_description=_('Email Client'),
description=_description,
manual_page='Roundcube',
clients=manifest.clients)
clients=manifest.clients,
tags=manifest.tags)
self.add(info)
menu_item = menu.Menu('menu-roundcube', info.name,

View File

@ -11,8 +11,12 @@ clients = [{
}]
backup = {
'data': {
'files': ['/etc/roundcube/freedombox-config.php',
'/var/lib/dbconfig-common/sqlite3/roundcube/roundcube']
}
'data': {
'files': [
'/etc/roundcube/freedombox-config.php',
'/var/lib/dbconfig-common/sqlite3/roundcube/roundcube'
]
}
}
tags = [_('Email'), _('Contacts'), _('Client')]

View File

@ -50,7 +50,7 @@ class RSSBridgeApp(app_module.App):
short_description=_('RSS Feed Generator'),
description=_description,
manual_page='RSSBridge', donation_url=None,
clients=manifest.clients)
clients=manifest.clients, tags=manifest.tags)
self.add(info)
menu_item = menu.Menu('menu-rssbridge', info.name,

View File

@ -1,7 +1,6 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
from django.utils.translation import gettext_lazy as _
"""
Application manifest for RSS-Bridge.
"""
@ -14,8 +13,6 @@ clients = [{
}]
}]
backup = {
'data': {
'files': ['/etc/rss-bridge/is_public']
}
}
backup = {'data': {'files': ['/etc/rss-bridge/is_public']}}
tags = [_('Feed Generator'), _('News'), _('RSS'), _('ATOM')]

View File

@ -53,7 +53,7 @@ class SambaApp(app_module.App):
app_id=self.app_id, version=self._version, name=_('Samba'),
icon_filename='samba', short_description=_('Network File Storage'),
manual_page='Samba', description=_description,
clients=manifest.clients,
clients=manifest.clients, tags=manifest.tags,
donation_url='https://www.samba.org/samba/donations.html')
self.add(info)

View File

@ -84,3 +84,5 @@ clients = [{
}]
backup: dict = {}
tags = [_('File Sharing'), _('Local Network')]

View File

@ -41,7 +41,7 @@ class SearxApp(app_module.App):
app_id=self.app_id, version=self._version, name=_('Searx'),
icon_filename='searx', short_description=_('Web Search'),
description=_description, manual_page='Searx',
clients=manifest.clients,
clients=manifest.clients, tags=manifest.tags,
donation_url='https://searx.me/static/donate.html')
self.add(info)

View File

@ -13,3 +13,5 @@ clients = [{
PUBLIC_ACCESS_SETTING_FILE = '/etc/searx/allow_public_access'
backup = {'config': {'files': [PUBLIC_ACCESS_SETTING_FILE]}}
tags = [_('Web Search'), _('Metasearch Engine')]

View File

@ -36,7 +36,7 @@ class ShaarliApp(app_module.App):
name=_('Shaarli'), icon_filename='shaarli',
short_description=_('Bookmarks'),
description=_description, manual_page='Shaarli',
clients=manifest.clients)
clients=manifest.clients, tags=manifest.tags)
self.add(info)
menu_item = menu.Menu('menu-shaarli', info.name,

View File

@ -1,6 +1,6 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Application manifest for bind.
Application manifest for Shaarli.
"""
from django.utils.translation import gettext_lazy as _
@ -30,3 +30,5 @@ clients = [{
}]
backup = {'data': {'directories': ['/var/lib/shaarli/data']}}
tags = [_('Bookmarks'), _('Link Blog'), _('Single User')]

View File

@ -51,7 +51,8 @@ class ShadowsocksApp(app_module.App):
icon_filename='shadowsocks',
short_description=_('Bypass Censorship'),
description=_description,
manual_page='Shadowsocks')
manual_page='Shadowsocks',
tags=manifest.tags)
self.add(info)
menu_item = menu.Menu('menu-shadowsocks', info.name,

View File

@ -3,6 +3,8 @@
Application manifest for Shadowsocks Client.
"""
from django.utils.translation import gettext_lazy as _
backup = {
'secrets': {
'files': [
@ -11,3 +13,11 @@ backup = {
},
'services': ['shadowsocks-libev-local@freedombox']
}
tags = [
_('Proxy'),
_('Client'),
_('SOCKS5'),
_('Censorship Resistance'),
_('Shadowsocks')
]

View File

@ -47,7 +47,8 @@ class ShadowsocksServerApp(app_module.App):
app_id=self.app_id, version=self._version,
name=_('Shadowsocks Server'), icon_filename='shadowsocks',
short_description=_('Help Others Bypass Censorship'),
description=_description, manual_page='Shadowsocks')
description=_description, manual_page='Shadowsocks',
tags=manifest.tags)
self.add(info)
menu_item = menu.Menu('menu-shadowsocks-server', info.name,

View File

@ -3,6 +3,8 @@
Application manifest for Shadowsocks Server.
"""
from django.utils.translation import gettext_lazy as _
backup = {
'secrets': {
'files': [
@ -11,3 +13,5 @@ backup = {
},
'services': ['shadowsocks-libev-server@fbxserver']
}
tags = [_('Proxy'), _('SOCKS5'), _('Censorship Resistance'), _('Shadowsocks')]

View File

@ -32,7 +32,8 @@ class SharingApp(app_module.App):
super().__init__()
info = app_module.Info(app_id=self.app_id, version=self._version,
name=_('Sharing'), icon_filename='sharing',
manual_page='Sharing', description=_description)
manual_page='Sharing', description=_description,
tags=manifest.tags)
self.add(info)
menu_item = menu.Menu('menu-sharing', info.name, None,

View File

@ -3,6 +3,8 @@
Application manifest for sharing.
"""
from django.utils.translation import gettext_lazy as _
backup = {
'config': {
'files': ['/etc/apache2/conf-available/sharing-freedombox.conf']
@ -13,3 +15,5 @@ backup = {
'name': 'sharing-freedombox'
}]
}
tags = [_('File Sharing')]

View File

@ -61,7 +61,8 @@ class SyncthingApp(app_module.App):
description=_description,
manual_page='Syncthing',
clients=manifest.clients,
donation_url='https://syncthing.net/donations/')
donation_url='https://syncthing.net/donations/',
tags=manifest.tags)
self.add(info)
menu_item = menu.Menu('menu-syncthing', info.name,

View File

@ -54,3 +54,5 @@ backup = {
},
'services': ['syncthing@syncthing']
}
tags = [_('Synchronization'), _('File Sharing'), _('Cloud Storage')]

View File

@ -66,7 +66,8 @@ class TiddlyWikiApp(app_module.App):
short_description=_('Non-linear Notebooks'),
description=_description,
manual_page='TiddlyWiki',
clients=manifest.clients)
clients=manifest.clients,
tags=manifest.tags)
self.add(info)
menu_item = menu.Menu('menu-tiddlywiki', info.name,

View File

@ -14,3 +14,14 @@ clients = [{
}]
backup = {'data': {'directories': [str(wiki_dir)]}}
tags = [
_('Wiki'),
_('Note Taking'),
_('Website'),
_('Journal'),
_('Digital Garden'),
_('Zettelkasten'),
_('Quine'),
_('non-Debian')
]

View File

@ -62,7 +62,7 @@ class TorApp(app_module.App):
], name=_('Tor'), icon_filename='tor',
short_description=_('Anonymity Network'),
description=_description, manual_page='Tor',
clients=manifest.clients,
clients=manifest.clients, tags=manifest.tags,
donation_url='https://donate.torproject.org/')
self.add(info)

View File

@ -52,3 +52,10 @@ backup = {
},
'services': ['tor@plinth']
}
tags = [
_('Relay'),
_('Anonymity Network'),
_('Censorship Resistance'),
_('Tor')
]

View File

@ -57,7 +57,7 @@ class TorProxyApp(app_module.App):
short_description=_('Anonymity Network'),
description=_description,
manual_page='TorProxy',
clients=manifest.clients,
clients=manifest.clients, tags=manifest.tags,
donation_url='https://donate.torproject.org/')
self.add(info)

View File

@ -50,3 +50,11 @@ backup = {
},
'services': ['tor@fbxproxy']
}
tags = [
_('Proxy'),
_('SOCKS5'),
_('Anonymity Network'),
_('Censorship Resistance'),
_('Tor')
]

View File

@ -76,7 +76,8 @@ class TransmissionApp(app_module.App):
short_description=_('BitTorrent Web Client'),
description=_description, manual_page='Transmission',
clients=manifest.clients,
donation_url='https://transmissionbt.com/donate/')
donation_url='https://transmissionbt.com/donate/',
tags=manifest.tags)
self.add(info)
menu_item = menu.Menu('menu-transmission', info.name,

View File

@ -35,3 +35,5 @@ backup = {
},
'services': ['transmission-daemon']
}
tags = [_('File Sharing'), _('BitTorrent'), _('Client'), _('P2P')]

View File

@ -51,7 +51,7 @@ class TTRSSApp(app_module.App):
short_description=_('News Feed Reader'),
description=_description,
manual_page='TinyTinyRSS',
clients=manifest.clients,
clients=manifest.clients, tags=manifest.tags,
donation_url='https://www.patreon.com/cthulhoo')
self.add(info)

View File

@ -51,3 +51,5 @@ backup = {
},
'services': ['tt-rss']
}
tags = [_('Feed Reader'), _('News'), _('RSS'), _('ATOM')]

View File

@ -48,7 +48,7 @@ class WireguardApp(app_module.App):
icon_filename='wireguard',
short_description=_('Virtual Private Network'),
description=_description, manual_page='WireGuard',
clients=manifest.clients,
clients=manifest.clients, tags=manifest.tags,
donation_url='https://www.wireguard.com/donations/')
self.add(info)

View File

@ -41,3 +41,5 @@ clients = [{
'url': 'https://apps.apple.com/us/app/wireguard/id1441195209'
}]
}]
tags = [_('VPN'), _('Anonymity'), _('Remote Access'), _('P2P')]

View File

@ -53,7 +53,7 @@ class WordPressApp(app_module.App):
app_id=self.app_id, version=self._version, name=_('WordPress'),
icon_filename='wordpress', short_description=_('Website and Blog'),
description=_description, manual_page='WordPress',
clients=manifest.clients,
clients=manifest.clients, tags=manifest.tags,
donation_url='https://wordpressfoundation.org/donate/')
self.add(info)

View File

@ -12,11 +12,15 @@ clients = [{
backup = {
'data': {
'files': ['/var/lib/plinth/backups-data/wordpress-database.sql',
'/etc/wordpress/is_public'],
'files': [
'/var/lib/plinth/backups-data/wordpress-database.sql',
'/etc/wordpress/is_public'
],
'directories': ['/var/lib/wordpress/']
},
'secrets': {
'directories': ['/etc/wordpress/']
},
}
tags = [_('Website'), _('Blog')]

View File

@ -22,3 +22,5 @@ backup = {
],
}
}
tags = [_('Image Viewer'), _('Photo'), _('Library')]

View File

@ -51,6 +51,17 @@
{% endfor %}
{% endblock %}
{% if app_info.tags %}
<div>
{% for tag in app_info.tags %}
<a href="{% url 'apps' %}?tag={{ tag|urlencode }}"
class="badge badge-pill badge-light">
{% trans tag %}
</a>
{% endfor %}
</div>
{% endif %}
{% if app_info.manual_page %}
<p class="manual-page">
<a href="{% url 'help:manual-page' lang='-' page=app_info.manual_page %}">

View File

@ -7,3 +7,40 @@
{% load i18n %}
{% block body_class %}apps-page{% endblock %}
{% block tags %}
{% if tags %}
<div class="container">
<div class="dropdown searchable-dropdown mr-2">
<div class="tag-input">
<div id="selected-tags">
{% for tag in tags %}
<span class="badge badge-light badge-pill tag-badge mr-2 d-flex align-items-center" data-tag="{{ tag }}">
{% trans tag %}
<button class="btn btn-sm btn-light remove-tag" data-tag="{{ tag }}">
<i class="fa fa-times"></i>
</button>
</span>
{% endfor %}
</div>
<input id="add-tag-input" type="search" class="form-control dropdown-toggle"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"
autocomplete="off">
<div class="dropdown-menu" aria-labelledby="add-tag-input">
<ul class="dropdown-items list-unstyled">
{% for tag in all_tags %}
{% if tag not in tags %}
<li class="dropdown-item" data-value="{{ tag }}">{% trans tag %}</li>
{% endif %}
{% endfor %}
</ul>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block page_js %}
<script type="text/javascript" src="{% static 'tags.js' %}"></script>
{% endblock %}

View File

@ -15,10 +15,12 @@
</div>
</div>
{% block tags %}{% endblock %}
<div class="container card-container">
<div class="row">
<div class="card-list card-list-primary">
{% for item in submenu.sorted_items %}
{% for item in menu_items %}
{% if not show_disabled or item.is_enabled %}
{% if advanced_mode or not item.advanced %}
{% include "card.html" %}
@ -34,7 +36,7 @@
<div class="card-section-title">{% trans "Disabled" %}</div>
<div class="row">
<div class="card-list card-list-disabled">
{% for item in submenu.sorted_items %}
{% for item in menu_items %}
{% if not item.is_enabled %}
{% if advanced_mode or not item.advanced %}
{% include "card.html" %}

View File

View File

@ -0,0 +1,60 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Functional, browser based tests for transmission app.
"""
import pytest
from selenium.webdriver.common.keys import Keys
from plinth.tests import functional
pytestmark = [pytest.mark.tags]
def _is_app_listed(session_browser, app):
"""Assert that the specified app is listed on the page."""
app_links = session_browser.links.find_by_href(f'/plinth/apps/{app}/')
assert len(app_links) == 1
@pytest.fixture(autouse=True)
def fixture_bittorrent_tag(session_browser):
"""Click on the BitTorrent tag."""
bittorrent_tag = '/plinth/apps/?tag=BitTorrent'
functional.login(session_browser)
functional.nav_to_module(session_browser, 'transmission')
with functional.wait_for_page_update(session_browser, timeout=10,
expected_url=bittorrent_tag):
session_browser.links.find_by_href(bittorrent_tag).click()
def test_bittorrent_tag(session_browser):
"""Test that the BitTorrent tag lists Deluge and Transmission."""
_is_app_listed(session_browser, 'deluge')
_is_app_listed(session_browser, 'transmission')
def test_search_for_tag(session_browser):
"""Test that searching for a tag returns the expected apps."""
search_input = session_browser.driver.find_element_by_id('add-tag-input')
with functional.wait_for_page_update(
session_browser, timeout=10,
expected_url='/plinth/apps/?tag=BitTorrent&tag=File%20Sharing'):
search_input.click()
search_input.send_keys('file')
search_input.send_keys(Keys.ENTER)
for app in ['deluge', 'nextcloud', 'sharing', 'syncthing', 'transmission']:
_is_app_listed(session_browser, app)
def test_click_on_tag(session_browser):
"""Test that clicking on a tag lists the expected apps."""
search_input = session_browser.driver.find_element_by_id('add-tag-input')
with functional.wait_for_page_update(
session_browser, timeout=10,
expected_url='/plinth/apps/?tag=BitTorrent&tag=Cloud%20Storage'):
search_input.click()
session_browser.find_by_css(
".dropdown-item[data-value='Cloud Storage']").click()
for app in ['deluge', 'nextcloud', 'syncthing', 'transmission']:
_is_app_listed(session_browser, app)

View File

@ -23,6 +23,7 @@ from django.views.generic.edit import FormView
from stronghold.decorators import public
from plinth import app as app_module
from plinth import menu
from plinth.daemon import app_is_running
from plinth.modules.config import get_advanced_mode
from plinth.modules.firewall.components import get_port_forwarding_info
@ -120,13 +121,61 @@ def index(request):
class AppsIndexView(TemplateView):
"""View for apps index"""
"""View for apps index.
This view supports filtering apps by one or more tags. If no tags are
provided, it will show all the apps. If one or more tags are provided,
it will select apps matching any of the provided tags.
"""
template_name = 'apps.html'
@staticmethod
def _pick_menu_items(menu_items, selected_tags):
"""Return a sorted list of menu items filtered by tags."""
def _mismatch_map(menu_item) -> list[bool]:
"""Return a list of mismatches for selected tags.
A mismatch is when a selected tag is *not* present in the list of
tags for menu item.
"""
menu_tags = set(menu_item.app.info.tags)
return [tag not in menu_tags for tag in selected_tags]
def _sort_key(menu_item):
"""Returns a comparable tuple to sort menu items.
Sort items by tag match count first, then by the order of matched
tags in user specified order, then by the order set by menu item,
and then by the name of the menu item in current locale (by
configured collation order).
"""
return (_mismatch_map(menu_item).count(True),
_mismatch_map(menu_item), menu_item.order,
menu_item.name.lower())
# Filter out menu items that don't match any of the selected tags. If
# no tags are selected, return all menu items. Otherwise, return all
# menu items that have at least one matching tag.
filtered_menu_items = [
menu_item for menu_item in menu_items
if (not selected_tags) or (not all(_mismatch_map(menu_item)))
]
return sorted(filtered_menu_items, key=_sort_key)
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context['show_disabled'] = True
context['advanced_mode'] = get_advanced_mode()
tags = self.request.GET.getlist('tag', [])
menu_items = menu.main_menu.active_item(self.request).items
context['tags'] = tags
context['all_tags'] = app_module.Info.list_tags()
context['menu_items'] = self._pick_menu_items(menu_items, tags)
return context
@ -364,8 +413,9 @@ class SetupView(TemplateView):
context['setup_state'] = setup_state
context['operations'] = operation.manager.filter(app.app_id)
context['show_rerun_setup'] = False
context['show_uninstall'] = (not app.info.is_essential and setup_state
!= app_module.App.SetupState.NEEDS_SETUP)
context['show_uninstall'] = (
not app.info.is_essential
and setup_state != app_module.App.SetupState.NEEDS_SETUP)
# Perform expensive operation only if needed.
if not context['operations']:

170
static/tags.js Normal file
View File

@ -0,0 +1,170 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
/**
* @licstart The following is the entire license notice for the JavaScript
* code in this page.
*
* This file is part of FreedomBox.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @licend The above is the entire license notice for the JavaScript code
* in this page.
*/
/**
* Update the URL path and history based on the selected tags.
*
* If no tags are selected, redirects to the base apps path. Otherwise,
* constructs a new URL with query parameters for each tag and updates
* the browser history.
*
* @param {string[]} tags - An array of selected tag names.
*/
function updatePath(tags) {
const appsPath = window.location.pathname;
if (tags.length === 0) {
this.location.assign(appsPath);
} else {
let queryParams = tags.map(tag => `tag=${tag}`).join('&');
let newPath = `${appsPath}?${queryParams}`;
this.history.pushState({ tags: tags }, '', newPath);
this.location.assign(newPath);
}
}
/**
* Get a list of tags currently displayed, excluding a specific tag if provided.
*
* Iterates through the tag badges in the UI, extracts their text content,
* and returns an array of tag names.
*
* @param {string} [tagToRemove] - The name of the tag to exclude.
* @returns {string[]} An array of tag names currently displayed.
*/
function getTags(tagToRemove) {
const tagBadges = document.querySelectorAll('#selected-tags .tag-badge');
return Array.from(tagBadges)
.map(tag => tag.dataset.tag)
.filter(tagName => tagName !== tagToRemove);
}
/**
* Filter and highlight the best matching tag based on the search term.
*
* This function updates the visibility and highlighting of dropdown items
* to match the user's input in the search box. It determines the best
* matching item and marks it as active.
*
* @param {KeyboardEvent} event - The keyboard event that triggered the search.
*/
function findMatchingTag(addTagInput, dropdownItems) {
const searchTerm = addTagInput.value.toLowerCase().trim();
// Remove highlighting from all items
dropdownItems.forEach(item => item.classList.remove('active'));
let bestMatch = null;
dropdownItems.forEach(item => {
const text = item.textContent.toLowerCase();
if (text.includes(searchTerm)) {
item.style.display = 'block';
function matchesEarly () {
return text.indexOf(searchTerm) < bestMatch.textContent.toLowerCase().indexOf(searchTerm);
};
if (bestMatch === null || matchesEarly()) {
bestMatch = item;
}
} else {
item.style.display = 'none';
}
});
// Highlight the best match
if (bestMatch) {
bestMatch.classList.add('active');
}
}
/**
* Manage tag-related UI interactions for filtering and displaying apps.
*
* This script manages the user interface for filtering apps based on
* selected tags. It provides functionality for adding and removing tags,
* updating the URL based on selected tags, and displaying a set of
* available tags in a searchable dropdown.
*/
document.addEventListener('DOMContentLoaded', function () {
// Remove Tag handler.
document.querySelectorAll('.remove-tag').forEach(button => {
button.addEventListener('click', () => {
let tag = button.dataset.tag;
let tags = getTags(tag);
updatePath(tags);
});
});
/**
* Searchable dropdown for selecting tags.
*
* As the user types in the input field, the dropdown list is filtered
* to show only matching items. The best matching item (first match if
* multiple match) is highlighted. Pressing Enter selects the
* highlighted item and adds it as a tag.
*/
const addTagInput = document.getElementById('add-tag-input');
const dropdownItems = document.querySelectorAll('li.dropdown-item');
var timeoutId;
addTagInput.addEventListener('keyup', (event) => {
clearTimeout(timeoutId);
// Select the active tag if the user pressed Enter
if (event.key === 'Enter') {
dropdownItems.forEach(item => {
if (item.classList.contains('active')) {
item.click();
}
});
}
// Debounce the user input for search with 300ms delay.
timeoutId = setTimeout(findMatchingTag(addTagInput, dropdownItems), 300);
});
dropdownItems.forEach(item => {
item.addEventListener('click', () => {
const selectedTag = item.dataset.value;
// Add the selected tag and update the path.
let tags = getTags('');
tags.push(selectedTag);
updatePath(tags);
// Reset the input field and dropdown.
addTagInput.value = '';
dropdownItems.forEach(item => {
item.style.display = '';
item.classList.remove('active');
});
});
});
// Handle browser back/forward navigation.
window.addEventListener('popstate', function (event) {
if (event.state && event.state.tags) {
updatePath(event.state.tags);
}
});
});

View File

@ -309,7 +309,6 @@ html {
}
#wrapper {
min-height: 100%;
position: relative;
}
@ -321,6 +320,59 @@ html {
margin-bottom: 1.25rem;
}
/* Tag Input Container */
.tag-input {
display: flex;
align-items: center;
border: 1px solid #ced4da;
border-radius: .25rem;
padding: .375rem .75rem;
width: 100%;
background-color: #fff;
margin-bottom: 3rem;
}
/* Selected Tags */
.tag-input #selected-tags {
display: flex;
flex-wrap: wrap;
margin-right: .5rem;
}
.tag-input .tag {
background-color: #e9ecef; /* Light gray background */
border-radius: .25rem;
padding: .25rem .5rem;
margin-right: .25rem;
margin-bottom: .25rem;
display: flex;
align-items: center;
}
/* Remove tag button */
.tag-badge .remove-tag {
background-color: #f8f9fa; /* Match the tag's background color */
border: none;
padding: 0.25rem 0.5rem;
cursor: pointer;
}
/* Adjust input field width */
.tag-input input[type="search"] {
flex-grow: 1;
border: none;
outline: none;
box-shadow: none;
width: auto;
min-width: 3rem;
}
/* dropdown-menu for tags is a scrollable list */
.tag-input .dropdown-menu {
bottom: calc(-100% - 10px);
overflow-y: auto;
}
@media (min-width: 768px) {
.content-container {
padding: 1.5rem 3rem 3rem;