mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-01-21 07:55:00 +00:00
*: 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:
parent
e2ae29acb2
commit
e5b7ed4faf
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -19,3 +19,5 @@ backup = {
|
||||
},
|
||||
'services': ['uwsgi'],
|
||||
}
|
||||
|
||||
tags = [_('File Sharing'), _('Pastebin')]
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -16,3 +16,5 @@ backup = {
|
||||
},
|
||||
'services': ['calibre-server-freedombox']
|
||||
}
|
||||
|
||||
tags = [_('Ebook'), _('Library'), _('Ebook Reader')]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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')]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -17,3 +17,5 @@ backup = {
|
||||
},
|
||||
'services': ['deluged', 'deluge-web']
|
||||
}
|
||||
|
||||
tags = [_('File Sharing'), _('BitTorrent'), _('Client'), _('P2P')]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -119,3 +119,12 @@ backup = {
|
||||
},
|
||||
'services': ['ejabberd']
|
||||
}
|
||||
|
||||
tags = [
|
||||
_('XMPP'),
|
||||
_('VoIP'),
|
||||
_('IM'),
|
||||
_('Encrypted Messaging'),
|
||||
_('Audio Chat'),
|
||||
_('Video Chat')
|
||||
]
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -78,3 +78,5 @@ backup = {
|
||||
},
|
||||
'services': ['postfix', 'dovecot', 'rspamd']
|
||||
}
|
||||
|
||||
tags = [_('Email')]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -14,3 +14,5 @@ clients = [{
|
||||
}]
|
||||
|
||||
backup = {'data': {'directories': [str(wiki_dir)]}}
|
||||
|
||||
tags = [_('Wiki'), _('Note Taking'), _('Website'), _('Quine'), _('non-Debian')]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -33,3 +33,5 @@ clients = [
|
||||
]
|
||||
|
||||
backup = {'data': {'directories': [GIT_REPO_PATH]}}
|
||||
|
||||
tags = [_('Git'), _('Version Control'), _('Dev Tool')]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -39,3 +39,5 @@ backup = {
|
||||
},
|
||||
'services': ['i2p']
|
||||
}
|
||||
|
||||
tags = [_('Anonymity Network'), _('Censorship Resistance')]
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -11,3 +11,5 @@ clients = [{
|
||||
}]
|
||||
|
||||
backup = {'data': {'directories': ['/var/lib/ikiwiki/', '/var/www/ikiwiki/']}}
|
||||
|
||||
tags = [_('Wiki'), _('Blog'), _('Website')]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -42,3 +42,5 @@ backup = {
|
||||
},
|
||||
'services': ['infinoted']
|
||||
}
|
||||
|
||||
tags = [_('Note Taking'), _('Collaborative Editing'), _('Gobby')]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -12,3 +12,5 @@ clients = [{
|
||||
}]
|
||||
|
||||
backup: dict = {}
|
||||
|
||||
tags = [_('Video Conferencing'), _('WebRTC')]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -12,3 +12,5 @@ clients = [{
|
||||
}]
|
||||
|
||||
backup: dict = {}
|
||||
|
||||
tags = [_('XMPP'), _('Client')]
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -18,3 +18,5 @@ backup = {
|
||||
},
|
||||
'services': ['kiwix-server-freedombox']
|
||||
}
|
||||
|
||||
tags = [_('Offline Reader'), _('Archival'), _('Censorship Resistance')]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -96,3 +96,13 @@ backup = {
|
||||
},
|
||||
'services': ['matrix-synapse']
|
||||
}
|
||||
|
||||
tags = [
|
||||
_('Chat Room'),
|
||||
_('Encrypted Messaging'),
|
||||
_('IM'),
|
||||
_('Audio Chat'),
|
||||
_('Video Chat'),
|
||||
_('Matrix'),
|
||||
_('VoIP')
|
||||
]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -21,3 +21,5 @@ backup = {
|
||||
},
|
||||
'services': ['mediawiki-jobrunner']
|
||||
}
|
||||
|
||||
tags = [_('Wiki'), _('Website')]
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -45,3 +45,5 @@ backup = {
|
||||
},
|
||||
'services': ['minetest-server']
|
||||
}
|
||||
|
||||
tags = [_('Game'), _('Block Sandbox')]
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -112,3 +112,5 @@ backup = {
|
||||
},
|
||||
'services': ['minidlna']
|
||||
}
|
||||
|
||||
tags = [_('Media Server'), _('Television'), _('UPnP'), _('DLNA')]
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -134,3 +134,5 @@ backup = {
|
||||
},
|
||||
'services': ['miniflux']
|
||||
}
|
||||
|
||||
tags = [_('Feed Reader'), _('News'), _('RSS'), _('ATOM')]
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -63,3 +63,5 @@ backup = {
|
||||
},
|
||||
'services': ['mumble-server']
|
||||
}
|
||||
|
||||
tags = [_('Audio Chat'), _('VoIP')]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -52,3 +52,5 @@ backup = {
|
||||
'files': ['/var/lib/plinth/backups-data/nextcloud-database.sql']
|
||||
}
|
||||
}
|
||||
|
||||
tags = [_('Cloud Storage'), _('File Sharing'), _('non-Debian')]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -56,3 +56,5 @@ clients = [{
|
||||
'url': 'https://tunnelblick.net/downloads.html'
|
||||
}]
|
||||
}]
|
||||
|
||||
tags = [_('VPN'), _('Anonymity'), _('Remote Access')]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -3,4 +3,8 @@
|
||||
Application manifest for privoxy.
|
||||
"""
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
backup: dict = {}
|
||||
|
||||
tags = [_('Ad Blocker'), _('Proxy'), _('Local Network')]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -50,3 +50,5 @@ backup = {
|
||||
},
|
||||
'services': ['quasselcore'],
|
||||
}
|
||||
|
||||
tags = [_('Chat Room'), _('IRC'), _('Client')]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -87,3 +87,11 @@ backup = {
|
||||
},
|
||||
'services': ['uwsgi']
|
||||
}
|
||||
|
||||
tags = [
|
||||
_('Calendar'),
|
||||
_('Contacts'),
|
||||
_('Synchronization'),
|
||||
_('CalDAV'),
|
||||
_('CardDAV')
|
||||
]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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')]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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')]
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -84,3 +84,5 @@ clients = [{
|
||||
}]
|
||||
|
||||
backup: dict = {}
|
||||
|
||||
tags = [_('File Sharing'), _('Local Network')]
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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')]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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')]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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')
|
||||
]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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')]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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')]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -54,3 +54,5 @@ backup = {
|
||||
},
|
||||
'services': ['syncthing@syncthing']
|
||||
}
|
||||
|
||||
tags = [_('Synchronization'), _('File Sharing'), _('Cloud Storage')]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -14,3 +14,14 @@ clients = [{
|
||||
}]
|
||||
|
||||
backup = {'data': {'directories': [str(wiki_dir)]}}
|
||||
|
||||
tags = [
|
||||
_('Wiki'),
|
||||
_('Note Taking'),
|
||||
_('Website'),
|
||||
_('Journal'),
|
||||
_('Digital Garden'),
|
||||
_('Zettelkasten'),
|
||||
_('Quine'),
|
||||
_('non-Debian')
|
||||
]
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -52,3 +52,10 @@ backup = {
|
||||
},
|
||||
'services': ['tor@plinth']
|
||||
}
|
||||
|
||||
tags = [
|
||||
_('Relay'),
|
||||
_('Anonymity Network'),
|
||||
_('Censorship Resistance'),
|
||||
_('Tor')
|
||||
]
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -50,3 +50,11 @@ backup = {
|
||||
},
|
||||
'services': ['tor@fbxproxy']
|
||||
}
|
||||
|
||||
tags = [
|
||||
_('Proxy'),
|
||||
_('SOCKS5'),
|
||||
_('Anonymity Network'),
|
||||
_('Censorship Resistance'),
|
||||
_('Tor')
|
||||
]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -35,3 +35,5 @@ backup = {
|
||||
},
|
||||
'services': ['transmission-daemon']
|
||||
}
|
||||
|
||||
tags = [_('File Sharing'), _('BitTorrent'), _('Client'), _('P2P')]
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -51,3 +51,5 @@ backup = {
|
||||
},
|
||||
'services': ['tt-rss']
|
||||
}
|
||||
|
||||
tags = [_('Feed Reader'), _('News'), _('RSS'), _('ATOM')]
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -41,3 +41,5 @@ clients = [{
|
||||
'url': 'https://apps.apple.com/us/app/wireguard/id1441195209'
|
||||
}]
|
||||
}]
|
||||
|
||||
tags = [_('VPN'), _('Anonymity'), _('Remote Access'), _('P2P')]
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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')]
|
||||
|
||||
@ -22,3 +22,5 @@ backup = {
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
tags = [_('Image Viewer'), _('Photo'), _('Library')]
|
||||
|
||||
@ -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 %}">
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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" %}
|
||||
|
||||
0
plinth/tests/tags/__init__.py
Normal file
0
plinth/tests/tags/__init__.py
Normal file
60
plinth/tests/tags/test_functional.py
Normal file
60
plinth/tests/tags/test_functional.py
Normal 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)
|
||||
@ -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
170
static/tags.js
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user