Files
ProxMenux/scripts/menus/search_menu.py
T
MacRimi 0fd3be15c1 feat(menu): search options and applications from the main menu
The main menu opens with a search that finds an option of ProxMenux, an application of the OCI catalog or a Helper-Script by its name or by related words, in that order, and by category after the names.

Every result opens what its name says and nothing is run by the search itself: a script starts at its first screen, an application opens its page, an option of the post-install or a system utility opens its list with it marked, and an option that lives inside a menu is opened by that menu, which stays behind it.

The menus take the option to open as an argument and behave as before without one. The Helper-Scripts catalog is kept on the host and refreshed once a day. The main menu, the lists of OCI manager and the Helper-Scripts menu share the same height.
2026-10-11 13:26:23 +02:00

309 lines
13 KiB
Python

#!/usr/bin/env python3
# ==========================================================
# ProxMenux - Menu search
# ==========================================================
# Author : MacRimi
# Copyright : (c) 2024 MacRimi
# License : GPL-3.0
# https://github.com/MacRimi/ProxMenux/blob/main/LICENSE
# ==========================================================
# Description:
# Finds an option by name or by related words across the ProxMenux menu,
# the OCI catalog and the Helper-Scripts, and opens it where it lives.
# ==========================================================
"""Search across the ProxMenux menu, the OCI catalog and the Helper-Scripts."""
import argparse
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import time
import unicodedata
import urllib.request
BASE = Path('/usr/local/share/proxmenux')
SCRIPTS = BASE / 'scripts'
INDEX = Path(__file__).resolve().with_name('search_index.json')
OCI_INDEX = BASE / 'oci/engine/catalog/index.json'
HELPERS_URL = 'https://raw.githubusercontent.com/MacRimi/ProxMenux/refs/heads/main/json/helpers_cache.json'
HELPERS_CACHE = BASE / 'helpers_cache.json'
HELPERS_MAX_AGE = 24 * 3600
# Results come in this order: the menu itself, then the two catalogs.
SOURCES = ('ProxMenux', 'OCI Manager', 'Helper-Scripts')
# Words an OCI application is also found by, when its name does not say them.
OCI_KEYWORDS = {'sharecovex': 'samba smb cifs nfs share compartir archivos time machine'}
_translations = None
def translate(text):
"""The text in the language of the menu; the text itself when it has none."""
global _translations
if _translations is None:
_translations = {}
try:
language = json.loads((BASE / 'config.json').read_text()).get('language', 'en')
if isinstance(language, str) and re.fullmatch(r'[a-z]{2}', language) and language != 'en':
loaded = json.loads((BASE / 'lang' / f'{language}.json').read_text(encoding='utf-8'))
if isinstance(loaded, dict):
_translations = loaded
except (OSError, ValueError):
pass
value = _translations.get(text)
return value if isinstance(value, str) and value else text
def tidy(text):
"""A menu label on one line: the menus pad theirs to line them up."""
return re.sub(r'\s+', ' ', text).strip()
def fold(value):
return ''.join(c for c in unicodedata.normalize('NFKD', value.casefold()) if not unicodedata.combining(c))
def returns_to_a_menu(script):
"""Whether a script ends by opening a menu itself, as the sub-menus do."""
try:
return bool(re.search(r'exec bash "\$(LOCAL_SCRIPTS|MENU_REPO)/', script.read_text(errors='ignore')))
except OSError:
return False
def own_entries():
try:
index = json.loads(INDEX.read_text(encoding='utf-8'))
except (OSError, ValueError):
return []
entries = []
for item in index:
script = SCRIPTS / item['script']
if not script.is_file():
continue
title = tidy(translate(item['title']))
# An option that lives inside a menu is given to that menu, which
# opens it first.
option = [item['argument']] if item.get('argument') else []
entries.append(dict(title=title, source='ProxMenux', kind=tidy(translate(item['menu'])),
terms=f"{title} {tidy(item['title'])} {item.get('keywords', '')}", related='',
action=['bash', str(script), *option], handoff=returns_to_a_menu(script)))
return entries
def utility_entries():
source = SCRIPTS / 'global/utils-install-functions.sh'
installer = SCRIPTS / 'utilities/system_utils.sh'
try:
block = re.search(r'^PROXMENUX_UTILS=\(\s*\n(.*?)^\)', source.read_text(), re.M | re.S)
except OSError:
return []
if not block or not installer.is_file():
return []
kind = tidy(translate('System Utilities Installer'))
return [dict(title=package, source='ProxMenux', kind=kind, terms=f'{package} {command}',
related=f'{description} {translate(description)}',
action=['bash', str(installer), package], handoff=False)
for package, command, description in
re.findall(r'^\s*"([^":]+):([^":]+):([^"\n]+)"', block.group(1), re.M)]
def post_install_entries():
script = SCRIPTS / 'post_install/customizable_post_install.sh'
try:
block = re.search(r'^\s*local options=\(\s*\n(.*?)^\s*\)', script.read_text(), re.M | re.S)
except OSError:
return []
if not block:
return []
kind = tidy(translate('Customizable post-installation script'))
return [dict(title=tidy(translate(title)), source='ProxMenux', kind=kind,
terms=f'{translate(title)} {title} {token}', related=f'{category} post postinstall',
action=['bash', str(script), token], handoff=False)
for category, title, token in
re.findall(r'^\s*"([^"|]+)\|([^"|]+)\|([A-Za-z0-9_]+)"', block.group(1), re.M)]
def oci_entries():
launcher = SCRIPTS / 'oci/oci_manager_apps.sh'
try:
applications = json.loads(OCI_INDEX.read_text(encoding='utf-8')).get('applications', [])
except (OSError, ValueError):
return []
if not launcher.is_file():
return []
kind = translate('OCI application')
return [dict(title=item.get('title') or item['id'], source='OCI Manager', kind=kind,
terms=f"{item.get('title') or item['id']} {item['id']} {OCI_KEYWORDS.get(item['id'], '')}",
related=str(item.get('category_label') or ''),
action=['bash', str(launcher), 'open', item['id']], handoff=False)
for item in applications
if not item.get('hidden') and item.get('automatic_install_candidate')]
def helpers_catalog(refresh=True):
"""The Helper-Scripts catalog. It is kept on the host and downloaded again
once a day; without a connection the one already here is used."""
try:
fresh = time.time() - HELPERS_CACHE.stat().st_mtime < HELPERS_MAX_AGE
except OSError:
fresh = False
if refresh and not fresh:
try:
with urllib.request.urlopen(HELPERS_URL, timeout=15) as response:
data = response.read()
if isinstance(json.loads(data), list):
temporary = HELPERS_CACHE.with_name(HELPERS_CACHE.name + '.new')
temporary.write_bytes(data)
os.replace(temporary, HELPERS_CACHE)
except (OSError, ValueError):
pass
try:
loaded = json.loads(HELPERS_CACHE.read_text(encoding='utf-8'))
except (OSError, ValueError):
return []
return loaded if isinstance(loaded, list) else []
def helper_entries(refresh=True):
launcher = SCRIPTS / 'menus/menu_Helper_Scripts.sh'
if not launcher.is_file():
return []
entries, seen = [], set()
for item in helpers_catalog(refresh):
slug = item.get('slug')
if not slug or slug in seen or not item.get('name'):
continue
seen.add(slug)
entries.append(dict(title=item['name'], source='Helper-Scripts', kind=str(item.get('type', '')).upper(),
terms=f"{item['name']} {slug}",
related=' '.join(item.get('category_names', [])),
action=['bash', str(launcher), slug], handoff=False))
return entries
def catalog(refresh=True):
return own_entries() + utility_entries() + post_install_entries() + oci_entries() + helper_entries(refresh)
def search(items, query):
"""What matches every word of the query: by its name first, then by its
category. The long description of an application is not searched, as it
names other applications and would bring them all."""
query = fold(query.strip())
wanted = re.findall(r'[a-z0-9]+', query)
if not wanted:
return []
def has_all(text):
words = re.findall(r'[a-z0-9]+', text)
return all(any(word.startswith(term) for word in words) for term in wanted)
ranked = []
for item in items:
name = fold(item['title'])
terms = fold(item['terms'])
if has_all(terms):
depth = 0
elif has_all(f"{terms} {fold(item['related'])}"):
depth = 1
else:
continue
score = 0 if name == query else 1 if name.startswith(query) else 2 if query in name else 3
source = SOURCES.index(item['source']) if item['source'] in SOURCES else len(SOURCES)
ranked.append((depth, source, score, name, item))
return [row[4] for row in sorted(ranked, key=lambda row: row[:4])]
def result_rows(matches, width):
"""One line per result in three columns. Only what is shown is cut."""
labels = (translate('Name'), translate('Source'), translate('Type'))
source_width = max(len(labels[1]), max(len(item['source']) for item in matches))
kind_width = min(29, max(len(labels[2]), max(len(item['kind']) for item in matches)))
name_width = max(12, width - source_width - kind_width - 6)
def cell(value, size):
value = re.sub(r'[\x00-\x1f\x7f]', ' ', value)
return (value if len(value) <= size else value[:size - 1] + '…').ljust(size)
def line(name, source, kind):
return f'{cell(name, name_width)} {cell(source, source_width)} {cell(kind, kind_width)}'
return line(*labels), [(str(number), line(item['title'], item['source'], item['kind']))
for number, item in enumerate(matches)]
def dialog(kind, title, text, rows=(), initial='', width=100, height=23):
terminal = shutil.get_terminal_size((100, 30))
width = max(40, min(width, terminal.columns))
height = max(8, min(height, terminal.lines - 2))
# The heading of the results is lined up with spaces, which are kept.
args = ['dialog', '--clear', '--no-collapse', '--backtitle', 'ProxMenux', '--title', title]
if kind == 'menu':
args += ['--menu', text, str(height), str(width), str(max(3, min(len(rows), height - 6)))]
for tag, label in rows:
args += [tag, label]
elif kind == 'inputbox':
args += ['--inputbox', text, '10', str(min(width, 90)), initial]
elif kind == 'infobox':
args += ['--infobox', text, '5', str(min(width, 60))]
else:
args += ['--msgbox', text, '8', str(min(width, 70))]
with open('/dev/tty', 'w') as tty:
result = subprocess.run(args + ['--stdout'], stdout=subprocess.PIPE, stderr=tty, text=True, check=False)
return result.stdout.strip() if result.returncode == 0 else None
def show_results(matches, query, handoff):
"""The results, until one that opens a menu is chosen or the list is left."""
while True:
columns = min(100, shutil.get_terminal_size((100, 24)).columns)
heading, rows = result_rows(matches, max(60, columns - 16))
tag_width = max(len(tag) for tag, _ in rows)
row_width = max(len(label) for _, label in rows)
# The list is centred in its window; the heading starts where its rows do.
heading = ' ' * (max(0, (columns - 8 - row_width - tag_width - 2) // 2) + tag_width + 4) + heading
selected = dialog('menu', f"{translate('Results:')} {query} ({len(matches)})", heading, rows)
if selected is None:
return False
item = matches[int(selected)]
if item['handoff'] and handoff:
# A sub-menu goes back to the main menu on its own: the main menu opens it.
handoff.write_text('\n'.join(item['action'][1:]) + '\n')
return True
subprocess.run(item['action'], check=False)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--search', metavar='QUERY', help='print the matches as JSON and exit')
parser.add_argument('--handoff', type=Path, help='file that receives the sub-menu the main menu has to open')
args = parser.parse_args()
if args.search is not None:
print(json.dumps(search(catalog(), args.search), ensure_ascii=False, indent=2))
return
try:
outdated = time.time() - HELPERS_CACHE.stat().st_mtime >= HELPERS_MAX_AGE
except OSError:
outdated = True
if outdated:
dialog('infobox', translate('Search'), '\n' + translate('Loading the Helper-Scripts catalog...'))
items = catalog()
query = ''
while True:
query = dialog('inputbox', translate('Search'),
translate('Name or related words: zima, plex, post…'), initial=query)
if query is None:
return
matches = search(items, query)
if not matches:
dialog('msgbox', translate('No results'), f"\n{translate('No matches for:')} {query}")
continue
if show_results(matches, query, args.handoff):
return
if __name__ == '__main__':
main()