mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
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 part of the option or of the application'), 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()
|