i18n: complete OCI catalogue and wizard Slovak wording

This commit is contained in:
VAIO73
2026-10-21 07:06:04 +00:00
parent 092a62e2dd
commit 37a6c02111
6 changed files with 655 additions and 477 deletions
@@ -0,0 +1,148 @@
"""Real catalogue/wizard rendering, with host and installation effects forbidden."""
import copy
from contextlib import ExitStack, redirect_stdout
import io
import json
import os
from pathlib import Path
import subprocess
import sys
import unittest
from unittest.mock import Mock, patch
ROOT = Path(os.environ.get('OCI_I18N_TEST_ROOT', Path(__file__).resolve().parents[3]))
sys.path[:0] = [str(ROOT / 'oci/src'), str(ROOT / 'oci/remote'), str(ROOT / 'oci/tests')]
from proxmenux_oci import cli, i18n, installer, stack
from proxmenux_oci.catalog import Catalog
from proxmenux_oci.converter import ConversionError
from proxmenux_oci.ui import UserCancelled
from test_advanced_flow_order import RecordingUI, addresses, storages
class CompleteUI(unittest.TestCase):
def setUp(self):
self.scope = ExitStack()
self.addCleanup(self.scope.close)
self.sk = json.loads((ROOT / 'lang/sk.json').read_text())
self.catalog = Catalog(ROOT / 'oci')
self.patch(i18n, '_language', 'sk')
self.patch(i18n, '_cache', self.sk)
self.patch(installer.host, 'storages', side_effect=storages)
self.patch(installer.host, 'bridges', return_value=[{'iface': 'vmbr0', 'cidr': '192.0.2.10/24'}])
self.patch(installer.host, 'timezone', return_value='Europe/Bratislava')
self.patch(installer.host, 'gpus', return_value={'intel': [], 'amd': [], 'nvidia': False})
self.patch(installer.access, 'ask_addresses', side_effect=addresses)
self.patch(installer.access, 'ask_ipv4', return_value=('dhcp', None))
self.effects = []
for module, name in [(cli, '_install'), (cli, '_rclone_mount'), (cli, 'run_remote_install'),
(installer, 'run_remote_install'), (cli.images, 'offer_removal'),
(subprocess, 'run'), (subprocess, 'Popen'), (subprocess, 'check_output'),
(Path, 'write_text'), (Path, 'write_bytes'), (Path, 'unlink')]:
self.effects.append(self.patch(module, name, side_effect=AssertionError('Forbidden effect: ' + name)))
self.addCleanup(lambda: [effect.assert_not_called() for effect in self.effects])
def patch(self, obj, name, *args, **kwargs):
return self.scope.enter_context(patch.object(obj, name, *args, **kwargs))
def test_search_no_match_uses_application_wording_and_never_installs(self):
ui = Mock()
ui.ask.return_value = 'fiction-no-such-app'
cli._search(self.catalog, ui, [])
self.assertEqual(ui.ask.call_args.args[0], 'Názov alebo časť popisu aplikácie')
self.assertIn('Žiadna zhoda aplikácií', ui.message.call_args.args[0])
ui.choose.assert_not_called()
def test_immich_detail_translates_service_roles_without_changing_template(self):
template = self.catalog.compose('immich')
before = copy.deepcopy(template)
text = cli._app_detail_text(self.catalog, self.catalog.find_item('immich'), template)
self.assertIn('Strojové učenie', text)
self.assertIn('Aplikácia', text)
self.assertIn('Oficiálny obraz', text)
self.assertNotIn('Machine learning', text)
self.assertEqual(template, before)
with patch.object(i18n, '_language', 'en'):
self.assertIn('Machine learning', cli._app_detail_text(self.catalog, self.catalog.find_item('immich'), template))
def test_missing_catalogue_entry_preserves_raw_error_and_localizes_cli(self):
for language, expected in [('sk', "Aplikácia 'fiction-missing' nie je v indexe"),
('en', "The application 'fiction-missing' is not in the index")]:
with self.subTest(language=language), patch.object(i18n, '_language', language):
with self.assertRaisesRegex(ConversionError, "The application 'fiction-missing' is not in the index"):
self.catalog.find_item('fiction-missing')
output = io.StringIO()
with redirect_stdout(io.StringIO()), patch.object(sys, 'stderr', output), patch.object(cli, 'PROJECT_ROOT', ROOT / 'oci'):
self.assertEqual(cli.main(['show', 'fiction-missing']), 1)
self.assertEqual(output.getvalue().strip(), 'ERROR: ' + expected)
def test_missing_template_uses_existing_translation_and_keeps_identifier(self):
self.patch(self.catalog, 'find_item', return_value={'id': 'fiction-missing'})
with self.assertRaises(ConversionError) as caught:
self.catalog.compose('fiction-missing')
self.assertEqual(str(caught.exception), "No template is available for 'fiction-missing'")
self.assertEqual(i18n.diagnostic(caught.exception), "Pre aplikáciu 'fiction-missing' nie je dostupná šablóna")
def test_detail_cancel_and_escape_do_not_enter_install_or_rclone(self):
for app in ('jellyfin', 'rclone', 'immich', 'suite-arr'):
for escape in (False, True):
with self.subTest(app=app, escape=escape):
ui = Mock()
ui.confirm.return_value = False
ui.detail_menu.return_value = None
if escape:
ui.detail_menu.side_effect = UserCancelled()
try:
cli._app_detail(self.catalog, ui, self.catalog.find_item(app))
except UserCancelled:
self.assertTrue(escape)
def test_special_wizard_plans_are_identical_in_english_and_slovak(self):
for app in ('immich', 'nextcloud-stack', 'paperless-ngx', 'tandoor'):
for mode in (installer.DEFAULT_MODE, installer.ADVANCED_MODE):
with self.subTest(app=app, mode=mode):
template = self.catalog.compose(app)
before = copy.deepcopy(template)
plan_sk = installer.build_deployment(template, RecordingUI(), mode)
with patch.object(i18n, '_language', 'en'):
plan_en = installer.build_deployment(template, RecordingUI(), mode)
self.assertEqual(plan_sk, plan_en)
self.assertEqual(template, before)
text = cli._deployment_summary_text(template, plan_sk)
self.assertNotIn('Consume/export:', text)
if app == 'paperless-ngx':
self.assertIn('Priečinky consume a export', text)
def test_romm_optional_credentials_are_localized_but_variable_names_stay(self):
ui = RecordingUI()
ui.confirm = lambda text, default=False, **kwargs: (ui.asked.append(text) or text.startswith('Nastaviť IGDB'))
ui.password = lambda text, **kwargs: (ui.asked.append(text) or 'fiction-secret')
ui.ask = lambda text, default=None, **kwargs: (ui.asked.append(text) or (default if default is not None else 'fiction-value'))
plan = installer.build_deployment(self.catalog.compose('romm'), ui, installer.ADVANCED_MODE)
self.assertTrue(any('Tajný kľúč klienta IGDB' == text for text in ui.asked))
self.assertFalse(any('IGDB Client Secret' == text for text in ui.asked))
server = next(service for service in plan['services'] if service['name'] == 'romm')
self.assertTrue(any(row['name'] == 'IGDB_CLIENT_SECRET' for row in server['deployment']['environment']))
def test_tandoor_warning_keeps_negative_requirement_and_first_run_account(self):
template = self.catalog.compose('tandoor')
warning = i18n.translate(template['proxmox']['security_profile']['warning'])
self.assertIn('nevyžaduje privilegovaný', warning)
credentials = template['first_run']['credentials']
self.assertTrue(any('Vygenerovaný účet správcu Tandoor' == i18n.translate(row['label']) for row in credentials))
def test_documented_commands_paths_and_literals_remain_copyable(self):
fragments = {
'rsnapshot starts with the default configuration, which backs up /data into /.snapshots. Edit /config/rsnapshot.conf inside the container to set your own backup points, snapshot root and retention intervals.': ['/data', '/.snapshots', '/config/rsnapshot.conf'],
'Each /request command needs a backend: add a [[backends]] block in the same file with the url and api_key of your Sonarr, Radarr or Seerr instance, then restart the container.': ['/request', '[[backends]]', 'url', 'api_key'],
'Named accounts need private mode. Stop the container, set public: false in /config/config.js, start it again and create each user with: pct exec <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>': ['public: false', 'pct exec <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>'],
'Certificate provider: empty for Let\'s Encrypt, zerossl for ZeroSSL': ["Let's Encrypt", 'zerossl', 'ZeroSSL'],
'Libraries: /data/media/movies, /data/media/series and /data/media/music. Select them in the media server.': ['/data/media/movies', '/data/media/series', '/data/media/music'],
}
for source, tokens in fragments.items():
with self.subTest(source=source):
for token in tokens:
self.assertIn(token, i18n.translate(source))
if __name__ == '__main__':
unittest.main()
+470 -467
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -15,7 +15,7 @@ from . import console, images
from .catalog import Catalog
from .converter import ConversionError
from .github_source import SourceError
from .i18n import N_, source_text, translate
from .i18n import N_, diagnostic, source_text, translate
from .installer import (
ADDRESS_SHOWN,
ADVANCED_MODE,
@@ -38,7 +38,7 @@ PUBLISHERS = {"linuxserver.io": "LinuxServer", "official": N_("Official image")}
STACK_LABELS = {
"server": N_("Server"),
"application": N_("Application"),
"machine_learning": "Machine learning",
"machine_learning": N_("Machine learning"),
"database": "PostgreSQL",
"valkey": "Valkey",
"cache": "Redis",
@@ -133,7 +133,7 @@ def _service_kind(service: dict[str, Any]) -> str:
if service.get("is_main"):
return translate("Application")
if "machine-learning" in name or "machine-learning" in image:
return "Machine learning"
return translate("Machine learning")
for key, label in SERVICE_KINDS:
if key in image:
return label
@@ -267,7 +267,7 @@ def _deployment_summary_text(template: dict[str, Any], deployment: dict[str, Any
target = (f"{mount['size_gb']} GB {on} {mount['source']}"
if mount["type"] == "managed-volume" else mount["source"])
lines.append(f" {mount['container_path']} → {target}")
for key, label in (("media", "Library"), ("application", "Application data"), ("transfer", "Consume/export")):
for key, label in (("media", N_("Library")), ("application", N_("Application data")), ("transfer", N_("Consume/export"))):
storage = plan.get(key)
if isinstance(storage, dict) and "mode" in storage:
if storage["mode"] == "host-bind":
@@ -614,7 +614,7 @@ def interactive(catalog: Catalog) -> int:
continue
except (ConversionError, InstallError, OSError, ValueError) as exc:
console.stop_spinner()
ui.message(f"{translate('The operation could not be completed')}:\n\n{exc}")
ui.message(f"{translate('The operation could not be completed')}:\n\n{diagnostic(exc)}")
# ---------------------------------------------------------------- maintenance CLI
@@ -773,7 +773,7 @@ def main(argv: list[str] | None = None) -> int:
return 130
except (ConversionError, SourceError, InstallError, OSError, ValueError) as exc:
console.stop_spinner()
print(f"ERROR: {exc}", file=sys.stderr)
print(f"ERROR: {diagnostic(exc)}", file=sys.stderr)
return 1
+18
View File
@@ -7,6 +7,7 @@ is the source language: a missing translation returns the English text.
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
@@ -46,6 +47,23 @@ def translate(text: str) -> str:
return _cache.get(text) or text
def diagnostic(error: Any) -> str:
"""Translate legacy diagnostics only when presenting them to a person.
The catalog's exception types and original messages remain stable for
internal callers; identifiers are inserted after the translation lookup.
"""
text = str(error)
for pattern, source in (
(r"No template is available for '(.*)'", N_("No template is available for '{app_id}'")),
(r"The application '(.*)' is not in the index", N_("The application '{app_id}' is not in the index")),
):
match = re.fullmatch(pattern, text)
if match:
return translate(source).format(app_id=match[1])
return text
def source_text(value: Any) -> str:
"""The English a catalog field was written in.
+3 -3
View File
@@ -365,13 +365,13 @@ def build_stack(template, ui, mode='advanced'):
service_name = group['service']
if service_name not in envs:
raise StackError(f"{translate('Unknown credential service:')} {service_name}")
if not quiet.confirm(f"{translate('Configure')} {group['label']} ({translate('optional')})", False):
if not quiet.confirm(f"{translate('Configure')} {translate(group['label'])} ({translate('optional')})", False):
continue
for field in group['fields']:
if field['name'] not in envs[service_name] or envs[service_name][field['name']] != '':
raise StackError(f"{translate('External field not reserved:')} {field['name']}")
value = (ui.password(field['label'], required=True) if field.get('sensitive',True)
else ui.ask(field['label'], required=True))
value = (ui.password(translate(field['label']), required=True) if field.get('sensitive',True)
else ui.ask(translate(field['label']), required=True))
if not value or any(c in value for c in '\r\n'):
raise StackError(translate('External credential is empty or spans multiple lines'))
envs[service_name][field['name']] = value
+10 -1
View File
@@ -3,6 +3,7 @@ transcoding and its recognition: the CPU, or each usable GPU of the host for
both or for only one of them."""
from pathlib import Path
import json
import sys
import unittest
from unittest.mock import patch
@@ -165,7 +166,15 @@ class ImmichAccelerationTests(unittest.TestCase):
def test_the_name_of_the_machine_learning_container_is_not_translated(self, *_):
script = (ROOT / "remote/install_immich_stack.sh").read_text()
self.assertNotIn('translate "Machine learning"', script)
self.assertNotIn('translate("Machine learning")', (ROOT / "src/proxmenux_oci/cli.py").read_text())
from proxmenux_oci.cli import _service_kind
from proxmenux_oci import i18n
service = {'name': 'immich-machine-learning', 'image': 'ghcr.io/immich-app/immich-machine-learning:release'}
original = dict(service)
with patch.object(i18n, 'language', return_value='sk'), patch.object(i18n, '_cache', json.loads((ROOT.parent / 'lang/sk.json').read_text())):
self.assertEqual(_service_kind(service), 'Strojové učenie')
_, _, plan = self.plan(NOTHING, DEFAULT_MODE)
self.assertIn('machine_learning', plan)
self.assertEqual(service, original)
if __name__ == "__main__":