mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
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.
309 lines
13 KiB
Python
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()
|