diff --git a/.github/scripts/tests/test_oci_rclone_hedgedoc_wording.py b/.github/scripts/tests/test_oci_rclone_hedgedoc_wording.py new file mode 100644 index 00000000..a6d2c7f8 --- /dev/null +++ b/.github/scripts/tests/test_oci_rclone_hedgedoc_wording.py @@ -0,0 +1,235 @@ +"""Inert, extracted consumers for Rclone guidance and HedgeDoc metadata.""" +import ast +import json +import re +import subprocess +import sys +import tempfile +from pathlib import Path +from types import SimpleNamespace +from unittest import TestCase +from unittest.mock import Mock + +ROOT = Path(__file__).resolve().parents[3] +INSTALLER = ROOT / 'oci/src/proxmenux_oci/installer.py' +RCLONE = ('Configure the remote in the Rclone Web UI first. Checking its name may start a ' + 'stopped CT, even in a dry run. Applying the mount restarts the CT and attempts ' + 'to publish two FUSE views on the host; a dry run does not publish them.') + + +def extract_function(path, name, **dependencies): + node = next(n for n in ast.parse(path.read_text()).body + if isinstance(n, ast.FunctionDef) and n.name == name) + scope = dict(dependencies) + future = ast.ImportFrom(module='__future__', names=[ast.alias(name='annotations')], level=0) + exec(compile(ast.fix_missing_locations(ast.Module(body=[future, node], type_ignores=[])), + str(path), 'exec'), scope) + return scope[name] + + +class RcloneHedgeDocWording(TestCase): + def test_i18n_workflow_runs_for_remote_shell_edits(self): + workflow = (ROOT / '.github/workflows/test-i18n.yml').read_text() + self.assertEqual(workflow.count(" - 'oci/remote/**/*.sh'"), 2) + + def test_remote_dry_run_messages_are_extracted_and_translate_with_fallback(self): + import runpy + extractor = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py')) + shell = ROOT / 'oci/remote/configure_rclone_mount.sh' + keys = [ + 'Dry run completed; mount configuration was not applied. The CT was already running.', + 'Dry run completed; mount configuration was not applied. The CT was started for the remote check.', + ] + extracted = extractor['extract_translate_texts'](ROOT / 'oci/remote') + self.assertTrue(set(keys).issubset(extracted)) + ui = (ROOT / 'oci/remote/oci_ui.sh').read_text() + lookup = 'translate() {' + ui.split('translate() {', 1)[1].split('\n}', 1)[0] + '\n}\n' + gate = shell.read_text().split('if [[ $DRY_RUN == 1 ]]; then', 1)[1].split('\nfi\n\nRC_USER=', 1)[0] + gate = 'if [[ $DRY_RUN == 1 ]]; then' + gate + '\nfi\n' + with tempfile.TemporaryDirectory() as directory: + cache = Path(directory) / 'locale.json' + for status, expected_key in [('running', keys[0]), ('stopped', keys[1])]: + for translations, expected in [({}, expected_key), + ({expected_key: 'MESSAGGIO DI TEST'}, 'MESSAGGIO DI TEST')]: + with self.subTest(status=status, translations=translations): + cache.write_text(json.dumps(translations)) + setup = (f'_OCI_LANGUAGE=it\n_OCI_LANG_FILE={cache}\n' + f'STATUS={status}\nDRY_RUN=1\n' + lookup + + 'msg_ok() { printf "%s\\n" "$1"; }\n') + result = subprocess.run(['bash', '-c', setup + gate], + text=True, capture_output=True, timeout=5) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), expected) + + def test_remote_dry_run_reports_stopped_ct_start_and_no_mount_publication(self): + script = (ROOT / 'oci/remote/configure_rclone_mount.sh').read_text() + seam = script.split('msg_info "$(translate "Checking the remote...")"', 1)[1].split('\nfi\n\nRC_USER=', 1)[0] + # This is the real status/listremotes/dry-run gate, not the root-guarded script. + seam = 'msg_info "$(translate "Checking the remote...")"' + seam + '\nfi\n' + for status, dry_run, starts, post_gate in ( + ('stopped', '1', 1, False), ('running', '1', 0, False), + ('stopped', '0', 1, True), ('running', '0', 0, True)): + with self.subTest(status=status, dry_run=dry_run): + with tempfile.TemporaryDirectory() as directory: + trace = Path(directory) / 'pct.log' + harness = ''' +set -Eeuo pipefail +VMID=101 REMOTE_NAME=notes DRY_RUN=%s +TRACE_FILE=%s +pct() { + printf 'pct:%%s\\n' "$*" >>"$TRACE_FILE" + case "$1" in + status) printf 'status: %s\\n' ;; + exec) if [[ "${5:-}" == version ]]; then return 0; else printf 'notes:\\n'; fi ;; + start) return 0 ;; + *) return 97 ;; + esac +} +oci_quiet() { "$@"; } +oci_log() { :; } +sleep() { :; } +translate() { printf '%%s' "$1"; } +msg_info() { printf 'info:%%s\\n' "$1"; } +msg_ok() { printf 'ok:%%s\\n' "$1"; } +die() { printf 'error:%%s\\n' "$1"; exit 1; } +''' % (dry_run, str(trace), status) + result = subprocess.run(['bash', '-c', harness + seam + '\nprintf "POST_DRY_GATE\\n"\n'], + text=True, capture_output=True, timeout=5) + calls = trace.read_text() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(calls.count('pct:start 101'), starts) + self.assertIn('pct:exec 101 -- /usr/local/bin/rclone listremotes', calls) + self.assertEqual('POST_DRY_GATE' in result.stdout, post_gate) + if dry_run == '1': + self.assertIn('mount configuration was not applied', result.stdout) + if starts: + self.assertIn('CT was started for the remote check', result.stdout) + else: + self.assertIn('CT was already running', result.stdout) + self.assertNotIn('pct:stop', calls) + + def test_hedgedoc_overlay_survives_index_enrichment(self): + sys.path.insert(0, str(ROOT / 'oci/src')) + try: + from proxmenux_oci.catalog import Catalog + finally: + sys.path.pop(0) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + catalog = Catalog(root, source=Mock()) + catalog.apps_dir.mkdir(parents=True) + catalog.overlays_dir.mkdir(parents=True) + template = json.loads((ROOT / 'oci/catalog/apps/hedgedoc.json').read_text()) + upstream = 'HedgeDoc gives you access to all your files wherever you are.' + corrected = 'HedgeDoc is a self-hosted collaborative Markdown editor for notes.' + template['catalog_ui']['description']['en_US'] = upstream + template['catalog_ui']['tagline']['en_US'] = upstream + overlay = json.loads((ROOT / 'oci/catalog/overlays/hedgedoc.json').read_text()) + self.assertTrue(overlay['catalog_ui']['hidden']) + self.assertEqual(overlay['catalog_ui']['description']['en_US'], corrected) + self.assertEqual(overlay['catalog_ui']['tagline']['en_US'], corrected) + (catalog.overlays_dir / 'hedgedoc.json').write_text(json.dumps(overlay)) + catalog._apply_overlay('hedgedoc', template) + self.assertEqual(template['catalog_ui']['description']['en_US'], corrected) + self.assertEqual(template['catalog_ui']['tagline']['en_US'], corrected) + (catalog.apps_dir / 'hedgedoc.json').write_text(json.dumps(template)) + payload = {'applications': [{'id': 'hedgedoc', 'description': upstream}, + {'id': 'unmodified', 'description': 'Original summary'}]} + catalog._enrich_index_from_templates(payload) + self.assertEqual(payload['applications'][0]['description'], corrected) + self.assertEqual(payload['applications'][1]['description'], 'Original summary') + + def test_rclone_guidance_precedes_inputs_and_confirmation(self): + events = [] + class UI: + def message(self, text): events.append(('message', text)) + def ask(self, label, default=None, required=True): + events.append(('ask', label)) + return {'VMID of the Rclone OCI container': '101', + 'Exact name of the remote': 'notes', + 'Mount name': 'notes'}.get(label, default or '') + def choose(self, label, options, default): + events.append(('choose', label)) + return default + def confirm(self, label, default): + events.append(('confirm', label)) + return True + schema = {name: {'default': value} for name, value in ( + ('vfs_cache_mode', 'writes'), ('shared_mount_root_parent', '/mnt/rclone'), + ('shared_mount_root', '/mnt/rclone/rw'), + ('shared_mount_read_only_root', '/mnt/rclone/ro'))} + schema['vfs_cache_mode']['options'] = ['writes'] + template = {'proxmox': {'laboratory_contract': {'configuration_schema': schema}}} + fn = extract_function(INSTALLER, 'build_rclone_mount_deployment', + TerminalUI=UI, DialogUI=UI, translate=lambda text: text, + re=re, InstallError=ValueError, UserCancelled=ValueError) + deployment = fn(template, UI()) + self.assertEqual(events[0], ('message', RCLONE)) + self.assertEqual(events[1][0], 'ask') + self.assertEqual(events[-1][0], 'confirm') + self.assertEqual(deployment['remote_name'], 'notes') + + def test_hedgedoc_detail_uses_corrected_template_and_index(self): + key = 'HedgeDoc is a self-hosted collaborative Markdown editor for notes.' + template = json.loads((ROOT / 'oci/catalog/apps/hedgedoc.json').read_text()) + item = next(item for item in json.loads((ROOT / 'oci/catalog/index.json').read_text())['applications'] + if item['id'] == 'hedgedoc') + self.assertEqual(template['catalog_ui']['tagline']['en_US'], key) + self.assertEqual(template['catalog_ui']['description']['en_US'], key) + self.assertEqual(item['description'], key) + source = extract_function(ROOT / 'oci/src/proxmenux_oci/i18n.py', 'source_text', + Any=object) + fn = extract_function(ROOT / 'oci/src/proxmenux_oci/cli.py', '_app_detail_text', + source_text=source, translate=lambda text: text, + textwrap=__import__('textwrap'), DETAIL_WIDTH=92, + publisher=lambda _: 'LinuxServer.io', + is_tested=lambda _: False, + _display_architectures=lambda _: 'amd64', + _image_label=lambda text: text, + _recommended_storage=lambda _: 'Container volume') + result = fn(SimpleNamespace(), item, template) + self.assertIn(key, result) + self.assertNotIn('gives you access to all your files', result) + + def test_missing_empty_and_synthetic_translation_on_real_consumers(self): + from unittest.mock import Mock + key = 'HedgeDoc is a self-hosted collaborative Markdown editor for notes.' + scope = {'language': lambda: 'it', '_cache': {}} + lookup = extract_function(ROOT / 'oci/src/proxmenux_oci/i18n.py', 'translate', **scope) + cache = lookup.__globals__ + template = json.loads((ROOT / 'oci/catalog/apps/hedgedoc.json').read_text()) + item = next(x for x in json.loads((ROOT / 'oci/catalog/index.json').read_text())['applications'] + if x['id'] == 'hedgedoc') + source = extract_function(ROOT / 'oci/src/proxmenux_oci/i18n.py', 'source_text') + detail = extract_function(ROOT / 'oci/src/proxmenux_oci/cli.py', '_app_detail_text', + source_text=source, translate=lookup, + textwrap=__import__('textwrap'), DETAIL_WIDTH=92, + publisher=lambda _: 'LinuxServer.io', is_tested=lambda _: False, + _display_architectures=lambda _: 'amd64', + _image_label=lambda text: text, + _recommended_storage=lambda _: 'Container volume') + for values, expected_rclone, expected_hedgedoc in ( + ({}, RCLONE, key), + ({RCLONE: '', key: ''}, RCLONE, key), + ({RCLONE: 'GUIDA RCLONE', key: 'EDITOR COLLABORATIVO'}, + 'GUIDA RCLONE', 'EDITOR COLLABORATIVO')): + with self.subTest(values=values): + cache['_cache'] = values + ui = SimpleNamespace(message=Mock(), ask=lambda *_args, **_kw: (_ for _ in ()).throw(StopIteration())) + rclone = extract_function(INSTALLER, 'build_rclone_mount_deployment', + TerminalUI=object, DialogUI=object, translate=lookup, + re=re, InstallError=ValueError, + UserCancelled=ValueError) + # Stop at the first prompt: no operational path is entered. + with self.assertRaises(StopIteration): + rclone(json.loads((ROOT / 'oci/catalog/apps/rclone.json').read_text()), ui) + ui.message.assert_called_once_with(expected_rclone) + self.assertIn(expected_hedgedoc, detail(SimpleNamespace(), item, template)) + + def test_extractor_sees_both_new_sources(self): + import runpy + extractor = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py')) + keys = extractor['extract_python_texts']([ROOT / 'oci/src']) + self.assertIn(RCLONE, keys) + keys = extractor['extract_catalog_texts']([ROOT / 'oci/catalog']) + self.assertIn('HedgeDoc is a self-hosted collaborative Markdown editor for notes.', keys) diff --git a/.github/workflows/test-i18n.yml b/.github/workflows/test-i18n.yml index 7a231cae..271a16ef 100644 --- a/.github/workflows/test-i18n.yml +++ b/.github/workflows/test-i18n.yml @@ -14,6 +14,9 @@ on: - 'scripts/**/*.sh' - 'scripts/**/*.func' - 'lang/*.json' + - 'oci/src/**/*.py' + - 'oci/catalog/**/*.json' + - 'oci/remote/**/*.sh' push: branches: [main, develop] paths: @@ -28,6 +31,9 @@ on: - 'scripts/**/*.sh' - 'scripts/**/*.func' - 'lang/*.json' + - 'oci/src/**/*.py' + - 'oci/catalog/**/*.json' + - 'oci/remote/**/*.sh' workflow_dispatch: permissions: diff --git a/oci/catalog/apps/hedgedoc.json b/oci/catalog/apps/hedgedoc.json index cdd9280b..2030ad35 100644 --- a/oci/catalog/apps/hedgedoc.json +++ b/oci/catalog/apps/hedgedoc.json @@ -8,10 +8,10 @@ "en_US": "Hedgedoc" }, "tagline": { - "en_US": "HedgeDoc gives you access to all your files wherever you are." + "en_US": "HedgeDoc is a self-hosted collaborative Markdown editor for notes." }, "description": { - "en_US": "HedgeDoc gives you access to all your files wherever you are." + "en_US": "HedgeDoc is a self-hosted collaborative Markdown editor for notes." }, "category": "documents", "category_label": "Documents & Notes", diff --git a/oci/catalog/index.json b/oci/catalog/index.json index d4e881f9..28f1d0c6 100644 --- a/oci/catalog/index.json +++ b/oci/catalog/index.json @@ -3995,7 +3995,7 @@ "title": "Hedgedoc", "repository_name": "docker-hedgedoc", "repository": "https://github.com/linuxserver/docker-hedgedoc", - "description": "HedgeDoc gives you access to all your files wherever you are.", + "description": "HedgeDoc is a self-hosted collaborative Markdown editor for notes.", "website": "https://hedgedoc.org/", "icon": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/hedgedoc.webp", "architectures": [ diff --git a/oci/catalog/overlays/hedgedoc.json b/oci/catalog/overlays/hedgedoc.json index 6ccee440..0b17a617 100644 --- a/oci/catalog/overlays/hedgedoc.json +++ b/oci/catalog/overlays/hedgedoc.json @@ -1,6 +1,12 @@ { "catalog_ui": { "hidden": true, - "hidden_reason": "Needs MariaDB or PostgreSQL in its own container; planned as a multi-container application" + "hidden_reason": "Needs MariaDB or PostgreSQL in its own container; planned as a multi-container application", + "tagline": { + "en_US": "HedgeDoc is a self-hosted collaborative Markdown editor for notes." + }, + "description": { + "en_US": "HedgeDoc is a self-hosted collaborative Markdown editor for notes." + } } } diff --git a/oci/remote/configure_rclone_mount.sh b/oci/remote/configure_rclone_mount.sh index 013b9d32..fe304d35 100755 --- a/oci/remote/configure_rclone_mount.sh +++ b/oci/remote/configure_rclone_mount.sh @@ -98,7 +98,11 @@ grep -Fxq "${REMOTE_NAME}:" <<<"$REMOTES" \ msg_ok "$(translate "Remote verified:") ${REMOTE_NAME}:" if [[ $DRY_RUN == 1 ]]; then - msg_ok "$(translate "Dry run completed; the container and the mounts were not changed.")" + if [[ $STATUS == running ]]; then + msg_ok "$(translate "Dry run completed; mount configuration was not applied. The CT was already running.")" + else + msg_ok "$(translate "Dry run completed; mount configuration was not applied. The CT was started for the remote check.")" + fi exit 0 fi diff --git a/oci/src/proxmenux_oci/catalog.py b/oci/src/proxmenux_oci/catalog.py index 0225679a..313f7d58 100644 --- a/oci/src/proxmenux_oci/catalog.py +++ b/oci/src/proxmenux_oci/catalog.py @@ -402,6 +402,10 @@ class Catalog: overlay_path = self.overlays_dir / f"{item['id']}.json" overlay_ui = json.loads(overlay_path.read_text(encoding="utf-8")).get("catalog_ui", {}) if overlay_path.exists() else {} item["hidden"] = overlay_ui.get("hidden", False) + # The index is rebuilt from upstream README summaries; preserve a + # locally curated English description from the existing overlay. + item["description"] = overlay_ui.get("description", {}).get( + "en_US", item.get("description")) path = self.apps_dir / f"{item['id']}.json" if not path.exists(): item["architectures"] = supported_architectures( diff --git a/oci/src/proxmenux_oci/installer.py b/oci/src/proxmenux_oci/installer.py index a463691b..9dcfd8bc 100644 --- a/oci/src/proxmenux_oci/installer.py +++ b/oci/src/proxmenux_oci/installer.py @@ -621,8 +621,9 @@ def build_rclone_mount_deployment( ) -> dict[str, Any]: ui = ui or TerminalUI() schema = template["proxmox"]["laboratory_contract"]["configuration_schema"] - ui.message(translate("The remote must already be created and authorized in the Rclone web UI. This " - "operation restarts the CT and publishes two FUSE views on the host.")) + ui.message(translate("Configure the remote in the Rclone Web UI first. Checking its name may start a " + "stopped CT, even in a dry run. Applying the mount restarts the CT and attempts " + "to publish two FUSE views on the host; a dry run does not publish them.")) vmid = int(ui.ask(translate("VMID of the Rclone OCI container"))) remote_name = ui.ask(translate("Exact name of the remote")) if not re.fullmatch(r"[A-Za-z0-9._-]{1,64}", remote_name):