diff --git a/AppImage/scripts/notification_templates.py b/AppImage/scripts/notification_templates.py index af9f78b2..c7853d31 100644 --- a/AppImage/scripts/notification_templates.py +++ b/AppImage/scripts/notification_templates.py @@ -2111,6 +2111,7 @@ FIELD_EMOJI = { 'hostname': '\U0001F4BB', # laptop 'vmid': '\U0001F194', # ID button 'vmname': '\U0001F3F7\uFE0F', # label + 'ct_name': '\U0001F4E6', # package / container 'device': '\U0001F4BD', # disk 'mount': '\U0001F4C2', # open folder 'source_ip': '\U0001F310', # globe @@ -2141,6 +2142,82 @@ FIELD_EMOJI = { } +_TEMPLATE_FIELD_RE = re.compile(r'\{([a-zA-Z_][a-zA-Z0-9_]*)[^}]*\}') + + +def _localized_template_labels(event_type: str, language: str) -> Dict[str, List[str]]: + """Return the visible labels paired with template fields in one locale. + + The old emoji pass compared rendered English words such as ``Duration`` + and ``Total updates``. That necessarily stops matching after a template + is translated. The template itself still knows which field each label + describes, so derive the visible wording from that localized template. + """ + requested = (language or 'en').split('-', 1)[0].lower() + key = f'templates.{event_type}.body' + # Use the raw catalog entry rather than runtime_message(): the latter + # deliberately formats unknown placeholders away, while this helper needs + # to inspect those placeholders to associate each label with its field. + body = ( + _catalog_value(_load_runtime_catalog(requested), key) + or _catalog_value(_load_runtime_catalog('en'), key) + ) + if not body: + body = TEMPLATES.get(event_type, {}).get('body', '') + + labels: Dict[str, List[str]] = {} + lines = body.splitlines() + for index, line in enumerate(lines): + matches = list(_TEMPLATE_FIELD_RE.finditer(line)) + for match in matches: + label = line[:match.start()].strip().rstrip(':').strip() + if label: + labels.setdefault(match.group(1), []).append(label) + + # A heading on its own line (for example "Important packages:") + # labels the variable rendered on the following line. + if line.strip().endswith(':') and index + 1 < len(lines): + next_matches = list(_TEMPLATE_FIELD_RE.finditer(lines[index + 1])) + if len(next_matches) == 1: + label = line.strip().rstrip(':').strip() + if label: + labels.setdefault(next_matches[0].group(1), []).append(label) + return labels + + +def _lxc_update_label_icons(language: str) -> Dict[str, str]: + """Return localized LXC-update detail prefixes with stable icons.""" + values = { + 'before': '0', 'after': '0', 'items': 'item', 'targets': 'target', + 'reason': 'reason', 'value': 'value', 'error': 'error', 'duration': '0s', + } + definitions = ( + ('lxcUpdate.sourceLabel', '🧭'), + ('lxcUpdate.targets', '🎯'), + ('lxcUpdate.osPending', '📦'), + ('lxcUpdate.osUnverified', '📦'), + ('lxcUpdate.applications', '🧩'), + ('lxcUpdate.applicationsUnverified', '🧩'), + ('lxcUpdate.dockerEngineChange', '🐳'), + ('lxcUpdate.dockerEngineVerified', '🐳'), + ('lxcUpdate.dockerEngineUnverified', '🐳'), + ('lxcUpdate.dockerImagesPending', '🐳'), + ('lxcUpdate.dockerImagesChanged', '🐳'), + ('lxcUpdate.deferredTargets', '⏳'), + ('lxcUpdate.reason', '📝'), + ('lxcUpdate.restartRequired', '🔄'), + ('lxcUpdate.verificationPending', '⏳'), + ('lxcUpdate.verificationWarning', '⚠️'), + ('lxcUpdate.duration', '⏱️'), + ) + result = {} + for key, icon in definitions: + rendered = runtime_message(key, language, **values).strip() + if rendered: + result[rendered.split(':', 1)[0].strip()] = icon + return result + + def enrich_with_emojis(event_type: str, title: str, body: str, data: Dict[str, Any]) -> tuple: """Replace the plain title/body with emoji-enriched versions. @@ -2201,6 +2278,13 @@ def enrich_with_emojis(event_type: str, title: str, body: str, preprocessed = re.sub(r'^\n+', '', preprocessed) preprocessed = preprocessed.strip() + language = str(data.get('_notification_language') or 'en') + localized_labels = _localized_template_labels(event_type, language) + lxc_update_labels = ( + _lxc_update_label_icons(language) + if event_type == 'lxc_update_applied' else {} + ) + # ── Extended emoji mappings for health/disk messages ── HEALTH_EMOJI_MAP = { # Disk patterns @@ -2246,6 +2330,30 @@ def enrich_with_emojis(event_type: str, title: str, body: str, if health_enriched: continue + + # LXC update outcomes are assembled from structured details rather + # than one static template line. Their translated labels come from + # the runtime catalog, so keep the mapping semantic rather than + # comparing an English translation. + matched_lxc_label = next( + (label for label in lxc_update_labels + if stripped.lower().startswith(label.lower())), + None, + ) + if matched_lxc_label: + icon = lxc_update_labels[matched_lxc_label] + if not stripped.startswith(icon): + enriched_lines.append(f'{icon} {stripped}') + else: + enriched_lines.append(stripped) + continue + + # Docker's engine inventory is generated by the detector, not the + # translated template. "Docker Engine" is its product name and stays + # stable across locales, so it is safe to decorate directly. + if event_type == 'docker_stack_update_available' and stripped.startswith('• Docker Engine:'): + enriched_lines.append(f'🐳 {stripped}') + continue # Try to match "FieldName: value" patterns enriched = False @@ -2270,6 +2378,7 @@ def enrich_with_emojis(event_type: str, title: str, body: str, } if field_key in _LABEL_MAP: label_variants.append(_LABEL_MAP[field_key]) + label_variants.extend(localized_labels.get(field_key, [])) for label in label_variants: if stripped.lower().startswith(label.lower() + ':'): diff --git a/AppImage/scripts/tests/test_notification_runtime_i18n.py b/AppImage/scripts/tests/test_notification_runtime_i18n.py index 76908dc8..c9c5090c 100644 --- a/AppImage/scripts/tests/test_notification_runtime_i18n.py +++ b/AppImage/scripts/tests/test_notification_runtime_i18n.py @@ -154,6 +154,70 @@ class RuntimeCatalogTests(unittest.TestCase): self.assertNotIn("Source:", rendered["body"]) self.assertNotIn("succeeded", rendered["title"]) + _title, enriched_body = notification_templates.enrich_with_emojis( + "lxc_update_applied", rendered["title"], rendered["body"], + {**data, "_notification_language": "sk", "severity": "INFO"}, + ) + self.assertIn("🧭 Zdroj: Manuálne", enriched_body) + self.assertIn("🎯 Ciele: iVentoy", enriched_body) + self.assertIn("🧩 Aplikácie: iVentoy: 1.0.42 → 1.0.43", enriched_body) + self.assertIn("🔄 Vyžaduje sa reštart: nie", enriched_body) + self.assertIn("⏱️ Trvanie: 16s", enriched_body) + + for language in self.RUNTIME_LANGUAGES: + rendered_locale = notification_templates.render_template( + "lxc_update_applied", data, language=language, + ) + _title, body_locale = notification_templates.enrich_with_emojis( + "lxc_update_applied", rendered_locale["title"], rendered_locale["body"], + {**data, "_notification_language": language, "severity": "INFO"}, + ) + self.assertIn("🧭", body_locale, language) + self.assertIn("⏱️", body_locale, language) + + def test_update_summary_body_icons_follow_the_selected_language(self): + data = { + "hostname": "pve01", "total_count": "2", "security_count": "0", + "pve_count": "1", "kernel_count": "0", "important_list": "none", + "severity": "INFO", "_notification_language": "sk", + } + rendered = notification_templates.render_template("update_summary", data, language="sk") + _title, enriched_body = notification_templates.enrich_with_emojis( + "update_summary", rendered["title"], rendered["body"], data, + ) + self.assertIn("📦 Aktualizácie spolu: 2", enriched_body) + self.assertIn("🛡️ Bezpečnostné aktualizácie: 0", enriched_body) + self.assertIn("⚙️ Aktualizácie jadra: 0", enriched_body) + self.assertIn("📋 Dôležité balíky:", enriched_body) + + for language in self.RUNTIME_LANGUAGES: + locale_data = {**data, "_notification_language": language} + rendered_locale = notification_templates.render_template( + "update_summary", locale_data, language=language, + ) + _title, body_locale = notification_templates.enrich_with_emojis( + "update_summary", rendered_locale["title"], rendered_locale["body"], locale_data, + ) + total_label = notification_templates._localized_template_labels( + "update_summary", language, + )["total_count"][0] + self.assertIn(f"📦 {total_label}: 2", body_locale, language) + + def test_docker_update_body_icons_preserve_localized_container_label(self): + data = { + "hostname": "pve01", "vmid": "210", "ct_name": "repopulse-labs-test", + "count": "1", "details": "• Docker Engine: 29.8.0 → 29.8.1", + "severity": "INFO", "_notification_language": "sk", + } + rendered = notification_templates.render_template( + "docker_stack_update_available", data, language="sk", + ) + _title, enriched_body = notification_templates.enrich_with_emojis( + "docker_stack_update_available", rendered["title"], rendered["body"], data, + ) + self.assertIn("📦 Kontajner repopulse-labs-test (CT 210) má 1 aktualizácií Docker:", enriched_body) + self.assertIn("🐳 • Docker Engine: 29.8.0 → 29.8.1", enriched_body) + def test_missing_slovak_key_falls_back_to_english(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory)